客户端开发

同一个 Native 动态库,为什么 Linux 能加载、Android 却失败?

通过最小 C 动态库复现与 readelf / ldd 产物检查,解释 Linux 宿主全局符号范围如何掩盖缺失的 DT_NEEDED 依赖,以及 Android 命名空间隔离为何直接报错。

约 11 分钟读完
目录
  1. 1. 最小复现:两行 C 代码还原跨平台差异
  2. 2. 编译产物中的 DT_NEEDED 与未解析符号对照
  3. 3. CMake 修复写法与构建期防漏选项
  4. 4. 排查顺序与平台工具边界

在给 Dart FFI 或 Flutter 插件编译跨平台 C/C++ 动态库(.so)时,遇到过一个初看很反直觉的现象:同一份源码和同一套 CMake 构建逻辑,在 Linux 桌面端加载运行完全正常,测试也全部通过;但交叉编译成 Android .so 打进安装包后,一调用 DynamicLibrary.open(底层即 dlopen)就直接抛出加载失败,提示找不到某个很基础的标准库函数(例如数学库里的 pow、expf 或压缩库符号)。

遇到这种“桌面端没有问题、在 Android 上出现问题”的情况,第一反应往往是去怀疑 Android NDK 版本、APK 打包目录或者 Flutter 引擎的加载器差异。但把编译出来的 .so 产物拆开核对动态段元数据后就会发现:问题从一开始就出在这个动态库自己身上——它直接调用了外部库的符号,却没有在 ELF 的 DT_NEEDED 中声明直接依赖,只是在 Linux 桌面端恰好使用了宿主进程已经加载进全局符号表的同名库。

1. 最小复现:两行 C 代码还原跨平台差异

不需要引入庞大的业务工程,用两段十几行的 C 代码就能在本地完整复现这个现象(以下实验在 Ubuntu 26.04、gcc 15.2.0、GNU readelf 2.46、glibc 2.43 下实测验证)。

假设动态库 calc.c 里只有一个计算分贝衰减系数的函数,内部调用了 <math.h> 的 pow:

// calc.c
#include <math.h>

double attenuate_gain(double db) {
  return pow(10.0, db / 20.0);
}

链接共享库时,默认情况下链接器允许 .so 保留未定义符号(留待运行时重定位解析)。我们分别用“漏写 -lm”和“显式声明 -lm”两种方式编译它:

# 未声明 libm 直接依赖(问题产物)
gcc -shared -fPIC -O2 calc.c -o libcalc_broken.so

# 显式声明 libm 直接依赖(修复产物)
gcc -shared -fPIC -O2 calc.c -lm -o libcalc_fixed.so

接下来准备两个极简宿主程序来加载它们:

  1. 模拟复杂桌面宿主(loader_desktop_like):像 Flutter Linux Runner、JVM 或 Python 解释器这类宿主,自身启动时就已经链接或以 RTLD_GLOBAL 方式加载了 libm.so.6;
  2. 模拟干净或隔离的加载环境(loader_clean):宿主自身不链接 libm,仅通过 dlopen(path, RTLD_NOW | RTLD_LOCAL) 按动态库自己的依赖契约加载。
// loader_clean.c
#include <dlfcn.h>
#include <stdio.h>

int main(int argc, char** argv) {
  void* h = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
  if (!h) {
    printf("FAIL: %s\n", dlerror());
    return 1;
  }
  double (*fn)(double) = dlsym(h, "attenuate_gain");
  printf("OK: attenuate_gain(-6.0) = %.6f\n", fn(-6.0));
  dlclose(h);
  return 0;
}

分别运行两个宿主加载同一个 libcalc_broken.so,再加载修复后的 libcalc_fixed.so,终端输出如下:

$ ./loader_desktop_like ./libcalc_broken.so
OK: attenuate_gain(-6.0) = 0.501187

$ ./loader_clean ./libcalc_broken.so
FAIL: ./libcalc_broken.so: undefined symbol: pow

$ ./loader_clean ./libcalc_fixed.so
OK: attenuate_gain(-6.0) = 0.501187

同一个 libcalc_broken.so 字节未改,在第一个宿主里算出了 0.501187,换到第二个宿主里立刻报 undefined symbol: pow。

2. 编译产物中的 DT_NEEDED 与未解析符号对照

使用 readelf 与 ldd -r 对照两个 .so 产物的 ELF 元数据,可以看到两者的差异:

# 1. 查看动态段声明的直接依赖(DT_NEEDED)
$ readelf -d libcalc_broken.so | grep NEEDED
(无输出:没有任何 DT_NEEDED 条目)

$ readelf -d libcalc_fixed.so | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]

# 2. 查看动态符号表中的 pow 条目
$ readelf -Ws libcalc_broken.so | grep pow
     3: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND pow

$ readelf -Ws libcalc_fixed.so | grep pow
     4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND pow@GLIBC_2.29 (2)

# 3. 执行独立重定位检查
$ ldd -r libcalc_broken.so
	statically linked
undefined symbol: pow	(./libcalc_broken.so)

结合这三组输出,可以梳理出跨平台表现差异的原因:

  • ELF 动态库的依赖契约是 DT_NEEDED:动态加载器打开一个 .so 时,会按照它动态段里的 DT_NEEDED 列表依次将依赖库加入当前解析范围。libcalc_broken.so 虽然在符号表里留下了未定义的 UND pow,却没有声明 NEEDED [libm.so.6],加载器也就不会主动加载数学库。
  • Linux 桌面端能够运行的原因:许多桌面宿主可执行文件自身就依赖 libm.so.6,这些库在进程启动时已进入全局符号范围(Global Scope)。当 libcalc_broken.so 重定位 pow 时,glibc 动态链接器在全局符号表里找到了宿主已映射进内存的 pow,从而掩盖了动态库自身的依赖缺失。如果把绑定策略改成延迟绑定(RTLD_LAZY),甚至可能等到运行中途第一次调用该函数时才触发异常。
  • Android 端直接报错的原因:Android 的 Bionic 动态链接器自 Android 7.0(API 24)起引入了严格的命名空间隔离(Linker Namespace),应用侧通过 FFI dlopen 加载的插件库运行在受限的加载范围内,链接器严格依据目标 .so 自身的 DT_NEEDED 链路解析外部符号,不再通过进程全局符号池解析未声明的依赖,因此在加载阶段直接报错。

3. CMake 修复写法与构建期防漏选项

在 CMake 中构建动态库时,只要目标源码直接调用了某个外部库的符号,就应当显式通过 target_link_libraries 声明,并且可以在链接阶段开启“禁止共享库存在未定义符号”的检查,在编译阶段提前拦截这类问题:

add_library(calc SHARED calc.c)

# 按目标平台声明直接系统依赖:Linux / Android 下数学函数位于 libm
if(UNIX AND NOT APPLE)
  target_link_libraries(calc PRIVATE m)
endif()

# 在 ELF 平台上拒绝生成残留未解析符号的 .so(插件明确依赖宿主导出的符号时除外)
if(UNIX AND NOT APPLE)
  target_link_options(calc PRIVATE "-Wl,--no-undefined")
endif()

加上 -Wl,--no-undefined(等价于 -Wl,-z,defs)之后,一旦漏写 -lm 或其他第三方库依赖,编译阶段链接器就会直接报错退出,避免生成缺少依赖声明的 .so 文件。

4. 排查顺序与平台工具边界

以后再遇到动态库“A 环境能加载、B 环境报找不到符号”时,可以按以下顺序核对,无需一开始就排查框架源码:

  1. 先检查产物元数据,不依赖单一宿主的运行结果:对 Linux / Android ELF 产物优先执行 readelf -d <lib.so>(或 Android NDK 自带的 llvm-readobj --needed-libs <lib.so>),核对 DT_NEEDED 是否覆盖了所有 UND 导入符号。
  2. 通过即时绑定检查隐性依赖:在 Linux 桌面或 CI 环境中验证自有库时,可以通过 ldd -r <lib.so>,或在启动测试进程时附带 LD_BIND_NOW=1(需要查看具体绑定来源时配合 LD_DEBUG=libs,bindings),强制在加载期完成全部符号解析。
  3. 区分不同操作系统的二进制格式与库归属:
    • 上述 readelf、DT_NEEDED、LD_BIND_NOW 仅针对 Linux / Android 使用的 ELF 格式;
    • macOS / iOS 使用 Mach-O 格式(用 otool -L 检查依赖),且标准数学符号直接由系统 libSystem.B.dylib 提供,不能直接套用 Linux 的链接参数;
    • Windows PE/COFF(.dll)本身在链接期就要求所有导入符号关联到具体导入库(用 dumpbin /dependents 检查),较少出现 ELF 这种“未声明依赖却能编出产物”的情况。
  4. 安全习惯:ldd 在部分实现中会通过调用动态链接器尝试加载目标二进制;检查自己编译的受信产物没有问题,若分析来源不明的第三方二进制文件,应始终只用 readelf -d 或 llvm-readobj 静态读取文件头。