ABI、链接器与符号可见性:为什么 native 库能加载也能加载错
上一章我们知道 C++ 会被编译成 .so。这一章继续往下问:为什么同一份 C++ 需要给不同手机编译不同 .so?为什么库存在,App 仍然可能报 UnsatisfiedLinkError?
答案集中在三个词:ABI、linker、symbol。
ABI 是什么
ABI 是 Application Binary Interface,应用二进制接口。
如果 API 是“源码怎么调用”,ABI 就是“机器码怎么合作”。它规定了这些底层约定。
CPU 指令集是什么
函数参数放寄存器还是栈
返回值放哪里
结构体内存布局如何对齐
符号名字怎样表示
异常、栈展开、调用约定怎么处理
可以把 ABI 想成一群人协作时使用的“手势规则”。源码层面大家都叫 add(a, b),但机器码层面参数到底放在哪个寄存器,需要统一约定。
Android 常见 ABI
arm64-v8a:64 位 ARM,现代真机主力
armeabi-v7a:32 位 ARM,老设备
x86_64:64 位 x86,模拟器常见
一个 ABI 对应一套 .so。
lib/arm64-v8a/libplayer_core.so
lib/x86_64/libplayer_core.so
如果设备是 arm64-v8a,APK 里却只有 x86_64,加载必然失败。
linker 在做什么
linker 有两个阶段的含义。
编译时 linker,也就是链接器 lld,把多个 .o 和 .a 合成 .so。
运行时 linker,也就是 Android 动态链接器,把 .so 装进进程。
运行时 linker 会做这些事。
检查 ELF 格式
检查 ABI
加载依赖库
解析外部符号
处理重定位
执行初始化函数
如果某个符号找不到,就会报错。
java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol ...
symbol 是什么
symbol 是二进制里的名字。函数、全局变量、JNI 入口都可能是 symbol。
比如 C++ 函数:
int add(int a, int b) {
return a + b;
}
编译后不一定还叫 add。C++ 支持重载,编译器会把函数名、参数类型编码进符号名,这叫 name mangling。
所以 JNI 入口常见写法是:
extern "C" JNIEXPORT void JNICALL
Java_com_zerobug_player_NativeBridge_nativeInit(JNIEnv*, jobject) {
}
extern "C" 的作用是让函数符号保持 C 风格,避免 C++ 名字改编。
为什么要控制符号可见性
默认把所有符号都暴露出去,看起来方便,但会带来问题。
库变大
加载时重定位更多
内部函数被外部错误依赖
不同三方库符号名冲突
攻击面变大
Android 官方符号可见性文档明确建议控制导出符号。版本脚本比单纯 -fvisibility=hidden 更精确,因为它能显式列出哪些符号是公共接口。
版本脚本示例
创建 libplayer_core.map.txt。
{
global:
JNI_OnLoad;
local:
*;
};
意思是:
只导出 JNI_OnLoad
其他符号全部隐藏
CMake 里链接。
target_link_options(
player_core
PRIVATE
"-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/libplayer_core.map.txt"
)
如果你使用 RegisterNatives,通常只需要导出 JNI_OnLoad。JNI 方法本身可以是普通静态函数,不需要暴露超长符号名。
用 nm 看符号
可以用 NDK 自带工具看 .so 导出了哪些符号。
llvm-nm -D libplayer_core.so
-D 表示只看动态符号表,也就是运行时 linker 能看到的符号。
你希望看到的结果应该很少。
JNI_OnLoad
如果看到一大堆业务类、模板函数、内部工具函数,说明符号边界太宽。
常见故障
第一类:ABI 不匹配。
APK 只有 x86_64
真机是 arm64-v8a
System.loadLibrary 失败
第二类:符号找不到。
编译通过
运行时报 cannot locate symbol
原因可能是依赖库没有打包,或 API level 不支持
第三类:符号冲突。
两个库都暴露同名 C 符号
运行时解析到错误实现
本章实验
先构建默认 .so,用 llvm-nm -D 看导出符号。
再加入版本脚本,只导出 JNI_OnLoad。
比较两次输出。
导出符号数量是否下降
APK 体积是否变化
System.loadLibrary 是否仍然成功
JNI 方法是否能正常注册
工程风险与观测
符号问题往往发生在装载阶段,所以日志非常重要。遇到 UnsatisfiedLinkError 时,不要只看 Java 堆栈,要复制完整的 dlopen failed 信息。
建议把失败分为三类。
ABI 风险:没有目标 ABI 对应的 .so
依赖风险:目标 .so 存在,但它依赖的另一个 .so 不存在
符号风险:所有库都存在,但某个函数符号无法解析
CI 里可以做一次轻量审计。
llvm-readelf -d libplayer_core.so
llvm-nm -D libplayer_core.so
前者看依赖库,后者看导出符号。
如果三方库导出了大量内部符号,要记录为观察项。它不一定马上出事故,但后续升级时容易出现冲突。
上线后要保留这些观测信息。
App version
ABI
targetSdkVersion
native library name
dlopen failed 原文
如果同一版本只在某个 ABI 崩溃,优先查 ABI 特定实现、汇编、Neon、预编译库版本。
本章结论
ABI 决定 native 库能否在某类设备上运行,linker 决定它能否被装进进程,symbol 决定运行时能否找到需要的入口。真正工程化的 NDK 模块,要把这三件事都纳入设计,而不是等线上崩溃后再查。