第三节:tombstone解读——文件结构、信号含义与堆栈展开原理

说到Android稳定性问题,tombstone文件绝对是个绕不开的话题。我入行那会儿,第一次看到tombstone文件,说实话有点懵——满屏的十六进制、寄存器值、调用栈,感觉像在看天书。后来啃了几个月源码,才慢慢摸清门道。

今天我就把自己总结的经验分享出来。你只要掌握了tombstone的结构、信号含义,以及backtrace的原理,以后遇到Native崩溃,心里就有底了。

3.1 tombstone文件长什么样?

tombstone文件通常保存在 /data/tombstones/ 目录下,文件名是 tombstone_XX。每次发生Native层崩溃,系统就会生成一个。我习惯先看文件头部,那里信息量最大。

一个典型的tombstone文件结构是这样的:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/xxx/xxx:11/RP1A.201105.001/1234567:user/release-keys'
Revision: '0'
ABI: 'arm64'
Timestamp: 2024-01-15 14:23:45.123456789+0800
pid: 12345, tid: 12346, name: my_app
uid: 10123
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    r0  00000000  r1  00000001  r2  00000000  r3  00000000
    r4  00000000  r5  00000000  r6  00000000  r7  00000000
    r8  00000000  r9  00000000  r10 00000000  r11 00000000
    ip  00000000  sp  bf123456  lr  aabbccdd  pc  aabbccdd
backtrace:
    #00 pc 00012345  /system/lib64/libc.so (strlen+10)
    #01 pc 00023456  /system/lib64/libutils.so (String8::length()+20)
    #02 pc 00034567  /data/app/xxx/lib/arm64/libnative.so (Java_com_example_MyClass_nativeMethod+100)
    #03 pc 00045678  /system/lib64/libart.so (art_quick_generic_jni_trampoline+200)
    #04 pc 00056789  /system/lib64/libart.so (art::JniMethodStart+30)
    ...

嗯,这里要注意几个关键字段:

  • Build fingerprint:系统版本信息,方便确认是不是特定版本的问题
  • ABI:32位还是64位,这个决定了你后续分析时用哪个工具链
  • pid/tid:进程ID和线程ID,有时候崩溃发生在子线程,光看pid不够
  • signal:信号类型,这是最核心的线索
  • backtrace:调用栈,告诉你代码执行到哪一步崩了

重点:tombstone文件里,signal和backtrace是黄金搭档。signal告诉你「怎么死的」,backtrace告诉你「死在哪儿」。

3.2 signal与si_code——崩溃的「死因」

signal是Linux信号机制的一部分。Android Native层崩溃,本质上就是进程收到了一个致命信号。常见的信号有这些:

信号值 信号名 常见原因
11 SIGSEGV 段错误,访问了非法内存地址
6 SIGABRT 主动调用abort(),通常是断言失败或检测到异常
4 SIGILL 非法指令,CPU执行了不认识的指令
7 SIGBUS 总线错误,访问了未对齐的内存地址
8 SIGFPE 浮点异常,比如除零操作

光看信号还不够,si_code提供了更细粒度的信息。拿最常见的SIGSEGV来说:

  • SEGV_MAPERR (code 1):地址没有映射到任何内存区域。说白了就是「地址不存在」。我遇到过很多次,都是空指针解引用,fault addr显示0x0。
  • SEGV_ACCERR (code 2):地址存在,但没有访问权限。比如你试图往只读内存里写数据。
  • SEGV_BNDERR (code 3):边界错误,通常是硬件边界检查失败。

我的经验:看到SEGV_MAPERR且fault addr是0x0,90%是空指针。看到SEGV_ACCERR,多半是往字符串常量区写数据了。我曾经排查过一个案例,同事把const char*当char*用,结果在strcpy时崩了,code就是SEGV_ACCERR。

3.3 backtrace与stack unwinding——调用栈是怎么来的?

backtrace就是调用栈,它记录了函数调用的链条。但问题是——CPU只知道当前执行到哪条指令,它怎么知道之前调用了哪些函数?

这就涉及到stack unwinding(堆栈展开)的原理了。

简单来说,每个函数调用时,都会在栈上保存一些信息:

  • 返回地址:函数执行完后该回到哪里
  • 栈帧指针:上一个函数的栈帧位置
  • 局部变量:函数内部的数据

当崩溃发生时,debuggerd(Android的崩溃处理守护进程)会做这么几件事:

  1. 读取当前CPU寄存器的值,特别是PC(程序计数器)和SP(栈指针)
  2. 从SP指向的位置开始,沿着栈帧链往上回溯
  3. 每找到一个栈帧,就提取返回地址,换算成对应的函数名和偏移量
  4. 重复这个过程,直到栈底或遇到无效地址

这个过程依赖两种方式:

  • 基于帧指针(FP):编译器在编译时生成帧指针寄存器,指向当前栈帧的底部。这种方式简单可靠,但会占用一个寄存器,而且有些优化选项会去掉帧指针。
  • 基于异常处理表(EH Frame):编译器生成额外的元数据,描述如何从任意指令位置回溯。这种方式更灵活,但需要解析DWARF格式的数据。

注意:如果编译时加了 -fomit-frame-pointer 优化,基于帧指针的unwinding就会失败。这时候backtrace可能只显示一行,或者干脆是乱码。我踩过这个坑——线上版本为了性能开了这个优化,结果崩溃栈全断了,根本定位不到问题。后来我们统一要求release版本也要保留帧指针。

3.4 实战:如何从backtrace定位问题

拿到backtrace后,怎么找到出问题的代码行?我一般这么做:

  1. 看最顶层的#00,那是崩溃时的指令位置
  2. 看#01、#02,那是调用链的上层
  3. 重点关注自己写的so库,系统库的调用通常只是路过

比如上面那个例子:

#00 pc 00012345  /system/lib64/libc.so (strlen+10)
#01 pc 00023456  /system/lib64/libutils.so (String8::length()+20)
#02 pc 00034567  /data/app/xxx/lib/arm64/libnative.so (Java_com_example_MyClass_nativeMethod+100)

#00在strlen里崩了,说明是计算字符串长度时出了问题。#01是String8::length(),说明传进来的字符串有问题。#02是自己的JNI方法,那问题大概率出在Java层传了个空字符串或者非法指针过来。

要定位到具体代码行,需要用addr2line工具:

# 把地址转换成源码行号
arm64-addr2line -e libnative.so 0x34567

它会输出类似这样的结果:

/home/user/project/src/native_method.cpp:45

嗯,第45行,去看看就知道了。

避坑指南:我曾经遇到一个情况,addr2line输出的行号跟实际代码对不上。后来发现是编译时用了不同的优化等级,导致指令重排了。解决办法是——用编译时生成的带符号表的so文件,不要用strip过的。我习惯在构建产物里保留一份unstripped的so,专门用来分析tombstone。

3.5 小结

tombstone解读其实没那么玄乎。记住三点:

  • signal告诉你「怎么死的」——是空指针还是非法指令
  • backtrace告诉你「死在哪儿」——从哪个函数、哪行代码开始
  • stack unwinding是背后的机制——理解它,你才能判断backtrace是否可信

下次再看到tombstone文件,别慌。先看signal,再看backtrace,最后用addr2line定位到代码行。这套流程走下来,大部分Native崩溃都能搞定。