背景
本文主要是简单介绍一下 native crash 的发生过程,如何捕获,以及如何抓取并生成 Android tombstone 文件中的信息。
native crash 的发生
以下面代码中的空指针问题为例来看下 crash 的发生:
1 | static int getValue() { |
当我们调用上面的方法后,进程会异常退出:Segmentation fault
。如果我们用lldb去运行他,可以看到更详细的信息:stop reason = signal SIGSEGV: address not mapped to object (fault address: 0x0)
我们先反编译一下上面的代码,看下他做了什么,以及为什么会导致进程异常退出:
1 | getValue: |
这里可以看到他是从虚拟地址0
处读取32bits数据写入w0寄存器(x0的低32位,高32位自动清0),然而操作系统不会为虚拟地址0
建立映射,因此MMU在将虚拟地址0
转换到物理地址时找不到相应的页表项,于是MMU会产生一个 Data Abort,cpu exception level会切换到 EL1(操作系统内核所在的特权级),并执行内核启动时所预设的exception handler。
以上面的case为例,内核可以根据 ESR_EL1 寄存器中的信息判断异常原因,从 FAR_EL1 寄存器中获得导致异常的虚拟地址(比如上面的 0x0)。然后内核会向对应进程发送信号(比如上面的 SIGSEGV),然后在从内核态返回用户态之前会处理下信号,以SIGSEGV为例,默认行为就是杀死进程,所以我们看到进程crash了。
native crash 的捕获
上面提过native代码发生异常时,系统会向进程发送信号,而大多数信号是可以设置信号处理器的,如果设置了自定义的 signal handler,那么系统会调用我们设置的signal handler,在这个handler中我们可以收集一下进程的状态信息,这就完成了crash的捕获以及信息的收集了。(不是所有的信号都能捕获,比如 SIGKILL,SIGSTOP)
下面是一个简单的设置 signal handler 的代码:
1 | struct sigaction action; |
Android tombstone 文件信息
在发生native crash的时候,系统会为我们抓取非常详细的信息写入 tombstone 文件中供我们分析,我们先来看看 tombstone 文件中有哪些信息,长什么样子(不同Android 版本生成的 tombstone 信息有些许差异,但主要信息是一致的):
1 | // Build fingerprint, ABI, Timestamp 等信息 |
tombstone 信息生成
从上面的例子可以看到,Android 系统生成的 tombstone 中主要包含的信息有:
- crash进程id,进程名,线程id,线程名
- signal 信息
- crash线程寄存器信息
- crash线程调用栈
- crash线程寄存器值为地址附近的内存数据
- crash进程内存映射信息
- 其他线程寄存器&调用栈信息
- 打开的fd信息
- logcat中的日志信息
这些信息已经相当丰富,但是我们没有权限读取系统生成的tombstone信息。(有root权限是可以的,通过 adb bugreport
也可以拿到,但是对于获取线上crash信息而言都是不行的)所以下面我们自己来实现这些信息的获取。
暂停crash进程的执行
上面提到我们需要获取内存数据,各个线程的寄存器、调用栈等信息,因此我们在收到signal的时候应该尽可能快的“冻结”crash进程中所有线程的执行,以避免破坏现场。于是我们在收到signal的时候会fork一个子进程,子进程会先“冻结”crash进程的执行,然后在子进程中完成上面列出来的一系列信息的收集。
fork子进程&传递crash信息
我们需要将crash的一些关键信息传递给子进程,比如crash的线程id,siginfo,ucontext_t,以及抓取的信息要写入的位置等。有多种方式可以用来传递这些信息,下面的示例代码我们以管道来实现:
1 | if (int pipeFds[2]; pipe(pipeFds) == 0) { |
几个小点稍微解释一下:
- 子进程中我们通过
dup2
将管道的读端重定向到stdin,方便后续可执行文件读取 - 设置
LD_LIBRARY_PATH
环境变量是因为我们的dumper程序依赖apk内的libc++_shared.so
,这样方便动态链接器查找到 libcrashdumpper.so
其实是一个可执行程序,这样命名并放到lib中,安装时系统会自动解压到nativeLibraryDir
中,并有可执行权限waitDumpperProcess
是通过waitpid
等待dumper进程执行完成
暂停crash进程的执行
在本文开头曾提到“不是所有的信号都能捕获,比如 SIGKILL,SIGSTOP”,此处SIGSTOP
就派上用场了,当一个进程(此处其实应该说是线程,只是信号机制是出现在线程机制之前的,所以这块的api以及部分描述有时候会用process)收到SIGSTOP
后内核会停止对其的调度直到收到SIGCONT
。
因此如果我们要暂停某个线程的执行就可以向他发送SIGSTOP
,如果要暂停整个进程的执行就可以向这个进程下的所有线程发送SIGSTOP
。不过后续我们需要获取crash进程的内存、寄存器等信息,使用ptrace
api 比较方便,我们也不用自己发送SIGSTOP
,PTRACE_ATTACH
到目标线程即可(PTRACE_ATTACH 请求也会发送SIGSTOP)。
获取crash进程的所有线程id
/proc/${pid}/task
目录为每个子线程包含一个子目录,目录名就是线程的id,因此我们可以获取到crash进程的所有子线程的id:
1 | std::vector<pid_t> loadThreads(pid_t pid) { |
暂停所有线程的执行
上面提到通过 ptrace attach 到指定线程可以暂停其执行,不过 ptrace 方法返回后对应线程可能还没有停止执行,可以通过 waitpid 确保其停止执行,因此我们可以先向crash进程的所有线程发送PTRACE_ATTACH
请求,然后再wait。
1 | void suspendThreads(const std::vector<pid_t>& tids) { |
获取crash进程id,进程名,线程id,线程名
- crash进程是我们dumper进程的父进程,因此通过
getppid
可获取其进程id - 通过
/proc/${pid}/cmdline
可获取进程名 - crash线程id已经通过管道传递过来了
- 通过
/proc/${tid}/comm
可获取线程名(Linux中线程是全局唯一的,因此 /proc/pid/task/{pid}/task/pid/task/{tid} 和 /proc/${tid} 指向同一个目录)
signal 信息
可以通过siginfo
拿到signal number,fault addr等信息,如果要拿到siginfo
以及ucontext_t
,在注册signal action的时候需要添加SA_SIGINFO
flag。
1 | static void printSignalInfo(const siginfo_t& info) { |
signalHasFaultAddr
: 并非所有的异常都有 fault addr,所以此处有个简单的判断,如果没有的话,就输出: fault addr: ——–
crash线程寄存器信息
crash 线程寄存器信息是在ucontext_t
数据结构中,已经通过管道传递给dumper进程了,我们只需要按照tombstone格式输出就行,示例代码如下:
1 | static void printRegisters(FILE* out, const ucontext_t& context) { |
crash线程调用栈
栈回溯
这个就是要实现栈回溯功能,但是在Android上实现栈回溯还是比较麻烦的,有很多文章介绍这方面内容,此处就以最简单最高效的基于fp的栈回溯方案来实现一下~
这个方案的原理是:如果编译的时候启用了-fno-omit-frame-pointer选项(target是aarch64时,通常是启用的),那么编译器会用x29寄存器(也就是fp)保存当前栈帧的起始地址,而fp指向的栈元素中保存上一个栈帧的起始地址(也就是 pre fp),紧靠着的下一个栈元素存放函数的返回地址(lr)。因此根据fp我们能找到一个个栈帧的起始位置,也就能找到一个个栈帧的返回地址(lr),而函数调用地址就是对应返回地址的上一条指令,因此就完成了回溯。
我们现在是在dumper进程中,没法直接读取crash进程的内存数据,不过上面提到过可以借助ptrace
系统调用来实现,示例代码如下:
1 | std::optional<long> readData(pid_t pid, void* addr) { |
然后我们可以实现一个简版的栈回溯:
1 | for (int i = 0; i < max; ++i) { |
几点补充:
- 某些so可能没有启用-fno-omit-frame-pointer,另外穿过jni、jit、oat等代码时可能也会存在问题,
所以回溯过程中可能会出现SIGSEGV等问题,一般可以通过 sigsetjmp,siglongjmp做一下保护
,不过上面是通过ptrace
系统调用读取的,如果地址访问存在问题会设置errno
,不会出现异常(signal),
在我们的readData
中已经做过判断,这种情况下会返回std::nullopt
- 也可以加个优化:通过
pthread_attr_getstack
获取线程栈的地址范围,如果fp超出范围就提前终止回溯 - 我们上面通过
lr - 4
来获取上一条指令的地址,这对于aarch64来讲没问题,因为指令长度固定4字节,
因此我们可以精确计算。但如果是aarch32,或者是x86这种变长指令集的话怎么处理呢?一种简单的方法是使用lr - 1
,
这个地址一定落在上一条指令中,通过他获取对应的行号信息也是准确的。
pc处代码所在文件的路径
1 | #02 pc 0000000000377030 /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: b10f5696fea1b32039b162aef3850ed3) |
调用栈中通常会输出pc处代码所在文件的路径,这个比较好实现:/proc/${pid}/maps
中存储了所有内存映射信息,包括映射起始虚拟地址,权限,路径名等信息,因此根据上一步拿到的pc虚拟地址就可以从maps中找到对应的路径信息
pc处代码在文件中的偏移
1 | #02 pc 0000000000377030 /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: b10f5696fea1b32039b162aef3850ed3) |
在输出的调用栈信息中pc的值其实不是上面取到的虚拟地址,因为每次运行其虚拟地址都是变化的,输出这个没有意义,因此他输出的是pc指向的代码在其文件中的偏移。这个可以通过pc虚拟地址 - 对应elf的load bias
来获得。elf的load bias在Android PLT-GOT hook 实现提到过,感兴趣的话可以看一下
符号名(函数名)获取
函数的符号名存储在 elf 文件的符号表(.symtab SHT_SYMTAB)中,配合字符串表(.strtab)可以加载出所有的符号信息,离上一步获取的相对pc(文件内偏移量)最近的(symbol.st_value <= relative_pc)的类型为STT_FUNC
的符号即是我们要找的符号名(函数名)。
因为.symtab & .strtab不是运行时需要的section,所以有可能会被strip掉,即使没有strip掉,他们大概率也不会被映射进内存。我们可以先check一下,如果这2个section已经被映射进内存,那么我们直接读内存数据解析,否则我们直接解析elf文件。下面给个解析elf文件中所有符号的偏移&符号名的示例代码:
1 | static std::vector<std::pair<uint64_t, std::string>> loadSymbolsFromPath(const std::string& path) { |
elf中动态符号的查找之前在ELF 通过 Sysv Hash & Gnu Hash 查找符号的实现及对比中提过,感兴趣的话可以看下。
(art_quick_generic_jni_trampoline+144)
符号名后面的 +xxx 指的是pc距离符号基地址的偏移:relative_pc - symble.st_value
获取elf的BuildId
build-id 是linker根据输入使用md5、sha1等算法计算的一个checksum,用于标记一次编译的。比如上面我们提到要获取elf文件的符号信息需要符号表,字符串表,但通常我们发布的elf都是strip过的,同时保留一个未strip版本的elf,然后我们的crash sdk在获取到elf的 build-id 以及 relative-pc 信息后上报到服务端,在服务端就可以根据build-id来找到对应的未strip的elf,然后来解析相应的符号信息。
build-id信息是存储在PT_NOTE
类型的segment中的,其name
的值是“GNU”,因此我们可以像如下代码那样读取 build-id 的信息:
1 | for (int i = 0; i < ehdr.e_phnum; ++i) { |
crash线程寄存器值为地址附近的内存数据
1 | memory near x1 ([anon:dalvik-LinearAlloc]): |
- 第一行中的 [anon:dalvik-LinearAlloc] 是以
x1
寄存器的值为虚拟地址,在/proc/${pid}/maps
中找到对应的内存映射项的pathname
- 第一列是虚拟地址,中间两列是内存值,最后一列是内存值的ascii表示,不可打印的字符用’.’代替
这个信息有时候是有用的,比如数组越界的case,有可能会发现相同的字符串序列多次出现,就可以查看下相关代码是否有问题。
crash进程内存映射信息
这个上面已经提到过了,读取/proc/${pid}/maps
就OK了
其他线程寄存器&调用栈信息
对于crash的线程,发生crash时的寄存器信息是由操作系统给我们的(context.uc_mcontext.regs)不用我们去获取。其他线程的寄存器信息可以通过ptrace PTRACE_GETREGS or PTRACE_GETREGSET
来获取,示例代码如下:
1 | bool getThreadRegs(pid_t tid, user_regs_struct& regs) { |
获取其他线程的调用栈信息跟上面提到的crash线程栈回溯实现一致,此处就忽略了。
有一点需要注意的是:对于crash线程的信息(寄存器值&调用栈)都是发生crash时的准确信息,而其他线程的寄存器值、调用栈都是crash之后一段时间的状态,所以我们文章开头提到要尽可能快的“冻结”crash进程中的所有线程,尽量接近crash现场。
打开的fd信息
在/proc/${pid}/fd
中保存了进程打开的所有fd信息,都是符号连接,文件名是fd的数值,内容是fd对应的名称,读取fd信息的示例代码如下:
1 | void dumpOpenFds(pid_t pid) { |
logcat中的日志信息
crash的时候收集最近的logcat信息通常是有帮助的,有些crash需要依赖系统日志来分析,如果我们编译时没有移除app内打印logcat的字节码的话,crash附近的业务log对分析、定位问题通常也有帮助,要收集logcat信息比较简单:fork一个子进程执行/system/bin/logcat
即可,示例代码如下:
1 | void dumpLogcat(const char* output) { |
几个注意点
要实现好crash捕获sdk还是比较复杂的,还有挺多地方要考虑,比如:预留一部分内存以应对oom类的crash,设置一个备用信号栈以应对stack overflow,预留一些fd以应对fd不足的crash等等。
本文转载自: 掘金