Termux 运行 glibc 应用的非 proot 方案启示

在 Termux 里运行一个为桌面 Linux 编译的二进制文件,常常以 Exec format errorlibc.so.6: cannot open shared object file 收场。传统解法是用 proot-distro 装一整套 Debian。但如果只是想跑一两个 glibc 工具,还有一条更轻的路——glibc-runner。要理解它为什么更快、更省,得先看清问题出在哪一层。

问题的根源:两套 libc

Termux 跑在 Android 上,用的是 Android 的 Bionic libc;而桌面 Linux 的二进制链接的是 GNU C 库(glibc)。两者在 ABI、符号实现、动态链接器路径上都不一样。

一个 glibc 程序启动时,内核会去加载它 ELF 头里写死的解释器——在 ARM64 上通常是 /lib/ld-linux-aarch64.so.2。Termux 里根本没有这个文件,加载第一步就失败;即便绕过,它接着要找的 libc.so.6 也是 glibc 的版本,Bionic 顶替不了。问题的本质不在内核,而在用户态缺了正确的链接器和库

proot 的解法,以及它的代价

proot-distro 的思路是”补一整套系统”:装下完整的 Debian/Ubuntu rootfs,再用 proot 把它挂成根目录。proot 无需 root 权限,靠的是 ptrace——它拦截目标进程的每一次系统调用,在用户态把 /lib/etc 这类路径重写到 rootfs 内部,再放行。

代价因此有两层:

  • 空间:一套完整发行版动辄数百 MB 到数 GB;
  • 性能:每个 syscall 都要陷入 proot 兜一圈再返回,在 syscall 密集的负载下,这层拦截的开销相当可观。

换句话说,为了跑一个 glibc 程序,proot 把整套 Linux 系统连同一层 syscall 翻译都搬了进来——这远超实际所需。

glibc-runner 的思路:只补缺口,不做模拟

glibc-runner 是 Termux 社区的工具,它不模拟系统,而是精确地把 glibc 程序缺的那块补齐:

  1. 提供预编译的 glibc 库文件(libc.so.6ld-linux-aarch64.so.2 等);
  2. 通过 glibc 自己的动态链接器启动目标程序,并用 LD_LIBRARY_PATH 把库搜索路径指向这套 glibc,确保程序加载到的是 glibc 而非 Bionic;
  3. 程序运行起来后,系统调用直接发往 Android 内核,没有 proot 那层 ptrace 拦截。

关键在第 3 点。Android 内核本身就是 Linux 内核,绝大多数 syscall 它都认。既然 glibc 程序真正缺的只是”正确的库 + 正确的链接器”,那么把这两样补上、让 syscall 直达内核,整个虚拟化层就被省掉了。

它的全部优势都源自这一点:

  • 轻量——只需 glibc 库(约 10–20MB),不必拉完整发行版;
  • 高效——syscall 直达内核,无 proot 中转;
  • 简单——仍是单一 Termux 环境,无需管理容器。

而它的边界同样源自这一点:glibc-runner 只补了”库”这一层,凡是程序还指望底下存在一套完整 Linux 系统的地方,它都无能为力。这一点在最后一节会再回到。

实际操作

1. 安装

Terminal window
# 安装 glibc-repo(添加 glibc-packages 仓库源)
pkg install -y glibc-repo
# 刷新包列表
pkg update
# 安装 glibc-runner
pkg install -y glibc-runner
# 验证
grun --version

glibc-repo 是元包,安装后会在 $PREFIX/etc/apt/sources.list.d/ 写入 glibc-packages 仓库;必须先装它并 pkg update,否则 glibc-runner 不可见。grun 就是用来运行 glibc 二进制的命令。

2. 基本用法

Terminal window
# 直接运行
grun ./myapp --help
# 传参
grun ./myapp arg1 arg2
# 配合管道
echo "test" | grun ./myapp

3. 包装脚本(推荐)

为省去每次手敲 grun,可包一层脚本:

cat > $PREFIX/bin/myapp << 'EOF'
#!/data/data/com.termux/files/usr/bin/sh
exec grun /path/to/real/myapp "$@"
EOF
chmod 755 $PREFIX/bin/myapp
myapp --help

4. 运行前先确认 ELF 类型

承接前面的原理——只有 ARM64 的 glibc ELF 才能这样跑,所以运行前值得先验明正身:

Terminal window
# 架构与类型
file ./myapp
# 期望:ELF 64-bit LSB executable, ARM aarch64, ...
# 动态链接器
readelf -l ./myapp | grep interpreter
# 期望含:/lib/ld-linux-aarch64.so.2 或类似

也可以手动核对 ELF 头:

Terminal window
# magic(应为 7f454c46)
od -An -tx1 -N4 ./myapp | tr -d ' \n'
# e_machine 字段(offset 18,ARM64 = 0xb7)
od -An -tx1 -j18 -N1 ./myapp | tr -d ' \n'

5. 处理子进程

如果程序会拉起其他 glibc 二进制作为子进程,这些子进程同样绕不开链接器问题,得各自包一层:

mv vendor/tool vendor/tool.real
cat > vendor/tool << 'EOF'
#!/data/data/com.termux/files/usr/bin/sh
exec grun "$(dirname "$0")/tool.real" "$@"
EOF
chmod 755 vendor/tool

若 Termux 自带等价的原生工具,直接替换更省事——原生工具走 Bionic,连 grun 都不必:

Terminal window
pkg install -y ripgrep
rm -f vendor/rg
ln -s $(command -v rg) vendor/rg

6. 环境变量

Terminal window
# 运行前设置
export MY_CONFIG=/path/to/config
grun ./myapp
# 或写进包装脚本
cat > $PREFIX/bin/myapp << 'EOF'
#!/data/data/com.termux/files/usr/bin/sh
export MY_CONFIG=/path/to/config
exec grun /path/to/real/myapp "$@"
EOF

自动化脚本模板

把上述步骤串起来,得到一个可复用的安装脚本:

#!/data/data/com.termux/files/usr/bin/bash
set -euo pipefail
readonly APP_NAME="myapp"
readonly APP_BINARY="/path/to/glibc/binary"
readonly WRAPPER_PATH="$PREFIX/bin/$APP_NAME"
# 检查 Termux 环境
if [ ! -d "$PREFIX" ]; then
echo "Error: Must run in Termux"
exit 1
fi
# 安装 glibc-runner
echo "Installing glibc-runner..."
pkg install -y glibc-repo
pkg update
pkg install -y glibc-runner
# 验证二进制文件
if [ ! -f "$APP_BINARY" ]; then
echo "Error: Binary not found: $APP_BINARY"
exit 1
fi
# 检查 ELF magic
magic=$(od -An -tx1 -N4 "$APP_BINARY" 2>/dev/null | tr -d ' \n')
if [ "$magic" != "7f454c46" ]; then
echo "Error: Not a valid ELF file"
exit 1
fi
# 创建包装脚本
cat > "$WRAPPER_PATH" << EOF
#!/data/data/com.termux/files/usr/bin/sh
exec grun "$APP_BINARY" "\$@"
EOF
chmod 755 "$WRAPPER_PATH"
# 验证安装
echo "Verifying installation..."
grun "$APP_BINARY" --version
"$WRAPPER_PATH" --version
echo "Installation complete!"
echo "Run with: $APP_NAME"

用数据印证

如果”省掉虚拟化层”的推断成立,差距应当能直接量出来。在一台 ARM64 Android 设备上跑同一个中等大小的 glibc 应用,对比如下:

方案启动时间内存占用磁盘占用
proot-distro (Debian)~3.5s~180MB~850MB
glibc-runner~1.2s~120MB~15MB(仅 glibc)

启动提速约 65%、内存降约 33%、磁盘降约 98%——磁盘那一项的悬殊,正对应”一套完整发行版”与”一组库文件”的体量之差;启动与内存的差距,则来自缺席的那层 ptrace 拦截。

边界由原理决定

正因为 glibc-runner 只补”库”这一层,它的限制几乎可以从原理直接推出:

  • 复杂依赖会失灵:程序若依赖大量 glibc 专有库,或需要 systemd、dbus 这类系统服务,光有库不够,可能仍得回到 proot。
  • 混用带来排查成本:glibc 与 Bionic 在同一环境共存,一旦出问题,定位往往比单一环境更费劲。
  • syscall 行为可能有差异:syscall 虽直达内核,但 Android 内核在某些调用上的行为未必和标准 Linux 完全一致,边角场景需实测。

对应地,它的最佳适用场景是:运行单个或少量 glibc 二进制(闭源工具、预编译开发工具等)、不需要发行版级别的包管理、且对性能与空间敏感的情况。

故障排查

Exec format error,提示找不到动态链接器。 多半不是 ARM64 架构。用 file 核实:

Terminal window
file ./myapp
# 期望:ELF 64-bit LSB executable, ARM aarch64, ...

error while loading shared libraries: libc.so.6 glibc 库缺失或损坏,重装即可:

Terminal window
pkg reinstall glibc-runner
grun --version

子进程调用失败。 排查应用内部拉起的子进程是否也是 glibc 二进制,是则按前文为它们逐一包 grun

Terminal window
# 列出应用目录下所有 ELF
find /path/to/app -type f -exec file {} \; | grep ELF

小结

glibc-runner 给出的启示,是把问题精确地定位到”用户态缺少正确的库与链接器”这一层,并只在这一层动手——补齐库、让 syscall 直达内核,从而绕开 proot 整套虚拟化的体量与开销。对于只需运行少量 glibc 二进制的场景,它比 proot-distro 更轻、更快;而一旦应用真正依赖一套完整的 Linux 系统,proot 仍是更稳妥的退路。

参考资源