ADB shell机制
运行下面三条命令,表面上都是“在设备上执行命令”:
adb shell id
adb shell
printf 'getprop\nexit\n' | adb shell但它们在 Android 17 的 ADB 中并不走完全相同的路径。第一条带有远端命令,通常选择非交互 raw 模式;第二条没有命令,需要交互式 PTY;第三条虽然也没有终端输入,却仍然要把本地管道的数据送进 远端 shell。adb shell 的正确模型不是“把字符串发到设备”,而是 client 先根据本地终端和设备 feature 选择传输能力,adbd 再根据 service 参数建立子进程和数据通道。
本文面向已经读过 ADB架构 的读者。你需要知道 adb client、host server、 transport、adbd 和 shell: service 的基本边界,但不需要预先知道 PTY 或 shell protocol 的实现。 本文只解释 shell 通道本身,不展开 logcat、dumpsys、am/pm/wm/svc、文件同步和端口转发;这些 主题会在后续文章分别处理。
继续学习时,可阅读 ADB logcat进阶 追踪日志读取通道,或阅读 dumpsys诊断 追踪 shell 入口之后的 Binder dump 消费者。
读完后,你应该能从 adb shell -T 'printf ...' 追踪到 adb_shell()、ShellServiceString()、 adbd 的 ShellService() 和 StartSubprocess(),解释为什么 stdout/stderr 是否分离、退出码是否可见、 stdin 是否可以关闭,不能只由命令文本决定。
1. 区分命令
1.1 三个独立选择
ADB shell 有三个经常被混为一谈的选择:
| 选择 | 代表值 | 决定什么 | 由谁最终确认 |
|---|---|---|---|
| 命令是否为空 | adb shell / adb shell id | 是否进入交互 shell,设备端运行什么命令 | client 拼接 service,adbd 创建进程 |
| 子进程 I/O 类型 | pty / raw | 是否使用伪终端,终端换行、回显和 stdout/stderr 合并方式 | client 建议,adbd ShellService 解析 |
| shell protocol | v2 / 无 | 是否使用带 packet id 的 stdin、stdout、stderr、exit 通道 | client 根据 shell_v2 feature 决定 |
PTY 是子进程的 I/O 设备,shell protocol 是 ADB 在 socket 上封装数据的协议。二者可以组合:Android 17 测试同时覆盖“PTY + shell protocol”和“raw + shell protocol”。因此,“使用 shell protocol”不 等于“远端一定是 raw”,也不等于“stdout 和 stderr 一定分开”。
图中的顺序很重要:client 先选择是否使用协议和哪种终端类型,再把结果编码进 service;adbd 不会 从“命令看起来像脚本”推断协议,而是解析这些明确参数。
1.2 实践路径
下面三类调用足以覆盖本文的主要机制:
| 场景 | 命令形式 | 通常的 client 选择 | 重点观察 |
|---|---|---|---|
| 单次命令 | adb shell id | shell protocol + raw | stdout、stderr 和退出码 |
| 交互会话 | adb shell | shell protocol + PTY | stdin packet、回显、窗口变化 |
| 管道输入 | printf 'id\n' | adb shell | shell protocol + raw | 本地 stdin EOF 与 close-stdin |
“通常”不是无条件保证。设备没有 shell_v2 时,client 必须退回兼容路径;显式 -x 也会关闭远端 退出码和 stdout/stderr 分离。判断实际行为时,应同时看 client feature 和最终 service 字符串。
版本与兼容边界
本文依据 android-17.0.0_r1 的 packages/modules/adb。shell_v2、-T/-t 的默认选择和退出 码处理都属于 ADB 实现细节;不能把旧版 ADB 的行为或某个厂商 adbd 的额外 service 直接外推到所有设备。
2. shell模式
2.1 adb_shell()
Android 17 的入口是 packages/modules/adb/client/commandline.cpp 中的 adb_shell()。它先读取设备 feature,再建立两个默认值:支持 shell_v2 时使用 shell protocol,PTY 分配模式为自动;不支持时 则为兼容旧设备准备强制 PTY。
源码文件:packages/modules/adb/client/commandline.cpp
相关函数/类型:adb_shell
enum PtyAllocationMode { kPtyAuto, kPtyNo, kPtyYes, kPtyDefinitely };
auto&& features = adb_get_feature_set_or_die();
bool use_shell_protocol = CanUseFeature(*features, kFeatureShell2);
PtyAllocationMode tty = use_shell_protocol ? kPtyAuto : kPtyDefinitely;
while ((opt = getopt(argc, const_cast<char**>(argv), "+e:ntTx")) != -1) {
switch (opt) {
case 'n':
close_stdin();
break;
case 'x':
use_shell_protocol = false;
tty = kPtyDefinitely;
escape_char = '~';
break;
case 't':
tty = (tty >= kPtyYes) ? kPtyDefinitely : kPtyYes;
break;
case 'T':
tty = kPtyNo;
break;
}
}这里有两个容易漏掉的设计点。第一,-x 不是“换一种输出格式”,而是回到历史兼容语义:关闭 shell protocol,恢复默认的 PTY 行为和 ~ escape 字符。第二,多个 -t 是累积的;单个 -t 只要求在 stdin 是终端时分配 PTY,-tt 才能在非终端输入下强制 PTY。
2.2 交互模式判定
源码文件:packages/modules/adb/client/commandline.cpp
相关函数/类型:adb_shell
bool is_interactive = (optind == argc);
std::string shell_type_arg = kShellServiceArgPty;
if (tty == kPtyNo) {
shell_type_arg = kShellServiceArgRaw;
} else if (tty == kPtyAuto) {
if (!unix_isatty(STDIN_FILENO) || !is_interactive) {
shell_type_arg = kShellServiceArgRaw;
}
} else if (tty == kPtyYes) {
if (!unix_isatty(STDIN_FILENO)) {
fprintf(stderr,
"Remote PTY will not be allocated because stdin is not a terminal.\n");
shell_type_arg = kShellServiceArgRaw;
}
}
std::string command;
if (optind < argc) {
// 说明:这里不做 shell 转义,参数会作为一个命令字符串交给远端 shell。
command = android::base::Join(
std::vector<const char*>(argv + optind, argv + argc), ' ');
}is_interactive 只表示命令行后面是否还有 command 参数;它不是“本地 stdin 是否为 TTY”。因此 adb shell、adb shell < script 和 adb shell id 需要分别看 command 是否为空以及 stdin 是否为 终端。自动模式下,只要 stdin 不是 TTY 或 command 非空,client 就倾向 raw。
不要把本地引号当成 ADB 协议字段
client 使用 android::base::Join 把剩余参数拼成命令字符串,没有在这里替你做远端 shell 转义。 例如本地 shell 先处理一层引号,设备端 shell 还可能再处理变量、管道、重定向和通配符。需要分析 命令含义时,要明确是哪一个 shell 在解析,而不是把 adb 参数数组想象成远端 execve 的 argv 数组。
2.3 service字符串
源码文件:packages/modules/adb/client/commandline.cpp
相关函数/类型:ShellServiceString
static std::string ShellServiceString(bool use_shell_protocol,
const std::string& type_arg,
const std::string& command) {
std::vector<std::string> args;
if (use_shell_protocol) {
args.push_back(kShellServiceArgShellProtocol);
const char* terminal_type = getenv("TERM");
if (terminal_type != nullptr) {
args.push_back(std::string("TERM=") + terminal_type);
}
}
if (!type_arg.empty()) {
args.push_back(type_arg);
}
// 说明:service 的参数区和命令区用第一个 ':' 分隔。
return android::base::StringPrintf("shell%s%s:%s",
args.empty() ? "" : ",",
android::base::Join(args, ',').c_str(),
command.c_str());
}典型结果可以写成:
| 调用 | service 形态 | 解释 |
|---|---|---|
adb shell id | shell,v2,raw:id | v2 协议、raw、命令为 id |
adb shell | shell,v2,pty,TERM=xterm: | v2 协议、PTY、空命令表示启动交互 shell |
adb shell -x id | shell:id | 关闭 v2,回到兼容的旧协议形态 |
这里的 TERM= 是 shell service 参数,不是独立的 ADB packet。adbd 会把它读成子进程的终端类型; 如果 service 中出现未知参数,Android 17 的 ShellService() 会记录 warning 并继续,这为将来扩展 保留了空间。
3. adbd 与子进程
3.1 ShellService()
设备端入口位于 packages/modules/adb/daemon/services.cpp。它找到第一个 :,把前半部分按逗号拆成 参数,把后半部分保留为 command。默认规则直接写在源码中:空 command 使用 PTY,非空 command 使用 raw;协议默认关闭,TERM 默认是 dumb。
源码文件:packages/modules/adb/daemon/services.cpp
相关函数/类型:ShellService
size_t delimiter_index = args.find(':');
if (delimiter_index == std::string::npos) {
LOG(ERROR) << "No ':' found in shell service arguments: " << args;
return unique_fd{};
}
std::string service_args(args.substr(0, delimiter_index));
std::string command(args.substr(delimiter_index + 1));
SubprocessType type(command.empty() ? SubprocessType::kPty : SubprocessType::kRaw);
SubprocessProtocol protocol = SubprocessProtocol::kNone;
std::string terminal_type = "dumb";
for (const std::string& arg : android::base::Split(service_args, ",")) {
if (arg == kShellServiceArgRaw) {
type = SubprocessType::kRaw;
} else if (arg == kShellServiceArgPty) {
type = SubprocessType::kPty;
} else if (arg == kShellServiceArgShellProtocol) {
protocol = SubprocessProtocol::kShell;
} else if (arg.starts_with("TERM=")) {
terminal_type = arg.substr(strlen("TERM="));
} else if (!arg.empty()) {
// 说明:未知参数不使整个 service 失败,便于未来增加参数。
LOG(WARNING) << "Ignoring unknown shell service argument: " << arg;
}
}
return StartSubprocess(command, terminal_type.c_str(), type, protocol);这段代码解释了一个常见误判:adb shell id 不是直接在 adbd 进程里执行 id,而是由 StartSubprocess() 建立独立子进程。adbd 的 service socket 只负责连接子进程的输入输出和生命周期。
3.2 protocol
daemon/shell_service.cpp 的文件头给出了实现层的行为矩阵:
| 子进程类型 | 协议 | stdout/stderr | 远端退出码 |
|---|---|---|---|
| PTY | 无 | 合并 | 不单独传回 |
| raw | 无 | 合并 | 不单独传回 |
| PTY | shell protocol | PTY 仍合并 | 通过 exit packet 传回 |
| raw | shell protocol | 分离 | 通过 exit packet 传回 |
“raw + 无协议”看起来像最直接的管道,但实现不会真的创建一个裸 pipe 后放任进程继续运行: StartSubprocess() 会把这种组合改成“raw 模式的 PTY”,因为 PTY 关闭时可以向子进程发送 SIGHUP, 避免某些不读取或不写入 pipe 的进程永远不知道连接已经断开。
源码文件:packages/modules/adb/daemon/shell_service.cpp
相关函数/类型:StartSubprocess
bool make_pty_raw = false;
if (protocol == SubprocessProtocol::kNone && type == SubprocessType::kRaw) {
// 说明:没有 shell protocol 时借助 PTY 的关闭语义回收子进程。
type = SubprocessType::kPty;
make_pty_raw = true;
}
unique_fd error_fd;
unique_fd fd = StartSubprocess(std::move(name), terminal_type, type, protocol,
make_pty_raw, protocol, &error_fd);
if (fd == -1) {
return error_fd;
}
return fd;因此 -x 的影响比“旧格式没有退出码”更深:它会让 client 失去独立的 stderr 和 exit 通道,adbd 还要使用 PTY 的关闭行为维持子进程生命周期。
4. shell传输
4.1 packet 边界
ADB 架构文章中的 A_OPEN/A_WRTE/A_CLSE 是 transport 层 packet;shell protocol 位于远端 service 建立之后的字节流上,使用自己的 packet 头。ShellProtocol 的头部是 1 字节 id 加 4 字节长度:
源码文件:packages/modules/adb/shell_protocol.h
相关函数/类型:ShellProtocol::Id
enum Id : uint8_t {
kIdStdin = 0,
kIdStdout = 1,
kIdStderr = 2,
kIdExit = 3,
kIdCloseStdin = 4,
kIdWindowSizeChange = 5,
kIdInvalid = 255,
};
// 说明:packet 头是 id + uint32_t length,数据区紧随其后。
enum {
kHeaderSize = sizeof(Id) + sizeof(length_t)
};| id | 方向 | 数据 | 消费者 |
|---|---|---|---|
| 0 | client → adbd | stdin 字节 | 远端子进程 stdin |
| 1 | adbd → client | stdout 字节 | 本地 stdout |
| 2 | adbd → client | stderr 字节 | 本地 stderr |
| 3 | adbd → client | 1 字节退出码 | RemoteShell() 返回值 |
| 4 | client → adbd | 空 payload | raw 子进程关闭 stdin 写端 |
| 5 | client → adbd | rows,cols,x,y 文本 | PTY 窗口大小 |
Read() 和 Write() 都以完整 packet 为单位工作;如果一次 read 不能装下完整 payload,读取端会在 后续调用中继续填充。这是 service 层的 framing,不应和 smart socket 的四位十六进制长度前缀混淆。
4.2 adbd线程
使用 shell protocol 后,adbd 的 Subprocess::PassDataStreams() 同时监视子进程 stdout、stderr 和 client protocol fd。stdout 使用 kIdStdout,stderr 使用 kIdStderr;client 发来的 stdin packet 则写入子进程 stdin。
源码文件:packages/modules/adb/daemon/shell_service.cpp
相关函数/类型:Subprocess::PassDataStreams
pfds.stdinout_pfd() = {.fd = stdinout_sfd_.get(), .events = POLLIN};
pfds.stderr_pfd() = {.fd = stderr_sfd_.get(), .events = POLLIN};
pfds.protocol_pfd() = {.fd = protocol_sfd_.get(), .events = POLLIN};
while (protocol_sfd_ != -1 && (stdinout_sfd_ != -1 || stderr_sfd_ != -1)) {
unique_fd* dead_sfd = PollLoop(&pfds);
if (dead_sfd) {
// 说明:先标记对应 fd 失效,再由管理线程完成子进程和 socket 的收尾。
dead_sfd->reset();
}
}PassOutput() 读取某个 fd 后把传入的 id 写进 protocol;PassInput() 则只在前一个 packet 的数据 全部写入 stdin 后才读取下一个 packet。这个顺序保证了一个大 stdin packet 不会被下一个 packet 的 数据覆盖,也避免把窗口变化误当成普通输入。
4.3 exit packet
子进程结束后,管理线程调用 waitpid() 得到正常退出码或信号退出码。只要 protocol fd 仍然打开, 它会写一个 kIdExit packet,然后关闭 protocol fd:
源码文件:packages/modules/adb/daemon/shell_service.cpp
相关函数/类型:Subprocess::WaitForExit
int exit_code = 1;
if (WIFSIGNALED(status)) {
exit_code = 0x80 | WTERMSIG(status);
} else if (WIFEXITED(status)) {
exit_code = WEXITSTATUS(status);
}
if (protocol_sfd_ != -1) {
output_->data()[0] = exit_code;
output_->Write(ShellProtocol::kIdExit, 1);
protocol_sfd_.reset(-1);
}因此“终端看到了输出”不等于“命令成功”。脚本应使用 client 的退出状态判断结果;如果强制使用 -x 或连接到不支持 shell protocol 的旧设备,退出码不会通过独立 packet 返回。
5. 交互 shell
5.1 kIdStdin
没有 command 时,adbd 启动空 name 的 shell 子进程。client 的 stdin_read_thread_loop() 在独立线程 中读取本地 stdin;如果使用协议,就把每次读取包装为 kIdStdin。交互模式还会发送初始窗口大小, 收到 SIGWINCH 时再次发送。
源码文件:packages/modules/adb/client/commandline.cpp
相关函数/类型:stdin_read_thread_loop
send_window_size_change(args->stdin_fd, args->protocol);
while (true) {
int r = unix_read_interruptible(args->stdin_fd, buffer_ptr, buffer_size);
if (r == -1 && errno == EINTR) {
send_window_size_change(args->stdin_fd, args->protocol);
continue;
}
if (r <= 0) {
if (args->protocol) {
args->protocol->Write(ShellProtocol::kIdCloseStdin, 0);
}
break;
}
if (args->protocol) {
args->protocol->Write(ShellProtocol::kIdStdin, r);
}
}管道输入和交互终端因此有一个关键区别:管道读到 EOF 后,client 可以发送 close-stdin;PTY 不能 简单关闭输入方向,否则可能丢失尚未读取的输出。adbd 对 PTY 的 close-stdin 只保留 fd,并建议让 远端 shell 自己执行 exit 或由 client 进程结束来终止会话。
5.2 交互入口
raw stdin 模式下,默认 escape 字符是 ~。client 在行首遇到 ~. 时打印断开信息并退出;这不是 远端 shell 收到的两个普通字符。-e none 会关闭这层处理,适合命令本身需要保留行首 ~ 的场景。
这也说明了为什么调试“输入没有到设备”时要先区分三层:本地终端的行规程、client 的 escape 解析、 shell protocol 的 kIdStdin packet。看到远端没有字符,不一定是 adbd 丢包。
5.3 输出
PTY 让远端程序认为自己连接着终端,可能启用回显、换行转换、颜色或分页;同时 stdout 和 stderr 共享同一个 PTY 输出。raw + shell protocol 更适合脚本,因为输出不会经过 PTY 的终端处理,并且 stderr 和退出码都有独立通道。
6. Shell测试
6.1 Shell验证
daemon/shell_service_test.cpp 没有只测“命令能否启动”,而是用同一个命令对照四种组合:
源码文件:packages/modules/adb/daemon/shell_service_test.cpp
相关函数/类型:RawShellProtocolSubprocess
StartTestSubprocess(
"echo foo; echo bar >&2; echo baz; exit 24",
SubprocessType::kRaw, SubprocessProtocol::kShell);
std::string stdout, stderr;
EXPECT_EQ(24, ReadShellProtocol(command_fd_, &stdout, &stderr));
ExpectLinesEqual(stdout, {"foo", "baz"});
ExpectLinesEqual(stderr, {"bar"});同文件的 PtyShellProtocolSubprocess 使用 PTY 和 shell protocol,断言退出码为 50,但 stderr 为空、 bar 出现在 stdout 中。这证明协议可以传递退出码,但不能抵消 PTY 本身合并两个输出流的语义。
6.2 EOF验证
InteractivePtySubprocess 通过 ShellProtocol::kIdStdin 写入三条带换行的命令,验证变量赋值、回显 和展开结果;CloseClientStdin 则发送一段没有末尾换行的输入,随后发送 kIdCloseStdin,断言 cat 能读到完整内容并继续输出。这两类测试分别锁定“换行完成一条交互命令”和“EOF 不等于丢弃 最后一个 packet”这两个边界。
CloseStdinStdoutSubprocess 与 CloseStderrSubprocess 还主动关闭子进程的 fd,确认另一条输出流 仍能被读取。它们证明的是管理线程的收尾能力,不是所有子进程都必须同时保持三个 fd 打开。
6.3 测试辅助函数
源码文件:packages/modules/adb/test_utils/test_utils.cpp
相关函数/类型:ReadShellProtocol
int exit_code = -1;
while (protocol->Read()) {
switch (protocol->id()) {
case ShellProtocol::kIdStdout:
std_out->append(protocol->data(), protocol->data_length());
break;
case ShellProtocol::kIdStderr:
std_err->append(protocol->data(), protocol->data_length());
break;
case ShellProtocol::kIdExit:
EXPECT_EQ(1u, protocol->data_length());
exit_code = protocol->data()[0];
break;
}
}
return exit_code;这个辅助函数明确了测试的判定方式:stdout 和 stderr 按 packet id 归并,exit 必须是一个字节;如果 连接关闭前没有收到 exit packet,返回值保持 -1。所以测试中的“退出码正确”不是读取本地进程的 返回值,而是验证协议真的发送了 kIdExit。
7. 命令结果
本文不列出所有 shell 命令,而是把命令按它们在验证链中的作用分组:
| 目的 | 示例 | 应观察什么 | 不要据此推出什么 |
|---|---|---|---|
| 验证 stdout 与退出码 | adb shell 'printf out; exit 7' | 输出与 client 返回状态 | 不代表 PTY 下 stderr 一定分离 |
| 验证 stderr | adb shell 'printf err >&2' | shell protocol 是否把错误流交给本地 stderr | 不代表命令失败一定有 stderr |
| 验证 stdin | printf 'id\nexit\n' | adb shell | EOF、输入 packet、shell 退出 | 不代表每个设备都使用相同 shell 实现 |
| 验证 PTY | adb shell -t 'test -t 0' | 远端 fd 是否认为自己连接终端 | 不代表颜色、分页和行规程完全一致 |
| 验证 raw | adb shell -T 'test -t 0' | 远端是否没有 PTY | 不代表 shell protocol 被关闭 |
命令中的引号属于本地 shell 和远端 shell 的共同边界。例如上表的 exit 7 要作为远端 shell 的一段 命令执行,不能把它理解为 adb 直接调用一个名为 exit 7 的设备程序。对脚本来说,最可靠的 结果记录是同时记录:client 选项、目标 feature、实际命令、stdout、stderr 和退出状态。
实践时先做最小可观测命令
排查 shell 通道时,先使用 printf、test -t 0、exit N 这类不会依赖 framework 服务的命令, 再进入 getprop、ps 或具体系统服务。这样可以把“shell 通道故障”和“目标命令本身不存在、权限 不足或输出格式变化”分开。
8. Shell 现象定位
遇到“输出看起来成功但脚本判断失败”“stderr 混进 stdout”“管道输入后 shell 不退出”等现象时, 可以按下面的顺序定位:
- 在
client/commandline.cpp::adb_shell确认是否启用了shell_v2,以及-x/-t/-T/-n是否改变 了默认值。 - 在
ShellServiceString()查看最终的shell[,args]:command,确认 command 区和参数区的分隔。 - 在
daemon/services.cpp::ShellService确认 adbd 解析出的SubprocessType、SubprocessProtocol和TERM。 - 在
daemon/shell_service.cpp::PassInput/PassOutput/WaitForExit追踪 packet id、fd 关闭和 exit packet。 - 用
daemon/shell_service_test.cpp中最接近的测试作为对应测试:输出分流看 raw/PTY protocol 对照, EOF 看CloseClientStdin,异常 fd 看两个 close 测试。
这条路径把“ADB shell 是不是一个终端”拆成了几个可以分别验证的问题:谁选择了 PTY,谁封装了 packet, 谁拥有子进程,谁写入退出码,谁在连接关闭后回收资源。只要保持这些 owner 边界,后续阅读 logcat、 dumpsys 或应用安装时,就不会把具体命令的行为误当成 ADB shell 通道本身的保证。
