作者简介:dc, 天天P图AND工程师
Android上比较常见的问题除了ANR、Java Crash还有Native Crash,尤其是像天天P图这样的具备拍摄能力的APP,使用了大量native代码进行性能提升。Native Crash常常发生在带有Jni代码的APP中,或者系统的Native服务中。作为比较难分析的一类问题,Native Crash其实还是有较多的方法去定位。
常见导致Native Crash的原因有以下几种:
1. jni内部数组越界、缓冲区溢出、空指针、野指针等;
2. jni中多线程出现竞争,比如一个线程调用jni接口释放了内部一个指针,另一个线程调用另外一个jni接口还在使用这个指针;
3. Android ART发现或出现异常;
4. 其他framework、Kernel或硬件bug;
一个典型的Native Crash日志如下:
对于不同的机器以及不同的系统,除了打印出的native的调用栈,还可能出现Java调用栈,或者诸如.../base.odex之类的信息。
其中如果出现libart.so(比如上图),不要简单的认为Runtime出现异常,实际上是因为在Java的代码执行过程中,需要Runtime参与方法查找、方法Invoke等操作,所以栈中存在art的信息也是正常的。
另外如果出现base.odex,或者/data/dalvik-cache/arm64/system@framework@boot.oat,是因为Runtime对apk或者framework的dex进行了dex2oat的优化,直接执行机器码。尽管出现这些信息的时候,一般会没有Java的调用栈,但是如果手机可以root,也可以通过oat文件、PC地址、函数偏移量查找到对应的代码。这里涉及的知识暂时不做赘述。
从常见的调用栈中,我们也可以看到Native Crash的一般类型:
1. Abort:Abort一般是Runtime通过libc主动进行的中止操作;
2. 空指针解引用:Jni代码出现空指针;
3. 低地址解引用:一般是结构体指针出现空指针,访问内部变量的偏移地址;
4. 栈破坏:内存越界、缓冲区溢出等;
5. 其他:多线程或者其他原因导致。
除了Jni代码可能导致Native Crash,系统的native进程或者服务以及dex编译生成的机器码oat也都可能以为缺陷出现Crash,表现也是Native Crash。
一旦出现Native Crash,系统或者Runtime产生对应的信号,然后通过对应的信号处理函数进行处理。
Android的Zygote在Fork进程的时候,都会在InitNonZygoteOrPostFork时调用StartSignalCatcher创建一个新的SignalCatcher线程,这个线程的作用就是用来捕获Linux信号。
这个线程也是通过pthread_create创建,运行起来之后,会一直等待信号的到来:
以上代码可以看出,只处理两种类型的信号,一种是SIGQUIT,一种是SIGUSR1。
对于SIGUSR1,也就是kill -10产生的信号,Runtime会强制进行GC并保存GC的profile信息。
对于SIGQUIT,即kill -3产生的信号,Runtime则会dump fingerprint、ABI、Build type、线程调用栈、GC profile、虚拟内存信息/proc/self/maps等,并产生/data/anr/traces.txt文件。
Linux中对信号的定义在signum.h文件中:
除了SignalCatcher,Runtime在启动的时候会创建一个FaultManager,
FaultManager则会捕获更多真正意义上的信号(SIGABRT/SIGBUS/SIGFPE/SIGILL/SIGSEGV):
SIGABRT一般由Runtime通过调用Runtime::Abort主动发起,一般出现在Jni中参数异常或者Runtime内部出现特定已知问题的时候,比如Runtime中调用LOG(FATAL)时都会调用到Runtime::Abort产生SIGABRT信号:
其他的信号一般原因是:
1. SIGBUS:总线出错,比如数据对齐;
2. SIGFPE:错误的运算操作,比如除零;
3. SIGILL:出现了非法指令;
4. SIGSEGV:访问了一个不合法内存地址,空指针或者内存越界导致的。
实际上FaultManager只对SIGSEGV进行了特殊处理,如果处理不了,也会通过art_sigsegv_fault再交给普通的sigaction进行处理,这样做的原因是,Java中的StackOverFlow以及NullPointerException异常是通过SIGSEGV实现的,如果出现了这些异常,需要先打印出Java调用栈,所以会执行特殊的信号处理函数。
另外对于这几种信号,Runtime在启动时候就通过InitPlatformSignalHandlersCommon注册了信号处理的handler:
如果出现了以上信号,会调用HandleUnexpectedSignalCommon进行处理,处理方式就是打印一些必要的调试信息,包括平台信息、进程线程信息、寄存器信息以及线程调用栈、虚拟内存信息等(这些信息除了能在logcat中看到以外,还能在手机的/data/tombstones/中看到):
分析Native Crash最直接的方式是查看logcat日志,一般情况下,只要APP没有自己实现信号捕获机制(比如使用了Bugly插件或者google breakpad),就不会影响到Runtime正常打印调用栈。我们通常只需要执行
adb logcat|grep DEBUG
就能过滤出Native Crash的日志,比如:
从日志中,我们可以看到以下信息(以上图为例):
1. 手机的型号:HUAWEI/VTR-AL00/HWVTR;
2. Android系统版本:8.0.0
3. 系统的build号:358(C00)
4. 系统的类型:user(对应有user/eng/userdebug/optional等,user表示是release版本,eng是调试版本)
5. ABI信息:arm
6. Crash的进程号:4090
7. Crash的线程号:4489
8. Crash的线程名称:GLThread 23038(名称可能被裁减导致不全)
9. Crash的进程名称:com.tencent.ptuxffectssdkdemo
10. 信号名:signal 11 (SIGSEGV)
11. 信号产生的原因:code 2 (SEGV_ACCERR)(如果信号是SIGABRT,则对应原因可能是SI_USER/SI_QUEUE/SI_TKILL/SI_KERNEL,其中SI_TKILL表示程序使用tkill发出的信号,如果是SI_USER,表示是用户手动发起的信号,比如使用命令kill -3杀死进程)
12. 异常的内存地址:fault addr 0xc002c85c(如果是SIGSEGV/SIGBUS等信号,一般都会有异常内存地址显示)
13. 中止消息:无(如果是SIGABRT,可能有类似java_vm_ext.cc:504] JNI DETECTED ERROR IN APPLICATION: java_array == null这样的消息)
14. 寄存器指向的内存地址:
r0 bca89e40 r1 bca89e40 r2 c002c85c r3 0002e8b0r4 00000000 r5 e6d352f0 r6 c1b1323c r7 c1b13110r8 00000056 r9 c589bc00 sl c1b13240 fp c589bc00ip 00000002 sp c1b13000 lr bfef0c81 pc c002c85c cpsr 60070010
15. Native调用栈:
#00 pc 0000285c /data/app/com.tencent.ptuxffectssdkdemo-LgQTLgNk9enAtLgqXLJJ_g==/lib/arm/libgameplay.so (offset 0x2e0000)
......
另外,对于寄存器的描述,可以参考《Procedure Call Standard for the ARM® Architecture》以及《Procedure Call Standard for the ARM 64-bit Architecture (AArch64)》:
以上这些寄存器对于我们分析函数参数传递等具有重要的意义。
如果发现由于使用了Bugly等插件导致无法正常打印出这些信息,那么建议关闭这些插件再复现问题。
当然,如果你的手机有root权限,可以pull出tombstone文件,目录在/data/tombstones/。tombstone文件是在出现Native Crash时的崩溃转储文件,一般最多保存10个文件,如果有新的Crash则会覆盖掉旧的文件。
tombstone文件相比logcat能提供更为丰富的调试信息,比如栈内存dump,寄存器指向内存地址周围的内存dump,以及从/prop/<pid>/maps/中cat出的虚拟内存信息。这些信息对于调试内存问题尤为重要。
一个典型的寄存器r5(r5 9c083d20)指向内存地址附近内存dump如下:
关于tombstone文件的详细解读,可以参考:https://source.android.com/devices/tech/debug/native-crash#crashdump
分析tombstone文件时,需要注意一点是,如果是SIGABRT信号,一般会有一条Abort Message,这条信息基本上可以说明该问题出现的原因,比如jni参数空指针之类(SIGABRT信号一般出现在assert失败时产生的Crash中)。
分析Native Crash最关键的是看调用栈,一个有效的调用栈可以直接定位到问题出现的现场,当然也不排除调用栈对应不上问题现场的现象。
Native调用栈的每一条都由几部分组成,以#00 pc 0004b3ac /system/lib/libc.so (tgkill+12)为例:
1. 栈帧号:#00
2. pc地址值:pc 0004b3ac
3. 对应的虚拟内存映射区域名称(通常是共享库或可执行文件):/system/lib/libc.so
4. PC 值对应的符号:tgkill
5. 符号偏移量(以字节为单位):12
由于app中的so是通过jni代码编译而来,编译出的so如果有对应的调试信息,就可以通过这些调试信息找到符号对应的代码行,这些调试信息就是符号表,包括symtab以及debug相关的section。
对于一个so,不同的信息以section节的方式组织,通过arm-linux-androideabi-readelf -S <so-file-name>可以看到该section信息。
以下是一个典型的没有symtab符号表的so信息(这个so是经过执行gradle任务transformNativeLibsWithStripDebugSymbolForDebug时strip后的):
而下面这个则是带有符号表的so信息:
正常情况下,cmake编译的so是分为两种,一个是libs下的不带符号表的so,一个是objs下面带有符号表的so,调试的时候需要用到objs下面的文件。尽管可以将带符号表的so放到lib/armeabi下面进行打包,但是因为打包apk时会自动执行transformNativeLibsWithStripDebugSymbolForDebug这样的gradle任务,最终这些调试信息会在打包apk strip掉,可以在gradle中增加以下选项禁止strip:
packagingOptions{
doNotStrip "*/*/*.so"
}
有了带符号表的so,我们可以使用addr2line工具从调用栈找到对应代码行:
arm-linux-androideabi-addr2line -Cpfie <symbol-so-path> <pc-address>
比如,对于调用栈:
#01 pc 001a7c7f /data/app/com.tencent.ptuxffectssdkdemo-LgQTLgNk9enAtLgqXLJJ_g==/lib/arm/libgameplay.so (_ZN8gameplay14PituCameraGame10initializeEv+1298)
它的符号(函数名称)为_ZN8gameplay14PituCameraGame10initializeEv(使用arm-linux-androideabi-c++filt _ZN8gameplay14PituCameraGame10initializeEv
从签名还原之后为gameplay::PituCameraGame::initialize()),函数内部偏移地址为十进制1298,pc地址为十六进制001a7c7f:
如果出现无法解析的现象,可能是因为当前符号表so与实际出现Crash的so不匹配(比如使用新代码编译的带符号表的so)。出现这样的现象时,对于一种情况,仍然可以进行解析,即确保当前出问题的native函数没有进行过修改,代码内部偏移量仍然有效。首先使用arm-linux-androideabi-nm -D module_video/libs/armeabi/libgameplay.so | grep _ZN8gameplay14PituCameraGame10initializeEv
查找到符号起始地址,然后再加上调用栈中的偏移量(比如上面例子中的1298),然后将新的地址给addr2line进行解析。
另外,Android为了简化addr2line解析整个Crash全部调用栈的过程,提供了ndk-stack脚本工具批量处理,有兴趣可以看下它的Python源码:
如果我们的调用栈中出现了app对应的odex/oat文件,则可以导出oat并使用objdump工具查找到对应的java代码。这个过程需要分析编译器从dex生成的汇编机器码,然后根据一定规则映射到dalvik字节码的指令偏移上,从而找到对应的Native代码的Java调用栈,这里以后有空再介绍。
新版的Android Studio支持直接创建带有Native代码的工程,并使用cmake编译jni代码,内部使用llvm+lldb进行编译和调试。尽管Android Studio默认不使用gdb进行调试,我们仍然可以使用gdb对我们的native代码进行调试,因为gdb是一款优秀的调试工具,尤其是对于我们的native源码单独进行编译,与java工程不一起管理的时候,除非我们能轻易将native代码放到Android Studio进行cmake编译。
在Android上使用gdb编译不是一件轻松的事情,但是也并不复杂。Android SDK中实际上已经包含了一套gdb调试工具,我们直接拿来使用即可。首先你需要找到这些工具,包括gdb+gdbserver:
其中gdbserver是用在target(手机)中附加到进程进行调试的服务,而gdb则是host上用于调试的界面,或者叫做client,另外你还可以给gdb加上一个图形界面。
完整的调试架构大致如下:
下面我们看看如何让gdb连接上的native代码。步骤分为以下4部分:
1. 将gdbserver放入手机(注意gdbserver可执行程序的abi必须与app的abi一致);
2. adb端口转发;
3. 启动调试器并attach到目标app进程;
4. 通过gdb连接remote的gdbserver开始调试。
如果你的手机已经root了,恭喜你,你可以少走一些弯路。对于root的手机(同时建议通过setenforce 0关闭selinux,防止安全设置禁用某些权限),以上4步可以具体为:
1. push gdbserver到手机:adb push ndk-bundle/prebuilt/android-arm/gdbserver/gdbserver /data/local/tmp/gdbserver
,之后增加可执行权限:adb shell chmod u+x /data/local/tmp/gdbserver
;
2. adb端口转发:adb forward tcp:6666 tcp:6666
3. attch到目标进程:adb shell /data/local/tmp/gdbserver --attach <pid>
4. 使用host版的gdb连接gdbserver:./ndk-bundle/prebuilt/darwin-x86_64/bin/gdb -tui
,然后输入target remote:6666
就可以愉快地开始调试了(这里建议使用sdk中的gdb,而不要用系统的gdb,因为可能存在协议不一致导致gdb无法与gdbserver正常通信)。
另外root的手机可以直接将带有符号表的so push到/data/app/<package-name>/lib/arm/下面替换,方便调试的时候gdb管理源代码。而非root的手机就要使用之前提到的doNotStrip方式打包进apk进行调试了。
如果你的手机没有root,那么就可能遇到一堆无权限的问题,比如无权限执行gdbserver、无权限attach到进程、无权限创建socket进行通信等等;这里通过参考Android Studio进行native调试的方法,可以顺利进行调试。
先看看我们用Android Studio的lldb调试器进行native调试时的输出:
$ adb shell cat /data/local/tmp/lldb-server | run-as com.tencent.weishi sh -c 'cat > /data/data/com.tencent.weishi/lldb/bin/lldb-server && chmod 700 /data/data/com.tencent.weishi/lldb/bin/lldb-server'$ adb shell cat /data/local/tmp/start_lldb_server.sh | run-as com.tencent.weishi sh -c 'cat > /data/data/com.tencent.weishi/lldb/bin/start_lldb_server.sh && chmod 700 /data/data/com.tencent.weishi/lldb/bin/start_lldb_server.sh'Starting LLDB server: /data/data/com.tencent.weishi/lldb/bin/start_lldb_server.sh /data/data/com.tencent.weishi/lldb unix-abstract /com.tencent.weishi-0 platform-1533822977589.sock "lldb process:gdb-remote packets"Debugger attached to process 12824
从上面可以看出,Android Studio通过cat输出lldb-server并run-as以应用的权限执行cat进行接收,然后将lldb-server写入到app的私有数据目录,紧接着chmod 700增加可执行权限。然后使用同样的方式将一个shell脚本start_lldb_server.sh发送到app数据目录。最后以app的权限运行脚本启动lldb。
这样我们可以使用同样的方式将gdbserver附加到调试进程:
1. push gdbserver到手机:先创建目录adb shell mkdir /data/local/tmp/
,然后push文件:adb push ndk-bundle/prebuilt/android-arm/gdbserver/gdbserver /data/local/tmp/gdbserver
;
2. 将gdbserver拷贝到调试app的私有数据目录:adb shell "cat /data/local/tmp/gdbserver | run-as <package-name> sh -c 'cat > /data/data/<package-name>/gdbserver && chmod 700 /data/data/<package-name>/gdbserver'"
3. adb端口转发:由于非root手机没有权限创建socket,可以转发到localfilesystem上,方法如下:adb forward tcp:6666 localfilesystem:/data/data/<package-name>/debug-pipe
4. 启动gdbserver:adb shell run-as <package-name> ./gdbserver +debug-pipe --attach <pid>
5. 使用host版的gdb连接gdbserver:./ndk-bundle/prebuilt/darwin-x86_64/bin/gdb -tui
,然后输入target remote:6666
就可以愉快地开始调试了
这里我将以上步骤写成了脚本,效果如下:
之后调试界面如下:
还可以给gdb加上一个gui界面,比如基于浏览器的gdbgui:
这样我们就可以方便使用gdb进行各种调试了,比如查看变量值、地址是否空指针等等。另外如果有权限,还可以结合coredump进行Native Crash分析。
Android手机中有个debuggerd进程,当发生Native Crash,系统会自动调用debuggerd来讲信息dump到tombstone文件中。另外也可以主动执行debuggerd,前提是手机要root(可能还需要关闭selinux)。
debuggerd可以直接打印native调用栈,用法是debuggerd [-b] PID
,如下:
对于应用开发者,通常app到用户手机上安装之后,出现问题很难获取对应日志,那么使用Bugly或者google breakpad就可以拿到一些有用的日志了,原理就是前面讲的信号捕获机制。不过还是不建议在日常调试过程中启用这类插件,避免丢掉有效的信息。
由于常见的Native Crash问题大多是内存问题导致,如果是系统开发者,还可以使用以下valgrind、checkjni和Address Sanitizer等工具进行代码前期的问题扫描。
如果是因为加载so或者link so导致的问题,本人实现了几个脚本,可以方便地获取到so文件之间的依赖关系(便于确定加载so的顺序),以及从大量的so中查找特定符号或者Java 类名。
so依赖关系通过arm-linux-androideabi-readelf及arm-linux-androideabi-nm分析so文件信息,再通过graphviz+dot绘制依赖图,如下:
如果是so文件的UnsatisfiedLinkError,那么只需要搜一下so中的符号就可以,比如:
Android上的Native Crash总的来说还是有章可循,通过分析有效的日志和调用栈以及使用正确的工具进行调试,也可以达到和Java Crash差不多的分析效率。这里仅仅粗略地介绍了一些技术,真正掌握Native Crash的分析方法,还有很多细节可以深究。
[1] https://source.android.com/devices/tech/debug/native-crash [2] ARM Stack Uwinding [3] Stack frame unwinding on ARM [4] https://developer.android.com/training/articles/perf-jni [5] https://www.jianshu.com/p/295ebf42b05b [6] http://gityuan.com/2016/06/25/android-native-crash/ [7] https://mp.weixin.qq.com/s/g-WzYF3wWAljok1XjPoo7w [8] https://wladimir-tm4pda.github.io/porting/debugging_gdb.html [9] https://source.android.com/devices/tech/debug/gdb [10] https://blog.csdn.net/ly890700/article/details/53104773
文章后记: 天天P图是由腾讯公司开发的业内领先的图像处理,相机美拍的APP。欢迎扫码或搜索关注我们的微信公众号:“天天P图攻城狮”,那上面将陆续公开分享我们的技术实践,期待一起交流学习!
加入我们: 天天P图技术团队长期招聘: (1) AND / iOS 开发工程师 (2) 图像处理算法工程师 期待对我们感兴趣或者有推荐的技术牛人加入我们(base 上海)!联系方式:ttpic_dev@qq.com