☰
Android Native崩溃信号全解析:从SIGSEGV到SIGABRT的排查指南
2026/10/17 1:17:47 网站建设 项目流程

做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崩溃中的常见场景
4SIGILL终止+核心转储执行非法指令、函数指针跳飞、ABI不匹配
5SIGTRAP终止+核心转储断点指令、assert、sanitizer检测到问题
6SIGABRT终止+核心转储主动调用abort、堆损坏、C++未捕获异常
7SIGBUS终止+核心转储地址未对齐、mmap后访问超出文件末尾
8SIGFPE终止+核心转储整数除零、取模除零
11SIGSEGV终止+核心转储空指针、野指针、use-after-free、栈溢出
13SIGPIPE终止写入对端已关闭的socket/管道
16SIGSTKFLT终止协处理器栈错误,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报告定位
SIGPIPEsocket写端对端关闭

这张表不是万能的,只是做第一轮分类非常高效。真实排查往往还需要回头读日志上下文,特别是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从头到尾读一遍,把本文提到的方法走通一次,后面自然就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询