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 指令

这里面,SIGSEGVSIGABRT 占了 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)

这段日志怎么看?我教你几个关键点:

  1. 信号类型:signal 11 (SIGSEGV),说明是段错误
  2. 错误码:code 1 (SEGV_MAPERR),表示访问了未映射的地址
  3. 错误地址:fault addr 0x0,地址是 0,典型的空指针
  4. 线程信息:tid 12345 (RenderThread),渲染线程出的问题
  5. 寄存器:x0 是 0,跟 fault addr 一致,说明 x0 寄存器里存的就是空指针
  6. 调用栈:#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 其实没那么可怕。