做Android稳定性或者日常绷着神经排查线上bug的人,一定都见过这种日志:
Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid ...第一次看到时,说实话我盯着“signal 11”愣了好一会儿,因为它和Java层的Exception完全不是一个路子。Java异常会告诉你具体哪个类哪一行,而native崩溃只甩给你一个数字、一个信号名、一个地址,剩下的全靠自己推断。
我先明确一点:这里说的“信号量signal”,指的是Linux/Unix操作系统里的signal机制,也就是进程收到内核或另一个进程发来的异步通知信号,而不是多线程同步用的semaphore。虽然中文经常把signal和信号量混着叫,但native crash日志里的SIGSEGV、SIGABRT这些,都是操作系统信号。理解这一点,后面看日志才不会被绕晕。
这篇文章我打算从信号本身说起,然后逐个拆解Android native崩溃里高频出现的signal,再教你从tombstone日志里把code、fault addr、backtrace这些信息串起来,最后给出一套我实际排查时固定会走的步骤和踩坑记录。不管你是刚开始碰ndk,还是已经维护了挺久的崩溃平台,这篇文章应该都能省你一些查资料的功夫。
1. 先建立框架:signal在Android native崩溃链路里的位置
1.1 signal到底是什么:从保险丝到内核通知
signal在Linux里并不是什么复杂概念。你可以把它理解成一台机器的保险丝:程序运行过程中一旦出现越界内存访问、非法指令这类危险操作,CPU和内核就会“跳闸”并给进程发一个信号,通知进程“你干了不该干的事”。
Linux把信号分成两大类:
- 同步信号:由CPU异常直接产生,比如除零、非法访问内存、执行非法指令。这类信号指向当前正在执行的指令,是排查native crash的主角。
- 异步信号:由其他进程或者内核发送,比如Ctrl+C、kill命令、子进程退出。这类信号不一定说明进程崩溃,但SIGPIPE这种由系统在socket写失败时发出的信号,也会导致进程直接被干掉。
进程收到signal之后,默认处理方式一般是终止进程、生成核心转储文件。而Android在默认动作之前插入了一步:它会在正式杀死进程前,让debuggerd这个守护进程介入,把进程当前的内存映射、线程栈、寄存器全部dump下来,写到/data/tombstones,再同步输出到logcat。
这就解释了一个很常见的现象:很多native crash之后,logcat里会先看到一长串带星号的日志,然后才是“Fatal signal 11”。那段带星号的日志,就是debuggerd在进程死透之前努力留下的“尸检报告”。
1.2 debuggerd如何接住signal
debuggerd是Android系统里专管native崩溃的守护进程。它的工作流程大致是:
- 进程收到致命信号,内核准备终止进程时触发信号处理流程。
- debuggerd通过ptrace方式附着到崩溃进程上,接管它的执行。
- debuggerd读取/proc/pid下的各类信息,包括线程列表、内存映射(maps)、寄存器组(regs)、栈回溯(backtrace)。
- 所有信息写入/data/tombstones目录下的tombstone_00等文件,同时在logcat里输出一份简化版日志。
理解这个流程很重要,因为调试的时候你要知道:logcat里看到的崩溃日志其实是debuggerd输出的“转述版本”,完整版在tombstone文件里。很多排查一开始只盯着logcat,结果信息不足,打开tombstone一看才发现关键线索都藏在里面。
1.3 Android端常见致命信号一览
先列一张表,把高频出现的信号放一起对比,后面逐个细说。这张表是我自己整理时用的,省得每次手翻man文档。
| 信号编号 | 信号名称 | 默认动作 | Android native崩溃中的常见场景 |
|---|---|---|---|
| 4 | SIGILL | 终止+核心转储 | 执行非法指令、函数指针跳飞、ABI不匹配 |
| 5 | SIGTRAP | 终止+核心转储 | 断点指令、assert、sanitizer检测到问题 |
| 6 | SIGABRT | 终止+核心转储 | 主动调用abort、堆损坏、C++未捕获异常 |
| 7 | SIGBUS | 终止+核心转储 | 地址未对齐、mmap后访问超出文件末尾 |
| 8 | SIGFPE | 终止+核心转储 | 整数除零、取模除零 |
| 11 | SIGSEGV | 终止+核心转储 | 空指针、野指针、use-after-free、栈溢出 |
| 13 | SIGPIPE | 终止 | 写入对端已关闭的socket/管道 |
| 16 | SIGSTKFLT | 终止 | 协处理器栈错误,Android上极少见 |
需要注意的是,SIGKILL(9)和SIGSTOP(19)虽然也常出现在日志里,但它们不能被进程捕获或忽略,而且多数是系统主动杀掉进程,不属于“程序自己崩了”的典型场景。排查自定义meeting的时候,先把这两者区分出去,能省不少时间。
2. 逐个拆解:8个高频signal的真实含义和排查方向
2.1 SIGSEGV:空指针、野指针、use-after-free
SIGSEGV是整个Android崩溃统计里最常见的一个,日常占所有native crash的50%以上。它的含义是Segmentation Violation,即进程访问了没有权限访问的虚拟地址。
SIGSEGV内部还有细分code,日志里最常见的是这两种:
SEGV_MAPERR:地址没有对应的物理页映射。翻译成人话:你访问了一个空的门牌号,或者这个门牌号根本不存在。SEGV_ACCERR:地址有映射,但访问权限不对。比如向只读页面写入数据,或者访问了已被标记为不可访问的页面。
我实际排查时观察到几种典型场景:
- 空指针解引用:fault addr是0x0,只读代码里很常见。Java层传进来的对象引用为null,JNI层没判空直接解引用,一把梭就崩了。
- 野指针:指针变量存了一个残留地址,fault addr是随机的脏值,看起来不像合法地址。
- use-after-free:内存释放后指针没有置空,再次被访问时,堆内存可能已经被重新分配或者标记为不可用,fault addr往往指向堆地址范围。
- 栈溢出:线程栈被写爆,访问到栈区域之外的虚拟地址,fault addr会落在线程栈边界附近。
这类问题排查的关键不只是看signal,而是要看backtrace和寄存器。先通过addr2line把pc地址还原成具体函数,再比对寄存器里哪个寄存器的值和fault addr一致,就能定位到到底是哪个指针出了问题,然后回到源码里查这个指针的赋值和释放路径。
2.2 SIGABRT:不是“崩溃”而是“主动自杀”
SIGABRT在所有native crash里的出现频率可以排到第二。它的含义是进程主动调用abort()退出。也就是说,SIGABRT不是“程序做错了某件事导致CPU报警”,而是“程序自己觉得没救了,主动终止自己”。
主动调用abort()的源头非常多,我列几个最常见的:
- C++异常没有被捕获:导致std::terminate,最终走到abort。
- 断言失败:代码里的assert等检查条件不满足。
- 堆分配器检测到异常:double free、free一个非法指针、内存越界写坏了堆元数据,libc很可能会主动abort。
- Android的FORTIFY机制:检测到缓冲区溢出等安全问题,会打印“FORTIFY: ...”后abort。
- 栈保护检测:编译器开启-fstack-protector后,函数返回时检查金丝雀值,发现栈被写坏会触发__stack_chk_fail并abort。
- ART运行时主动放弃:比如JNI调用参数严重错误,ART会调用Runtime::Abort。
看到SIGABRT时,最应该先看日志里的“Abort message”。debuggerd会把调用abort前设置的message一起dump出来,这一行往往和崩溃根因强相关。比如:
Abort message: 'terminating with uncaught exception of type std::bad_alloc'这句话基本直接告诉你是内存分配失败且异常没被捕获,接下来就该顺着代码找哪里在new大对象或者在增长容器。
如果没有Abort message,再看backtrace里紧跟在abort、pthread_kill下面的调用者是谁。重点看是libc内部的检测逻辑触发的,还是业务代码主动调用的,这两种方向的排查策略完全不同。
2.3 SIGBUS:对齐和mmap越界的经典场景
SIGBUS字面意思是总线错误,Android上遇到的人比SIGSEGV少,但如果遇到,往往比SIGSEGV更藏。
最常见的两种成因:
- 非对齐访问:某些架构对未对齐的内存访问会直接出错。ARM老一代对这种问题尤其敏感,ARMv8虽然支持不少非对齐访问,但在某些设备或内核配置下仍然会触发。
- mmap文件后越界访问:这是我自己踩得最多的一次坑。用mmap把文件映射到内存,然后按结构体逐字段读,结果结构体尺寸超出了文件实际末尾,一旦访问到超出文件内容的页,内核会触发SIGBUS,而不是SIGSEGV。
为什么越界文件访问是SIGBUS而非法地址是SIGSEGV?简单想:SIGSEGV是“虚拟地址本身不存在或没权限”,SIGBUS是“地址存在,但底层对象没法给你这个数据”,比如文件的页在磁盘上不存在。
日志里SIGBUS也可能带code,常见的有BUS_ADRALN(地址对齐错误)、BUS_ADRERR(物理地址不存在)、BUS_OBJERR(对象特定错误)。遇到SIGBUS,先用code缩小方向,再看backtrace里是不是文件映射读取或指针解引用。
2.4 SIGILL、SIGFPE、SIGTRAP、SIGPIPE、SIGSTKFLT
这几个信号不像SIGSEGV和SIGABRT那么高频,但每一个都值得心里有数。
SIGILL表示执行了非法指令。最常见的是函数指针被写坏,程序跳到了数据区或者未映射区域执行。还有一个场景是ABI不匹配,比如错误地加载了另一种架构的so,函数入口处的指令根本不认识。
SIGFPE是整数除零或取模除零,注意浮点除零不会触发SIGFPE,只会得到inf或nan。所以看到SIGFPE时,第一时间去查代码里的整数除法、取模、ratiol等运算,重点看分母有没有可能为0。
SIGTRAP通常不是业务代码主动触发的,但常见于assert、sanitizer。Android启用AddressSanitizer后,检测到越界写或use-after-free,会打印报告并触发SIGTRAP或SIGABRT。调试时看到SIGTRAP,建议先往上翻日志,看看sanitizer是不是已经输出了详细错误。
SIGPIPE是写入一个读端已关闭的socket或管道时产生的信号。在JNI层做socket通信,尤其自己封装本地socket时,对端关闭后继续write,默认动作就是终止整个进程,而且日志很简单,不一定会留下tombstone风格的完整信息。
SIGSTKFLT在Android移动端真的很少见,遇到的话优先怀疑特定设备或驱动的兼容性问题,先做设备维度数据分析,而不是闷头看堆栈。
3. 实战读日志:signal参数、code、fault addr背后的线索
3.1 一句Fatal signal日志的完整拆解
我随便摘一句典型的崩溃首行日志:
Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 17321 (RenderThread), pid 16888 (com.example.demo)这行日志每一次出现都值得拆开读:
signal 11 (SIGSEGV):收到的信号种类。code 1 (SEGV_MAPERR):该信号下的细分错误码。fault addr 0x0:触发异常的虚拟地址。tid 17321 (RenderThread):崩溃的线程ID和线程名,比pid更重要。pid 16888:整个进程的ID。
我一开始排查时犯过一个低级错误:只看进程名,以为崩溃在主线,结果打开tombstone发现崩在RenderThread,很多渲染相关的问题就被误导了。务必记住:真正崩溃的是tid标记的那个线程,不是pid。
fault addr这行很关键。0x0多半是空指针;如果是一个看起来像堆地址的值,通常是野指针或use-after-free;如果是栈地址附近的值,可能是栈溢出或栈被写坏。
3.2 tombstone里比code更细的ESR信息
如果只是靠logcat里的第一行日志,很多信息是不够的。打开/data/tombstones下的tombstone文件,头部会有更完整的描述,比如进程名、ABI、build指纹、signal详情,以及一条重要的“Cause”信息。
Android 12之后的版本,debuggerd会自动尝试分析崩溃原因,直接在日志里给出类似:
Cause: null pointer dereference Cause: write to read-only memory这个Cause不是瞎猜,它是debuggerd结合fault addr、寄存器、maps等综合分析出来的。排查时先看这一行,能省很多力气。
更细一层,如果能拿到内核日志或者设备root权限,还可以看ARM64的ESR寄存器。ESR里记录了CPU异常的具体类型,比如是translation fault、permission fault还是alignment fault。配合fault addr和code,能把问题从“段错误”细化到“是页表翻译失败还是权限拦截”。不过普通app不一定拿得到这些信息,所以我把这一步放在有调试环境的场景才做。
3.3 用maps文件判断fault addr在什么内存区域
tombstone里一定有当进程的内存映射表,也就是maps。它的格式大致是:
起始地址-结束地址 权限 偏移量 设备 inode 路径比如:
73f0000000-73f0010000 r-- 00000000 08:01 2345 /system/lib64/libart.so 73f0010000-73f001f000 r-x 00010000 08:01 2345 /system/lib64/libart.so权限那段四个字符依次表示r(读)、w(写)、x(执行)、p或s(私有或共享)。拿到fault addr后,去maps里找它落在哪个区间,信息量很大:
- 落在
[anon:libc_malloc]、[anon:scudo]这类匿名映射:是堆内存,可能是use-after-free或者堆越界。 - 落在
[stack]或[anon:stack]:与栈相关,查栈溢出或栈被写坏。 - 落在某个so的映射区:看权限,如果是
r--或r-x但你要写,那就是SEGV_ACCERR的写只读页问题。 - 落在两个映射区之间的空隙:访问了一个完全未映射的地址,空指针和野指针高发。
这里我习惯把fault addr和maps对照记在纸上,比直接看代码来得快。很多时候fault addr落在哪个区决定了完全不同的排查方向。
3.4 案例:模拟项目X的一次SIGSEGV定位
模拟项目X是一款跨平台的轻量渲染引擎,在灰度阶段崩溃率突然上升。线上日志显示:
Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x6f8c002000注意这里的code是2,也就是SEGV_ACCERR,不是常见的1。fault addr也不是0x0。第一反应就不是空指针,而是权限问题。
打开tombstone,backtrace定位到某个纹理缓存释放函数。再查maps,发现fault addr落在一段匿名映射区域,权限是r--(只读)。而崩溃指令是在向这个地址写入数据。
于是顺着代码追:纹理数据在渲染线程发出释放请求后,底层已经对这块内存执行了mprotect保护,改成了只读,但同一时刻另一个线程仍然尝试写入未完成的纹理提交任务。这就是典型的异步回收和同步提交之间缺同步机制。
修复方式是给纹理缓存加引用计数,释放时等所有引用解除才执行mprotect和munmap。这类问题如果只看signal而忽略code和maps,很容易误判成野指针,方向错了会浪费一整天。
4. 从signal到修复:四步定位法
4.1 第一步:解析backtrace得到精确函数
有了tombstone之后,第一步永远是先解析堆栈。手机上的backtrace默认只显示模块名和地址,比如:
#00 pc 000000000008b000 /system/lib64/libart.so #01 pc 00000000000c5340 /system/lib64/libart.so要还原成函数名,最常用的工具是NDK里的llvm-addr2line。拿自己编译的so:
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \ -f -C -e libapp.so 0x8b000这里有个细节容易坑人:backtrace里的pc地址,在Android 10以上通常是相对于模块基址的偏移,可以直接传给addr2line;但某些日志版本输出的是绝对虚拟地址,这时需要先减去该so在maps里的基址,再传给addr2line。
另一个省事的手段是NDK官方的ndk-stack脚本,它可以批量处理logcat或tombstone文件:
$NDK/ndk-stack -sym ./obj/local/arm64-v8a -dump tombstone_00前提是你保留了带符号信息的unstripped so。我吃过一次亏:某次线上crash的so已经strip且没有备份符号表,结果pc地址怎么解析都出不来。现在凡是对外发布的版本,我都会强制存档一份带符号的so,按版本号和构建号归档。
4.2 第二步:结合源码和寄存器判断访问方式
解析出函数名还不够,得回到代码里看崩溃指令本身。ARM64的tombstone会dump出所有通用寄存器x0到x30,以及sp、pc、pstate。
排查时我习惯关注三件事:
- 哪个寄存器的值等于fault addr:这个寄存器通常是那个“非法指针”的来源,知道是x0还是x1传进来的,能判断是哪个参数出了问题。
- pc指向的指令是什么:如果崩溃指令是
str或ldr,说明是普通内存读写;如果是blr或br,说明是间接跳转,函数指针嫌疑大。 - sp和栈上的数据:如果sp已经非常靠近线程栈边界,优先考虑栈溢出。
举个例子,某次分析得到pc地址解析到一个字符串拼接函数,x0的值和fault addr一致,而x0来自前一层函数传入的参数。回溯看调用关系后,发现参数从上个函数拿到的就是null。这一步把问题从“哪里崩”缩小到了“谁传了非法参数”。
4.3 第三步:用Sanitizer复现疑难问题
如果靠堆栈和寄存器仍然定位不到根因,尤其是use-after-free这类内存生命周期问题,静态分析效率很低。这时就得上动态检测工具,Android平台最常用的是AddressSanitizer,也就是ASan。
在CMake层面加:
target_compile_options(app PRIVATE -fsanitize=address -fno-omit-frame-pointer ) target_link_options(app PRIVATE -fsanitize=address)arm64设备上还可以考虑HWASan(硬件加速的地址消毒),性能开销比ASan低不少,但需要系统镜像支持。ASan/HWASan的典型优势是:它能在内存访问发生的那一刻打印精确的分配和释放堆栈,比如“这块内存在哪里申请、在哪里释放、又在哪一行被再次访问”,比事后猜指针强太多。
缺点是慢,只能放在本地复现或内部测试包,不能直接上生产。我们针对线上疑难crash的做法是构造一个内部复现版本,加上ASan后交给测试同学跑同样的操作路径,一旦复现就直接拿报告定位。
4.4 第四步:回归验证与防回归
修完之后不能光看这个crash没有再出现,而是要做两件事:
- 单元测试:针对崩溃路径写一个最小复现用例,把触发条件固定下来,后续每次发版自动跑。
- 监控埋点:如果项目里已经有native crash监控,需要在崩溃现场主动抓取signal、backtrace、maps快照,方便人工分析。
很多团队只做前三步,修完就跑,结果过两个版本又换了个场景崩。本质上还是对内存生命周期没有形成约束。我自己的习惯是任何native修复都要在代码评审里明确“空指针判不判”“生命周期谁管”“线程同步有无完整”。
5. 高频问题与避坑技巧实录
5.1 速查表:信号+code快速判断
整理一张排查速查表,遇到对应日志直接跳到对应思路,省得每次从头想。
| 日志片段 | 优先怀疑方向 |
|---|---|
| SIGSEGV、code 1、fault addr 0x0 | 空指针,看JNI参数和本地指针赋值 |
| SIGSEGV、code 2、fault addr 非0x0 | 写了只读页/不可访问页,查异步释放和mprotect |
| SIGSEGV、fault addr 在堆区域 | use-after-free或堆越界,考虑ASan |
| SIGABRT、Abort message是C++异常 | 查未捕获异常,看new失败或异常边界 |
| SIGABRT、Abort message是FORTIFY | 查strcpy/memcpy越界 |
| SIGABRT、backtrace里有__stack_chk_fail | 栈溢出,查局部大对象或递归 |
| SIGBUS、code BUS_ADRALN | 非对齐访问,查结构体packed和指针强转 |
| SIGBUS、backtrace里有mmap相关 | 文件映射越界访问 |
| SIGILL、pc落在数据段 | 函数指针被写坏 |
| SIGFPE | 整数除零,先查分母 |
| SIGTRAP、前面有Sanitizer输出 | 按sanitizer报告定位 |
| SIGPIPE | socket写端对端关闭 |
这张表不是万能的,只是做第一轮分类非常高效。真实排查往往还需要回头读日志上下文,特别是abort message和sanitizer输出。
5.2 backtrace为空或不全时怎么办
排障时最让人烦躁的情况是:日志里明明有signal信息,backtrace却完全是空的,或者只有几帧明显不连续。
常见的成因:
- so被strip且没有任何符号,debuggerd能输出的只有pc和模块名。
- 栈被严重破坏,unwinder扫描栈内存时得不到有效调用链。
- 线程栈已经指向一个完全未映射的地址,导致回溯直接失败。
处理办法是按优先级尝试:
- 检查tombstone头部的cause字段,有些版本即使backtrace缺失也会给出“null pointer dereference”之类的分析。
- 手动用寄存器重建栈帧:从tombstone里的sp出发,按ARM64栈布局逐字扫描栈内存,寻找看起来像代码段地址的值,再和maps里的so区间比对。这个过程比较手工,但对定位崩溃点很有帮助。
- 关掉编译优化,保留-funwind-table和frame pointer重新出包复现。
另外提醒一句,线上发布包务必保留unstrip符号表,这是backtrace可解析的前提。没有符号表,再完整的backtrace也等于一堆数字。
5.3 信号被第三方库“吃掉”怎么办
有些app接入了带native崩溃捕获能力的SDK,这些SDK往往会在底层设置自己的signal handler。系统debuggerd的默认行为可能会被覆盖,导致logcat里看不到tombstone日志,或者看到的backtrace是SDK自定义处理的现场。
遇到这种情况,我的经验是分两层处理:
- 第一层:查看/data/anr或/data/tombstones目录下是否还有系统生成的tombstone文件,即使logcat被干预,系统守护进程有时仍会dump一份。
- 第二层:关闭SDK拦截能力做对照测试。很多SDK提供“debug模式”或“捕获后直接交给系统”的配置,在内部测试包里把它关掉,就能看到原始日志。
如果线上必须依赖SDK,就要在SDK自己的崩溃回调里把signal、tombstone路径、自定义堆栈保存下来,再通过埋点上传,而不是等logcat。
5.4 其他需要留心的细节
排查native crash越久,越觉得细节决定效率。几个自己常用的经验:
- 先筛选崩溃线程:tombstone的“thread list”里会列出所有线程,真正崩溃的那个会标出signal。其他线程的backtrace大部分时间是噪音,如果你只分析主线程,很可能错过真凶。
- 频繁crash的设备先做机型聚类:某些厂商的GPU驱动、蓝牙驱动对特定so的操作非常敏感,同一段代码在不同机型上表现完全不同。这类问题不是app逻辑错了,而是驱动或ROM兼容性bug,要单独列数据,不要硬塞进统一崩溃组。
- 崩溃率上升时先看版本和热修复逻辑:很多“莫名崩溃增多”的case,翻一下版本记录发现是某个so升级或某次代码合入引起的。
最后再分享一个小技巧:排查SIGSEGV时,不要只盯着崩溃点,而是把它前一次成功运行的路径也拉出来看。很多内存问题不是“发生的瞬间”产生的,而是几十毫秒前就埋下了病根,只是到崩溃点才发作。比如写坏了一块堆内存的相邻区域,崩的时候可能在完全不相关的代码里。这种“延迟引爆”的case,只看signal和backtrace很容易被误导,把日志周边的线程状态、内存分配记录都看一下,反而更能接近真相。
我做稳定性优化这么多年,最大的体会是signal只是起点,它告诉你的不是“为什么崩”,而是“以什么方式崩”。真正决定排查效率的,是你有没有留好符号表、能不能把tombstone读完整、会不会利用code和maps做分类。SIGSEGV、SIGABRT这些名字背得再熟,都不如亲手拿addr2line解一次堆栈来得踏实。如果你是刚开始接触这块,别急着背信号表格,先挑一个线上SIGSEGV把tombstone从头到尾读一遍,把本文提到的方法走通一次,后面自然就顺了。