第4章:Native Crash分析:addr2line与objdump工具链使用、so库符号解析、常见native崩溃模式
各位好,我是老张。今天咱们聊聊Native Crash分析。说实话,做Android稳定性这些年,我处理最多的就是Native问题。Java层的崩溃相对好定位,但Native层一旦崩了,很多人就慌了神。其实没那么可怕,掌握几个工具,摸清几种模式,你也能成为Native问题终结者。
4.1 工具链:addr2line与objdump
先说说最常用的两个工具。我个人习惯把它们叫做「Native调试双雄」——addr2line和objdump。
4.1.1 addr2line:地址转行号
拿到一个Native崩溃的backtrace,里面全是十六进制地址。比如这样:
#00 pc 0001a3b4 /system/lib/libc.so (strlen+12)
#01 pc 0002c5f8 /system/lib/libutils.so
#02 pc 0003e7a0 /data/app/com.example/lib/arm64/libnative.so (offset 0x3e7a0)
这些地址怎么变成代码行号?用addr2line。
基本用法:
addr2line -f -e libnative.so 0003e7a0
输出:
Java_com_example_native_NativeBridge_processData
/home/user/project/native/jni/native_bridge.cpp:156
你看,地址直接映射到了函数名和行号。我在项目中遇到过很多次,同事拿着backtrace问我「这地址怎么看」,我教他用addr2line,三秒就定位到了问题。
4.1.2 objdump:反汇编与符号表
addr2line搞不定的时候,就得请objdump出场了。比如地址在某个函数内部,但addr2line只显示函数名,不显示具体偏移。这时候objdump能帮你看到汇编级别的代码。
常用命令:
objdump -d libnative.so | grep -A 20 "<processData>:"
这会反汇编processData函数,显示20行汇编代码。结合崩溃地址的偏移量,你就能定位到具体是哪条指令崩了。
我印象最深的一次,一个空指针崩溃,addr2line只告诉我是在某个函数里,但函数有200多行。用objdump一看,发现是访问了一个未初始化的成员变量。嗯,这种问题光靠看代码还真不好找。
4.2 so库符号解析
符号解析说白了就是搞清楚「这个地址对应哪个函数」。但实际工作中,情况往往更复杂。
4.2.1 符号表与动态符号表
so文件里有两张表:.symtab(完整符号表)和.dynsym(动态符号表)。strip命令会移除.symtab,但.dynsym会保留,因为动态链接需要它。
查看动态符号表:
objdump -T libnative.so | grep processData
输出:
000000000001a3b4 g DF .text 00000034 processData
这里能看到符号的地址、大小、类型等信息。我曾经遇到过一个诡异的问题,崩溃地址明明在so的代码段内,但addr2line就是解析不出来。后来用objdump -T一看,发现那个函数被inline了,符号表里根本没有。
4.2.2 地址对齐与偏移计算
崩溃日志里的地址,有时候是绝对地址,有时候是相对地址。怎么区分?
看这个例子:
#00 pc 0001a3b4 /system/lib/libc.so
#01 pc 0002c5f8 /system/lib/libutils.so
#02 pc 0003e7a0 /data/app/com.example/lib/arm64/libnative.so
这些地址都是相对so基址的偏移。但如果是这样的:
#00 pc 0000007fabc1234 /system/lib/libc.so
这就是绝对地址了。你需要知道so的加载基址,然后做减法得到偏移。
获取加载基址的方法:
cat /proc/[pid]/maps | grep libnative.so
输出类似:
7fabc00000-7fabc20000 r-xp 00000000 08:01 12345 /data/app/com.example/lib/arm64/libnative.so
基址就是7fabc00000。用崩溃地址减去基址,得到偏移,再喂给addr2line。
4.3 常见Native崩溃模式
做久了你会发现,Native崩溃就那么几种模式。摸清了,分析起来就快了。
4.3.1 空指针访问
这是最常见的,没有之一。信号是SIGSEGV,地址通常是0x0或者很小的值。
典型backtrace:
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
#00 pc 0001a3b4 libnative.so (processData+24)
为什么会这样?说白了就是访问了空指针的成员。比如:
void processData(Data* data) {
data->value = 10; // data是NULL,崩了
}
我建议你在写代码时,对指针参数做空检查。尤其是JNI层,Java传过来的对象可能为null。
4.3.2 内存越界
这种问题比空指针难定位。信号也是SIGSEGV,但fault addr是一个看起来「合理」的地址。
举个例子:
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x7fabc1234
#00 pc 0002c5f8 libnative.so (writeBuffer+48)
地址0x7fabc1234看起来像堆内存,但为什么访问不了?因为越界了。
常见场景:
- 数组下标越界:buffer[100] 但只分配了50个元素
- 字符串操作:strcpy目标缓冲区太小
- 野指针:释放后继续使用
我曾经遇到一个特别坑的案例。一个线程在写buffer,另一个线程在释放buffer。两个线程没有加锁,结果写的时候内存已经被释放了。崩溃地址看起来完全正常,但就是访问不了。查了两天才发现是竞态条件。
4.3.3 栈溢出
信号是SIGSEGV,但fault addr在栈附近。backtrace里经常能看到很多重复的函数调用。
典型特征:
#00 pc 0001a3b4 libc.so (__stack_chk_fail+8)
#01 pc 0002c5f8 libnative.so (recursiveFunc+16)
#02 pc 0002c5f8 libnative.so (recursiveFunc+16)
#03 pc 0002c5f8 libnative.so (recursiveFunc+16)
...
看到没?同一个地址出现了几十次。这就是递归调用没有终止条件,把栈撑爆了。
Android默认栈大小是8KB(主线程)或1MB(子线程)。如果你的函数局部变量很大,或者递归深度很深,很容易栈溢出。
4.3.4 非法指令
信号是SIGILL。这种情况比较少见,但遇到了就很头疼。
原因通常有:
- CPU架构不匹配:arm64的so跑在arm32上
- 代码损坏:so文件被部分覆盖
- JIT编译错误:动态生成的代码有问题
我记得有一次,客户反馈某个功能在特定手机上必崩。拿回日志一看,SIGILL。查了半天,发现是那个手机的CPU不支持某些ARMv8指令。解决方案很简单,编译时加上-march=armv7-a就行。
4.4 实战分析流程
说了这么多,总结一下我的分析流程:
- 看信号:SIGSEGV、SIGABRT、SIGILL,不同信号指向不同问题
- 看地址:fault addr是0x0?是栈地址?还是堆地址?
- 解析backtrace:用addr2line把地址转成行号
- 看代码:定位到具体行,分析上下文
- 复现验证:修改代码,确认问题修复
这个流程我用了十年,基本没失手过。当然,有些问题需要结合logcat日志、内存快照等更多信息。但核心思路不变——先定位,再分析,最后修复。
好了,这一章就到这里。下一章我们聊聊ANR分析,那又是另一个有意思的话题。