同一个 Native 动态库,为什么 Linux 能加载、Android 却失败?
通过最小 C 动态库复现与 readelf / ldd 产物检查,解释 Linux 宿主全局符号范围如何掩盖缺失的 DT_NEEDED 依赖,以及 Android 命名空间隔离为何直接报错。
在给 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
接下来准备两个极简宿主程序来加载它们:
- 模拟复杂桌面宿主(
loader_desktop_like):像 Flutter Linux Runner、JVM 或 Python 解释器这类宿主,自身启动时就已经链接或以RTLD_GLOBAL方式加载了libm.so.6; - 模拟干净或隔离的加载环境(
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 环境报找不到符号”时,可以按以下顺序核对,无需一开始就排查框架源码:
- 先检查产物元数据,不依赖单一宿主的运行结果:对 Linux / Android ELF 产物优先执行
readelf -d <lib.so>(或 Android NDK 自带的llvm-readobj --needed-libs <lib.so>),核对DT_NEEDED是否覆盖了所有UND导入符号。 - 通过即时绑定检查隐性依赖:在 Linux 桌面或 CI 环境中验证自有库时,可以通过
ldd -r <lib.so>,或在启动测试进程时附带LD_BIND_NOW=1(需要查看具体绑定来源时配合LD_DEBUG=libs,bindings),强制在加载期完成全部符号解析。 - 区分不同操作系统的二进制格式与库归属:
- 上述
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 这种“未声明依赖却能编出产物”的情况。
- 上述
- 安全习惯:
ldd在部分实现中会通过调用动态链接器尝试加载目标二进制;检查自己编译的受信产物没有问题,若分析来源不明的第三方二进制文件,应始终只用readelf -d或llvm-readobj静态读取文件头。