前言
native的异常不同于Java异常,往往堆栈是一堆的地址,如下所示
本文主要介绍如何通过一系列的工具去还原native异常堆栈。
获取崩溃日志
分析Native Crash最直接的方式是查看logcat日志,一般情况下,只要APP没有自己实现信号捕获机制(比如使用了Bugly插件或者google breakpad),就不会影响到Runtime正常打印调用栈。我们通常只需要执行
$ adb logcat|grep DEBUG
如果crash已经发生了,如果没有清空logcat的缓存,也可以如下挽救一下,执行如下命令,最新的崩溃在最后面
$ adb logcat --buffer=crash
当然,如果你的手机有root权限,可以pull出tombstone文件,目录在/data/tombstones/。tombstone文件是在出现Native Crash时的崩溃转储文件,一般最多保存10个文件,如果有新的Crash则会覆盖掉旧的文件。
如果没有root权限,可以使用bugreport命令导出日志文件,里面也可能有墓碑文件
$ adb bugreport E:\Reports\MyBugReports
更多用法可以查看官方文档
native异常格式
要想准确的定位异常发生原因,那么首先得看懂crash的日志
- 首先是第一行多个**********************,这个表示异常堆栈的起始点,然后一些基础信息,比如crash发生的时间、包名,pid和tid等,比较关键的是 ABI: 'arm64',表示当前手机处理器类型
- 第二个是崩溃的基础信息,主要是一个signal,一个错误码和发生错误的内存地址
signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr 0x79ce10f8e8
- signal下一行为寄存器快照
- backtrace下面则为异常调用堆栈
要想看懂异常堆栈,其实只需要关注第二点以及第四点
异常堆栈解析
#00 pc 000000000000e8e8 /....../libnesample.so (Java_com_example_nesample_MainActivity_stringFromJNI+28) (BuildId: 55b26db218ce4b27f098e20358fe013adec533c1)
1、#00:栈帧号
2、000000000000e8e8:pc地址,16进制
3、/....../libnesample.so:异常出现的so
4、Java_com_example_nesample_MainActivity_stringFromJNI:方法名(如果为_Z开头的乱码,可以使用C++fit工具还原)
5、+28:函数内部偏移地址符号偏移量(以字节为单位):28
6、BuildId:so文件的BuildId
我们可以通过函数起始的pc地址加上函数内部偏移地址符号偏移量,也可以算出异常堆栈的地址,具体查看末尾的nm工具
signal信号量
与 Java 平台不同,C/C++ 没有一个通用的异常处理接口,在 C 层,CPU 通过异常中断的方式,触发异常处理流程。不同的处理器,有不同的异常中断类型和中断处理方式,linux 把这些中断处理,统一为信号量,每一种异常都有一个对应的信号,可以注册回调函数进行处理需要关注的信号量。
所有的信号量都定义在\
#define SIGHUP 1 // 终端连接结束时发出(不管正常或非正常)
#define SIGINT 2 // 程序终止(例如Ctrl-C)
#define SIGQUIT 3 // 程序退出(Ctrl-\)
#define SIGILL 4 // 执行了非法指令,或者试图执行数据段,堆栈溢出
#define SIGTRAP 5 // 断点时产生,由debugger使用
#define SIGABRT 6 // 调用abort函数生成的信号,表示程序异常
#define SIGIOT 6 // 同上,更全,IO异常也会发出
#define SIGBUS 7 // 非法地址,包括内存地址对齐出错,比如访问一个4字节的整数, 但其地址不是4的倍数
#define SIGFPE 8 // 计算错误,比如除0、溢出
#define SIGKILL 9 // 强制结束程序,具有最高优先级,本信号不能被阻塞、处理和忽略
#define SIGUSR1 10 // 未使用,保留
#define SIGSEGV 11 // 非法内存操作,与SIGBUS不同,他是对合法地址的非法访问,比如访问没有读权限的内存,向没有写权限的地址写数据
#define SIGUSR2 12 // 未使用,保留
#define SIGPIPE 13 // 管道破裂,通常在进程间通信产生
#define SIGALRM 14 // 定时信号,
#define SIGTERM 15 // 结束程序,类似温和的SIGKILL,可被阻塞和处理。通常程序如果终止不了,才会尝试SIGKILL
#define SIGSTKFLT 16 // 协处理器堆栈错误
#define SIGCHLD 17 // 子进程结束时, 父进程会收到这个信号。
#define SIGCONT 18 // 让一个停止的进程继续执行
#define SIGSTOP 19 // 停止进程,本信号不能被阻塞,处理或忽略
#define SIGTSTP 20 // 停止进程,但该信号可以被处理和忽略
#define SIGTTIN 21 // 当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN信号
#define SIGTTOU 22 // 类似于SIGTTIN, 但在写终端时收到
#define SIGURG 23 // 有紧急数据或out-of-band数据到达socket时产生
#define SIGXCPU 24 // 超过CPU时间资源限制时发出
#define SIGXFSZ 25 // 当进程企图扩大文件以至于超过文件大小资源限制
#define SIGVTALRM 26 // 虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间.
#define SIGPROF 27 // 类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的时间
#define SIGWINCH 28 // 窗口大小改变时发出
#define SIGIO 29 // 文件描述符准备就绪, 可以开始进行输入/输出操作
#define SIGPOLL SIGIO // 同上,别称
#define SIGPWR 30 // 电源异常
#define SIGSYS 31 // 非法的系统调用
so文件解析
一个完整的 so 由C代码加一些 debug 信息组成,这些debug信息会记录 so 中所有方法的对照表,就是方法名和其偏移地址的对应表,也叫做符号表,这种 so 也叫做未 strip 的,通常体积会比较大。
通常release的 so 都是需要经过一个strip操作的,这样strip之后的 so 中的debug信息会被剥离,整个 so 的体积也会缩小。
如下可以看到strip之前和之后的大小对比。
可以简单将这个debug信息理解为Java代码混淆中的mapping文件,只有拥有这个mapping文件才能进行堆栈分析。
如果堆栈信息丢了,基本上堆栈无法还原,问题也无法解决。
所以,这些debug信息尤为重要,是我们分析NE问题的关键信息,那么我们在编译 so 时候务必保留一份未被strip的so 或者剥离后的符号表信息,以供后面问题分析,并且每次编译的so 都需要保存,一旦产生代码修改重新编译,那么修改前后的符号表信息会无法对应,也无法进行分析。
so文件与crash日志对应
当native异常发生的是时候查看异常堆栈,里面有一个BuildID,如下所示
通过这个就能知道崩溃对应的so。
通过Linux的flie命令即可知道so的BuildID,如下所示
带debug信息的so
$ file libnesample.so
libnesample.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[sha1]=55b26db218ce4b27f098e20358fe013adec533c1, with debug_info, not stripped
strip后的so
$ file libnesample.so
libnesample.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[sha1]=55b26db218ce4b27f098e20358fe013adec533c1, stripped
如果本地已经无法找到Crash对应的符号表文件或者Debug SO文件了,但还能找回Crash对应的APP版本的Native工程代码,可以重新用NDK编译出Debug SO。然后检查BuildId是否一致,一份代码多次打包,build是一致的。
符号表位置
不同的gradle版本或Android Studio版本位置不同,不过一般都在app/build/intermediates/
下面的某些文件夹里面
strip的so文件路径
没有strip的so文件路径
在打包so提供给别人使用的时候,需要检查一下是否去掉了调试信息,不然可能带来风险
strip so
常用的strip 命令:
- strip - s xxx.so : 移除所有符号信息(会覆盖原有带符号表的so)
- strip - S xxx.so: 移除调试符号信息,这一个不会真的strip,推荐使用-s
- strip - s xxx.so -o yyy.so: 移除所有符号信息,裁减好后,输出指定名字的so
不同架构的strip
在Android 手机中 cpu 架构是各不相同的,比如aarch64-linux-android-strip.exe、arm-linux-androideabi-strip.exe因此不同架构下要使用各自的strip裁减对应的so库。
ndk-stack
具体的用法如下
要使用 ndk-stack
,首先要有一个包含未剥离版应用共享库的目录。如果您使用 ndk-build
,则可在 $PROJECT_PATH/obj/local/<abi>
中找到这些未剥离版共享库,其中 <abi>
是崩溃设备的 ABI,通过查看崩溃日志中ABI信息可以获取到。(so路径只需要到<abi>
这一级,不是so的路径,不然会解析不出来)
使用此工具的方式有两种。您可以将 logcat 文本作为直接输入馈送到程序。例如:
adb logcat | $NDK/ndk-stack -sym $PROJECT_PATH/obj/local/armeabi-v7a
您也可以使用 -dump
选项将 logcat 指定为输入文件。例如:
$ adb logcat > /tmp/foo.txt
$NDK/ndk-stack -sym $PROJECT_PATH/obj/local/armeabi-v7a -dump foo.txt
该工具会在开始解析 logcat 输出时查找第一行星号。例如:
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
具体信息可以查看官方文档
上面的异常堆栈命令如下
$ ndk-stack -sym \app\build\intermediates\cmake\release\obj\arm64-v8a\ -dump crash_log.txt
解析结果如下
addr2line工具
按照名字可以知道,此工具是将异常堆栈的地址转换成行号的工具,用法如下,后面的地址见异常堆栈,如下所示,libnesample.so为我们自己的so,所以地址就是000000000000e8e8,也可以传递多个地址,空格隔开
$ aarch64-linux-android-addr2line -f -C -e libnesample.so 000000000000e8e8
解析结果如下
$ aarch64-linux-android-addr2line.exe -f -C -e libnesample.so 000000000000e8e8
//如下为输出
Java_com_example_nesample_MainActivity_stringFromJNI
C:/Users/R22345/Desktop/NESample/app/src/main/cpp/native-lib.cpp:16
如果解析失败,会显示问号,一般解析失败的原因就是so与堆栈不匹配
注意事项
当我们使用addr2line工具的时候会发现,会有很多不同版本的工具比如arm-linux-androideabi-addr2line、aarch64-linux-android-addr2line、x86_64-linux-android-addr2line,所以我们需要使用对应版本的工具,不然会报File format not recognized
错误
32位CPU:arm-linux-androideabi-addr2line
64CPU:aarch64-linux-android-addr2line
x86CPU:i686-linux-android-addr2line
x86_64CPU:x86_64-linux-android-addr2line
一般手机都用aarch64-linux-android-addr2line即可,也可以通过命令去查看,如下所示,处理器类型为aarch64
$ adb shell cat /proc/cpuinfo
其他工具
aarch64-linux-android-c++filt(c++filt工具)
windwos版本有很多不同的版本,如果提示File format not recognized,换一个版本
因为C++ name demangling的存在,我们查看堆栈的时候,可能函数名字变成一个_Z开头的的字符串,可以使用此工具还原
$ arm-linux-androideabi-c++filt.exe _ZN8gameplay14PituCameraGame10initializeEv
//结果
gameplay::PituCameraGame::initialize()
aarch64-linux-android-nm(nm工具)
windwos版本有很多不同的版本,如果提示File format not recognized,换一个版本
nm命令是linux下自带的特定文件分析工具,一般用来检查分析二进制文件、库文件、可执行文件中的符号表,返回二进制文件中各段的信息。
$ aarch64-linux-android-nm.exe -D libnesample.so > auttt.log
举个例子
#00 pc 000000000000e8e8 /....../libnesample.so (Java_com_example_nesample_MainActivity_stringFromJNI+28) (BuildId: 55b26db218ce4b27f098e20358fe013adec533c1)
首先使用nm命令导出符号表,然后查找对应的函数名,上面的例子是Java_com_example_nesample_MainActivity_stringFromJNI
结果如下
000000000000e8cc T Java_com_example_nesample_MainActivity_stringFromJNI
e8cc转换为10进制为59596,然后加上偏移量28为59624,59624转换为16进制为e8e8,与堆栈的pc地址相符
参考链接
https://zhuanlan.zhihu.com/p/352651095
https://blog.csdn.net/hexingen/article/details/124860298
https://zhuanlan.zhihu.com/p/42833833
https://bugly.qq.com/docs/user-guide/symbol-configuration-android/?v=20220805151734