Skip to content

ADB shell机制

追踪 adb shell 从客户端参数选择到 adbd 子进程、PTY/raw、shell protocol、标准流和退出码的完整路径,并给出可解释的命令实践。

基于android-17.0.0_r1
AndroidADBShellPTYShell Protocol

ADB shell机制 ​

运行下面三条命令,表面上都是“在设备上执行命令”:

bash
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 protocolv2 / 无是否使用带 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 idshell protocol + rawstdout、stderr 和退出码
交互会话adb shellshell protocol + PTYstdin packet、回显、窗口变化
管道输入printf 'id\n' | adb shellshell 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

cpp
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

cpp
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

cpp
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 idshell,v2,raw:idv2 协议、raw、命令为 id
adb shellshell,v2,pty,TERM=xterm:v2 协议、PTY、空命令表示启动交互 shell
adb shell -x idshell: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

cpp
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无合并不单独传回
PTYshell protocolPTY 仍合并通过 exit packet 传回
rawshell protocol分离通过 exit packet 传回

“raw + 无协议”看起来像最直接的管道,但实现不会真的创建一个裸 pipe 后放任进程继续运行: StartSubprocess() 会把这种组合改成“raw 模式的 PTY”,因为 PTY 关闭时可以向子进程发送 SIGHUP, 避免某些不读取或不写入 pipe 的进程永远不知道连接已经断开。

源码文件:packages/modules/adb/daemon/shell_service.cpp

相关函数/类型:StartSubprocess

cpp
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

cpp
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方向数据消费者
0client → adbdstdin 字节远端子进程 stdin
1adbd → clientstdout 字节本地 stdout
2adbd → clientstderr 字节本地 stderr
3adbd → client1 字节退出码RemoteShell() 返回值
4client → adbd空 payloadraw 子进程关闭 stdin 写端
5client → adbdrows,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

cpp
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

cpp
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

cpp
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

cpp
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

cpp
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 一定分离
验证 stderradb shell 'printf err >&2'shell protocol 是否把错误流交给本地 stderr不代表命令失败一定有 stderr
验证 stdinprintf 'id\nexit\n' | adb shellEOF、输入 packet、shell 退出不代表每个设备都使用相同 shell 实现
验证 PTYadb shell -t 'test -t 0'远端 fd 是否认为自己连接终端不代表颜色、分页和行规程完全一致
验证 rawadb 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 不退出”等现象时, 可以按下面的顺序定位:

  1. 在 client/commandline.cpp::adb_shell 确认是否启用了 shell_v2,以及 -x/-t/-T/-n 是否改变 了默认值。
  2. 在 ShellServiceString() 查看最终的 shell[,args]:command,确认 command 区和参数区的分隔。
  3. 在 daemon/services.cpp::ShellService 确认 adbd 解析出的 SubprocessType、SubprocessProtocol 和 TERM。
  4. 在 daemon/shell_service.cpp::PassInput/PassOutput/WaitForExit 追踪 packet id、fd 关闭和 exit packet。
  5. 用 daemon/shell_service_test.cpp 中最接近的测试作为对应测试:输出分流看 raw/PTY protocol 对照, EOF 看 CloseClientStdin,异常 fd 看两个 close 测试。

这条路径把“ADB shell 是不是一个终端”拆成了几个可以分别验证的问题:谁选择了 PTY,谁封装了 packet, 谁拥有子进程,谁写入退出码,谁在连接关闭后回收资源。只要保持这些 owner 边界,后续阅读 logcat、 dumpsys 或应用安装时,就不会把具体命令的行为误当成 ADB shell 通道本身的保证。