1. Native Crash 基础:什么是 Native Crash、常见信号类型、Crash 日志初步解读
各位同学,咱们今天正式开讲 Native Crash。说实话,这玩意儿是 Android 开发里最让人头疼的问题之一。Java 层的崩溃你还能看个堆栈,Native 层一崩,很多时候连个像样的日志都没有。我当年刚接触这块时,看到 tombstone 文件直接懵了——这都啥跟啥?
但别怕,这东西其实有规律可循。咱们一步步来。
1.1 什么是 Native Crash?
Native Crash,说白了就是 C/C++ 代码在运行过程中,触发了操作系统层面的异常信号。系统收到这个信号后,默认行为就是杀掉进程,顺便吐一份「遗书」——也就是 tombstone 文件。
跟 Java Crash 不一样,Java 层崩溃是虚拟机内部捕获了异常,还能走个 try-catch。Native 层崩溃是直接跟内核打交道,内核发现你干了非法的事,二话不说就发信号。进程收到信号,要么自己处理,要么死给你看。
关键区别:
- Java Crash:由 ART 虚拟机捕获,可恢复(try-catch)
- Native Crash:由 Linux 内核发送信号,进程直接挂掉
- Java Crash 有堆栈,Native Crash 可能只有寄存器信息
我在项目中遇到过最典型的情况:某个第三方 so 库,在低端机型上频繁闪退。Java 层日志干干净净,logcat 里就一行「signal 11 (SIGSEGV)」。当时排查了整整两天,最后发现是 so 库里的 memcpy 操作越界了。
1.2 常见信号类型
Linux 信号机制是 Native Crash 的核心。每个信号都对应一种特定的错误类型。咱们重点掌握几个最常见的:
| 信号 | 数值 | 含义 | 常见原因 |
|---|---|---|---|
| SIGSEGV | 11 | 段错误 | 访问非法内存地址、空指针解引用 |
| SIGABRT | 6 | 异常终止 | assert 失败、abort() 调用、堆损坏检测 |
| SIGBUS | 7 | 总线错误 | 未对齐的内存访问、映射文件访问越界 |
| SIGFPE | 8 | 浮点异常 | 除零操作、浮点运算溢出 |
| SIGILL | 4 | 非法指令 | CPU 执行了不认识的指令、代码损坏 |
| SIGTRAP | 5 | 断点陷阱 | 调试断点、int3 指令 |
这里面,SIGSEGV 和 SIGABRT 占了 Native Crash 的 90% 以上。我个人的经验是:看到 SIGSEGV,先怀疑指针问题;看到 SIGABRT,先怀疑堆内存损坏或者 assert 失败。
小技巧: 在 logcat 里搜「signal」关键字,能快速定位 Native Crash 的信号类型。比如「signal 11 (SIGSEGV), code 1 (SEGV_MAPERR)」——code 1 表示访问了未映射的内存区域,说白了就是空指针或者野指针。
1.3 信号背后的故事
你可能会问:为什么是信号?为什么不是直接报错?
嗯,这得从 Linux 的设计说起。内核没法知道你的程序逻辑对不对,它只能管最底层的事——内存访问、指令执行、权限检查。一旦发现违规,内核就发个信号,让进程自己决定怎么处理。
进程可以注册信号处理函数(signal handler),捕获信号后做清理工作。但大多数 Native Crash 都没有自定义 handler,所以系统就用默认行为——终止进程,生成 tombstone。
我曾经遇到过一个坑:某个 so 库自己注册了 SIGSEGV 的 handler,在里面做日志上报。结果 handler 本身也有 bug,导致信号嵌套,直接死循环。最后手机重启才解决。所以啊,自定义信号 handler 要格外小心,别在 handler 里干复杂的事。
1.4 Crash 日志初步解读
Native Crash 的日志主要来自两个地方:
- logcat:实时输出,能看到信号信息和简短的调用栈
- tombstone 文件:保存在 /data/tombstones/ 目录下,内容更详细
咱们先看一段典型的 logcat 输出:
F/libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 12345 (RenderThread), pid 6789 (com.example.app)
F/DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
F/DEBUG : Build fingerprint: 'google/redfin/redfin:12/SP1A.210812.016/12345678:user/release-keys'
F/DEBUG : Revision: '0'
F/DEBUG : ABI: 'arm64'
F/DEBUG : Timestamp: 2024-01-15 10:30:45.123456789+0800
F/DEBUG : pid: 6789, tid: 12345, name: RenderThread >>> com.example.app <<<
F/DEBUG : signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
F/DEBUG : x0 0000000000000000 x1 0000007f8b4c2000 x2 0000000000000010
F/DEBUG : x3 0000000000000000 x4 0000000000000000 x5 0000000000000000
F/DEBUG : x6 0000000000000000 x7 0000000000000000
F/DEBUG : x8 0000000000000000 x9 0000000000000000 x10 0000000000000000
F/DEBUG : x11 0000000000000000 x12 0000000000000000 x13 0000000000000000
F/DEBUG : x14 0000000000000000 x15 0000000000000000
F/DEBUG : x16 0000007f8b4c0000 x17 0000007f8b4c1000 x18 0000000000000000
F/DEBUG : x19 0000000000000000 x20 0000007f8b4c2000 x21 0000000000000000
F/DEBUG : x22 0000000000000000 x23 0000000000000000 x24 0000000000000000
F/DEBUG : x25 0000000000000000 x26 0000000000000000 x27 0000000000000000
F/DEBUG : x28 0000000000000000 x29 0000007f8b4c1f00
F/DEBUG : sp 0000007f8b4c1ef0 lr 0000007f8b4c1234 pc 0000007f8b4c1256
F/DEBUG :
F/DEBUG : backtrace:
F/DEBUG : #00 pc 0000000000001256 /data/app/com.example.app/lib/arm64/libnative.so (offset 0x1000)
F/DEBUG : #01 pc 0000000000001345 /data/app/com.example.app/lib/arm64/libnative.so (Java_com_example_app_NativeBridge_processData+44)
F/DEBUG : #02 pc 0000000000005678 /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+128)
F/DEBUG : #03 pc 0000000000007890 /apex/com.android.art/lib64/libart.so (art_quick_invoke_stub+568)
这段日志怎么看?我教你几个关键点:
- 信号类型:signal 11 (SIGSEGV),说明是段错误
- 错误码:code 1 (SEGV_MAPERR),表示访问了未映射的地址
- 错误地址:fault addr 0x0,地址是 0,典型的空指针
- 线程信息:tid 12345 (RenderThread),渲染线程出的问题
- 寄存器:x0 是 0,跟 fault addr 一致,说明 x0 寄存器里存的就是空指针
- 调用栈:#00 在 libnative.so 的偏移 0x1256 处,函数名没符号化
注意: 生产环境下的 so 库通常 strip 过,函数名和行号都丢了。你需要用 addr2line 或者 ndk-stack 工具来还原符号。这个咱们后面章节会详细讲。
1.5 从日志到问题定位
拿到日志后,第一步不是看代码,而是确认「谁崩了」。是主线程还是子线程?是咱们自己的 so 还是第三方的?
比如上面这个例子,线程名是 RenderThread,说明是渲染相关的问题。再看调用栈,#00 在 libnative.so 里,偏移 0x1256。如果你有符号文件,用 addr2line 一查就知道是哪行代码了。
我个人的习惯是:先看 fault addr,再看寄存器,最后看调用栈。fault addr 如果是 0x0,大概率是空指针;如果是 0x5 或者 0xdeadbeef 这种奇怪的值,可能是野指针或者内存被踩了。
避坑指南: 我曾经遇到一个 case,fault addr 是 0x10,看起来像空指针偏移。结果查了半天,发现是某个结构体里的成员偏移算错了。所以啊,别只看地址大小,要结合代码逻辑分析。
1.6 本章小结
Native Crash 的本质是 Linux 信号机制。最常见的信号是 SIGSEGV(段错误)和 SIGABRT(异常终止)。日志解读的关键是:信号类型、错误地址、寄存器值、调用栈。这四个要素能帮你快速定位问题的大致方向。
下一章,咱们会深入讲 tombstone 文件的完整解读,以及如何用 addr2line 和 ndk-stack 还原调用栈。到时候你会觉得,Native Crash 其实没那么可怕。