出于执行效率、业务安全、复用已有代码的需求,目前市场上越来越多的 Android App 采用 C/C++ 来实现其关键逻辑。C/C++ 有内存管理灵活、与 linux 底层联系更紧密、多种编程范式等特点,但也正是由于这些特点,使得普通开发人员在使用 C/C++ 开发时,更容易出让进程直接崩溃的 bug。所以能分析 C/C++ 崩溃日志并能从日志中分析出原因,成为 Android 开发人员一项必备技能。本文介绍如何通过分析 Native 崩溃日志来定位出错的 C/C++ 代码及出错原因。
一、Native 崩溃日志格式
extern "C" JNIEXPORT int gen_stack(int i)
{
if (i > 2)
return gen_stack(i - 2) + gen_stack(i - 1);
else
{
int *p = NULL;
*p = 123;
return 1;
}
}
点击并拖拽以移动
当调用 gen_stack(4) 发生 Native 崩溃时,一般 logcat 会打印如下格式的日志:
#00 pc 00000c1c /data/app-lib/com.testbugrpt-1/libtestNDKCrash.so (gen_stack+27)
#00 表示堆栈序号
pc 00000c1c 表示崩溃发生时 程序计数器 位于 libtestNDKCrash.so 偏移 0xc1c 处
gen_stack+27 表示0xc1c处正好是 gen_stack 符号(此处为函数名)偏移为27的一条指令
#01 pc 00000c0f /data/app-lib/com.testbugrpt-1/libtestNDKCrash.so (gen_stack+14)
这是第二层堆栈,表示在离 libtestNDKCrash.so 0xc0f(也就是gen_stack + 14)位置的指令发生了一次函数调用,产生了第一层堆栈。
二、Native崩溃分析工具
在介绍工具之前,先简单讲一下有调试与无调试信息的两个版本 so 。 一个含有 native 代码的 app 项目的典型结构是这样的:
--jni
--Android.mk
--其它源文件
--libs
--armeabi
--armeabi-v7a
--arm64-v8a
....
--obj
--local
--armeabi
--armeabi-v7a
--arm64-v8a
....
点击并拖拽以移动
通常一次编译会先生成一个有含有调试信息的 so, 路径通常是在 obj/local/ 各 abi 目录下,其中还有一些中间文件(比如.o文件);再通过对这些含有调试信息的 so 进行一次 strip , 产生对应的无调试信息 so, 放到 libs 目录下各 abi 目录中, 发布产品时,我们都是用这些 strip 后的 so。
一般的分析崩溃日志的工具都是利用含有调试信息的 so, 结合崩溃信息,分析崩溃点在源代码中的行号。
ndk-stack
ndk-stack.exe位于ndk根目录。运行以下命令:
D:\Android\android-ndk-r10c\ndk-stack.exe -sym E:\workspace\TestBugrpt\app\src\main\obj\local\armeabi-v7a\ -dump log.txt
其中 log.txt 为崩溃日志,可以从 monitor 中点击保存得到。或者运行:
adb logcat | ndk-stack.exe -sym E:\workspace\TestBugrpt\app\src\main\obj\local\armeabi-v7a\
这样再运行程序,当崩溃发生时,ndk-stack.exe 会自动从 logcat 中获取崩溃日志。
运行以上命令时,要 注意 -sym 参数指示的路径都是 obj\local\ 目录,同时要匹配对应机器的 abi 目录。可以得到:
表明gen_stack + 27对应testNDKCrash.cpp的第13行,即*p = 123; 查看对应的源代码,可以发现是此处的写空指针导致崩溃。
addr2line
addr2line 一般位于 android-ndk-r10c\toolchains\arm-linux-androideabi-4.9\prebuilt\windows\bin\ ,其路径与文件名因操作系统、 abi 不同而有所不同。
可以运行如下命令:
arm-linux-androideabi-addr2line.exe -e E:\workspace\TestBugrpt\app\src\main\obj\local\armeabi-v7a\libtestNDKCrash.so 00000c1c 00000c0f
与 ndk-stack 不同的是,ndk-stack 接受一个 obj/local/abi 目录为参数,而 addr2line 接受 local 下一个具体的 so 文件路径为参数。其中00000c1c 00000c0f 就是上面第一节中分析的崩溃点离libtestNDKCrash.so的偏移量,即
得到输出:
E:/workspace/TestBugrpt/app/src/main//jni/testNDKCrash.cpp:13
E:/workspace/TestBugrpt/app/src/main//jni/testNDKCrash.cpp:9
分别对应两个偏移在源码中的位置。
objdump
上面两种工具都是将崩溃点对应到源码再进行分析,objdump 则是可以在汇编层对崩溃原因进行分析。当然这要求开发人员了解一些 arm/x86 汇编知识。
objdump 也是 ndk 自带的一个工具,通常与 addr2line 在同一目录。运行如下命令:
arm-linux-androideabi-objdump.exe -S -D E:\workspace\TestBugrpt\app\src\main\obj\local\armeabi-v7a\libtestNDKCrash.so > e:\dump.txt
由于输出比较多,将输出重定位到 e:\dump.txt 便于查看。打开 dump.txt , 定位到 00000c1c :
int *p = NULL;
*p = 123;
c16: 2300 movs r3, #0
c18: 227b movs r2, #123 ; 0x7b
c1a: 1c68 adds r0, r5, #1
c1c: 601a str r2, [r3, #0]
点击并拖拽以移动
上面两句是源代码,下是对应的Arm汇编。
如果要分析的 so 没有调试信息, ndk-stack 与 addr2line 就无能为力了,只有 objdump 还能派上用场。当然,这种情况下有更好用的工具,比如 IDA Pro。不过那又是另外一个故事了。
三、常见崩溃类型及原因
SIGSEGV 段错误
SEGV_MAPERR 要访问的地址没有映射到内存空间。 比如上面对空指针的写操作, 当指针被意外复写为一个较小的数值时。
SEGV_ACCERR 访问的地址没有权限。比如试图对代码段进行写操作。
SIGFPE 浮点错误,一般发生在算术运行出错时。
FPE_INTDIV 除以0
FPE_INTOVE 整数溢出
SIGBUS 总线错误
BUS_ADRALN 地址对齐出错。arm cpu比x86 cpu 要求更严格的对齐机制,所以在 arm cpu 机器中比较常见。
SIGILL 发生这种错误一般是由于某处内存被意外改写了。
ILL_ILLOPC 非法的指令操作码
ILL_ILLOPN 非法的指令操作数
当调用堆栈中出现 stack_chk_fail 函数时,一般是由于比如 strcpy 之类的函数调用将栈上的内容覆盖,而引起栈检查失败。
SIG是信号名的通用前缀。ILL是 illegal instruction(非法指令) 的缩写。SIGILL 是当一个进程尝试执行一个非法指令时发送给它的信号。
可执行程序含有非法指令的原因,一般也就是cpu架构不对,编译时指定的march和实际执行的机器的march不同。这种情况,因为工具链一样,连接脚 本一样,所以可执行程序可以执行,不会发生exec format error。但是会包含一些不兼容的指令。还有另外一种可能,就是程序的执行权限不够,比如在目态下运行的程序只能执行非特权指令,一旦CPU遇到特权指 令,将产生illegal instruction错误。
SIG是信号名的通用前缀。ILL是 illegal instruction(非法指令) 的缩写。SIGILL 是当一个进程尝试执行一个非法指令时发送给它的信号。
可执行程序含有非法指令的原因,一般也就是cpu架构不对,编译时指定的march和实际执行的机器的march不同。这种情况,因为工具链一样,连接脚 本一样,所以可执行程序可以执行,不会发生exec format error。但是会包含一些不兼容的指令。还有另外一种可能,就是程序的执行权限不够,比如在目态下运行的程序只能执行非特权指令,一旦CPU遇到特权指 令,将产生illegal instruction错误。