☰
Android native crash信号全解析:从SIGSEGV到SIGABRT的定位实战
2026/10/17 5:58:50 网站建设 项目流程

先把一个容易踩的概念坑说清楚:题目里的“信号量”其实是Linux信号signal,不是那个做P/V操作的semaphore。在Android的native crash日志里看到的signal 11、signal 6,是指操作系统投递给崩溃进程的信号编号。搞懂这一堆信号的含义,是排查native崩溃的第一道门槛,否则你拿到tombstone就是两眼一抹黑。

很多同学拿到崩溃日志,看到signal 11 (SIGSEGV)知道是段错误,再往下就不认识了——code 1是什么?fault addr 0x0又说明什么?为什么同样是signal 11,别人说是空指针,你却定位到一个完全不相干的函数?这篇文章就把Android native崩溃里signal的完整含义讲透,包括每个信号怎么来的、code字段如何解读、怎么从三行关键日志快速锁定排查方向,以及我这些年踩过的一些坑。无论你是刚接触NDK开发,还是已经在崩溃治理里挣扎,这篇都值得完整看一遍。

1. native crash和signal的关系,先把底层逻辑理顺

1.1 signal是什么:内核发给进程的犯规哨声

signal本质上是操作系统通知进程“你越界了”的事件机制。进程跑在CPU上,当某条指令触发异常——比如访问了没有映射的虚拟地址、执行了非法指令、整数除零——硬件会把异常上报给内核,内核根据异常类型找到当前正在运行的进程,向它投递对应的signal。进程如果没有注册对应的handler,默认动作就是终止进程,同时Android的debuggerd守护进程会介入,把崩溃现场的寄存器、堆栈、内存映射等关键信息写进tombstone。

这里有一个容易误会的点:很多人以为signal是异步的、随机的,但其实绝大多数native crash触发的signal是同步信号,也就是说导致崩溃的那条指令就在backtrace的顶层帧附近。SIGSEGV、SIGBUS、SIGILL、SIGFPE都是典型的同步信号,它们直接指向出错的代码位置。而SIGPIPE这类信号可能由内核异步触发,跟当前正在执行的指令没有必然关系。搞清楚同步和异步的区别,后面定位问题会省很多力气。

还有一个非常常见的误区:signal本身不等于错误根因。它只是告诉你“哪一类事情发生了”,真正的原因往往藏在触发这条指令之前的一系列状态变化里——一次越界写可能早在几百毫秒前就发生了,只是直到某个校验点或某次指针解引用时才以signal的形式暴露出来。所以搞懂signal的含义只是入口,要把code、fault addr、backtrace和abort message串起来才能还原完整故事。

1.2 崩溃日志里的signal长什么样

Android的native crash日志统称为tombstone,logcat里通常以DEBUG开头,系统会打印一大段现场信息。拿到日志后,最上面几行是这样的:

*** *** *** *** *** *** *** *** *** *** *** *** *** pid: 12345, tid: 12346, name: com.example.crashdemo >>> com.example.crashdemo <<< signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 Cause: null pointer dereference r0 00000000 r1 00000001 r2 00000000 r3 00000000 ... backtrace: #00 pc 0000000000012ab4 /data/app/com.example.crashdemo-.../lib/arm64/libdemo.so (Java_com_example_crashdemo_NativeLib_crash+28) #01 pc 00000000000127c8 /data/app/com.example.crashdemo-.../lib/arm64/libdemo.so (native_thread_entry+132)

关键就三行:

  • signal 11 (SIGSEGV):第几号信号,信号的正式名称。
  • code 1 (SEGV_MAPERR):信号的子类型,告诉你在这一类异常里具体是哪种情况。
  • fault addr 0x0:触发异常的内存地址。这一行对于SIGSEGV和SIGBUS尤其关键,很多时候比backtrace还直观。

1.3 为什么有些崩溃没有backtrace

这是无数人崩溃过的问题:拿到tombstone,signal、code、fault addr都有,偏偏backtrace只有一两个系统库帧,甚至完全空白。原因通常有三个。

第一,栈已经被写坏。比如局部数组越界、memcpy长度算错,把栈上的返回地址覆盖了,这时候回溯逻辑无法沿着栈帧链走,自然出不来有效栈。第二,函数被编译器优化成了尾调用或内联,符号信息里找不到完整链路。第三,so文件本身没有导出符号表,或者被strip过度,backtrace里全是问号。

遇到这种情况,我的经验是不要再死磕backtrace,转而重点分析fault addr和寄存器值,特别是r0到r3这些前四个参数寄存器,往往能看出当时在调用什么函数。实在不行就上AddressSanitizer重编一版,用工具的ErrorStack替代系统backtrace,后面会展开讲。

2. 高频signal逐个拆解:从名字到触发场景

2.1 SIGSEGV(11):内存访问踩雷

SIGSEGV全称Segmentation Fault,段错误,是Android native崩溃里出场率最高的信号。触发条件是进程访问了一个虚拟地址空间中根本不存在的区域,或者访问了一个存在但当前没有权限的区域。最典型的就是空指针解引用——fault addr是0x0,code是SEGV_MAPERR。

但SIGSEGV绝不只是空指针。悬垂指针访问已释放内存、缓冲区越界写坏相邻堆块、在对象生命周期结束后调用其成员函数、use-after-free,这些在日志里往往也表现为SIGSEGV,只是fault addr不是0而已。我见过一个典型案例:一个类A持有另一个类B的裸指针,B在另一个线程被释放了,A的某个回调函数里还去调用B的方法,结果每次崩溃的地址都不一样,因为那个内存块可能已被复用成其他对象。这种崩溃你光看signal是看不出来的,必须结合fault addr和对象分配释放的时序去推。

排查SIGSEGV时,fault addr就是第一线索。addr为0,优先查所有空指针路径;addr是一个看起来像堆地址的随机值,优先查use-after-free和越界写;addr落在栈附近,查栈溢出或栈上数组越界。如果日志里还有Cause行,类似“null pointer dereference”,那是debuggerd根据fault addr做的初步判断,可以参考但别盲信。

2.2 SIGABRT(6):主动认错的崩溃

SIGABRT和前面几个不一样,它不是CPU执行指令触发的硬件异常,而是进程自己调用了abort()主动终止自己。在Android里,SIGABRT的来源非常集中:C++的assert断言失败、libc的Fortify防护检测到堆或栈溢出、某些系统库的CHECK失败、C++异常没有被捕获且没有terminate handler处理——这些都会主动拉下SIGABRT。

tombstone里SIGABRT的code通常是-6,对应SI_TKILL,意思是线程通过tgkill系统调用向自己发送了结束信号。看到这个组合,基本可以确定是某种检查失败或主动abort,而不是内存踩踏。SIGABRT的tombstone里往往有一行特殊的“Abort message”,这一行比backtrace更值钱。例如:

Abort message: 'stack corruption detected' Abort message: 'CHECK failed: ret == 0: (...)' Abort message: 'terminating due to uncaught exception'

我处理过的崩溃里,SIGABRT的定位效率通常是最高的,因为Abort message等于把根因写在脸上了。比如“stack corruption detected”是编译器插桩检测到栈金丝雀被改写,直接告诉你是栈缓冲区溢出。如果Abort message是某个CHECK表达式,那就去代码里查这个CHECK条件为什么会失败。

2.3 SIGBUS(7):总线错误,多为对齐和映射黑洞

SIGBUS全称Bus Error,总线错误。它和SIGSEGV的区别在于:SIGSEGV是地址不存在或没权限,SIGBUS是地址存在但访问方式有问题。最常见的两类触发场景:一类是未对齐的内存访问,code是BUS_ADRALN;另一类是mmap映射的文件,访问时越过了文件实际长度,code是BUS_ADRERR。

ARM架构对未对齐访问尤其敏感,x86上可能只是性能惩罚,ARM64上直接给你SIGBUS。实践中常见于把一段字节流强转成结构体指针,比如从网络缓冲区直接取4字节int,而这个缓冲区的起始地址没有按4字节对齐。我见过一个崩溃率挺高的场景:某个协议解析代码从socket buffer的第3个字节开始读取一个int32,在x86上跑得好好的,换到arm64上一上线就崩,日志就是signal 7 + BUS_ADRALN。解决方式很简单,用memcpy替代强转,不要让编译器生成非对齐的加载指令。

2.4 SIGILL(4):非法指令

SIGILL全称Illegal Instruction,非法指令。触发条件是CPU执行了一条当前架构不识别的指令。在Android上常见原因有三个方向:一是so文件和CPU指令集不匹配,比如库是arm64的,却被一个只支持armeabi-v7a的32位进程加载;二是热修复或某种运行时patch把代码段改坏了,跳转到了一个不是合法指令的位置;三是JIT生成的代码有问题,不过Android上App层的JIT崩溃更多还是落在ART或自家虚拟机里。

SIGILL日志里code是ILL_ILLOPC表示非法操作码,ILL_PRVOPC表示特权指令违规,后者少见。排查时先确认加载的so架构和当前设备ABI是否一致,再看pc地址对应的so段有没有被篡改,最后才怀疑到JIT。曾经有位同事排查一个只在某些老设备上出现的SIGILL,查到最后是构建系统打出来的so实际上是x86_64版本,包名却是arm64-v8a,设备加载后第一条指令就崩。这就是构建产物发错的典型翻车现场。

2.5 SIGFPE(8):算术异常

SIGFPE全称Floating-Point Exception,浮点异常,但实际应用里整数的除零也归它管。code是FPE_INTDIV表示整数除零,FPE_INTOVF表示整数溢出。C++不像Java对除零有明确语义,整数除零在native层直接触发SIGFPE崩溃,没有try-catch可以兜住。

这类崩溃定位最简单,去backtrace顶层帧看是什么除法或者取模操作,然后检查分母变量的来源。如果分母来自外部输入、文件内容、网络字段,记得做非零校验。还有一种隐蔽场景是INT_MIN除以-1,数学结果应该是2147483648,超出int范围,也会触发SIGFPE。这个属于边角料问题,但一旦遇到很难想明白。

2.6 其它常见信号:SIGSTKFLT、SIGPIPE、SIGTRAP、SIGSYS

SIGSTKFLT(16)是栈错误信号,在部分架构上表示栈处理异常,但Android实际的栈溢出崩溃更多表现成SIGSEGV,因为栈空间耗尽后访问栈底之外的地址就会触发段错误。所以如果你看到SIGSTKFLT,先去检查栈溢出和畸形栈指针,但别把它当成唯一可能。

SIGPIPE(13)是管道破裂,最典型场景是往一个对端已关闭的socket写数据。默认行为是终止进程,很多App网络库没做处理就会莫名其妙闪退。Android开发里普遍做法是signal(SIGPIPE, SIG_IGN)忽略它,或者用send的MSG_NOSIGNAL标志,让写错误以返回码形式暴露出来,而不是直接崩。

SIGTRAP(5)是陷阱信号,调试器断点会触发它,但Android里有时也能看到它出现在崩溃日志中——某些断言实现会主动raise(SIGTRAP)来让调试器捕捉。SIGSYS(31)是非法系统调用,Android的多进程沙箱用seccomp过滤系统调用,App如果用了当前版本不允许的syscall,就会收到SIGSYS。这类问题一般是兼容性适配的锅,常见于某些so用到了较新的内核调用。

下面把核心信号的默认行为和优先排查方向汇总成一张速查表,建议收藏:

信号编号默认行为优先排查方向
SIGSEGV11段错误,终止进程空指针、悬垂指针、越界写,fault addr是钥匙
SIGABRT6主动调用abort先读Abort message,查断言、堆栈破坏、未捕获异常
SIGBUS7总线错误,终止进程未对齐访问、mmap越界
SIGILL4非法指令,终止进程so架构不匹配、代码段损坏、JIT异常
SIGFPE8算术异常,终止进程整数除零、INT_MIN/-1溢出
SIGSTKFLT16栈错误栈溢出、栈指针异常
SIGPIPE13终止进程socket对端关闭,建议忽略或MSG_NOSIGNAL
SIGTRAP5陷阱/断点断言、调试器、主动raise
SIGSYS31非法系统调用seccomp沙箱限制、使用了过高版本的syscall

3. 别只看signal,code和fault addr才是定位钥匙

3.1 code字段全解读

很多tombstone里signal和code是连在一起打印的,格式是“signal 11 (SIGSEGV), code 1 (SEGV_MAPERR)”,code部分不少人直接无视,这是最可惜的浪费。code是信号的具体子类型,它决定了同一类信号下不同的处理策略。

SIGSEGV的code最常见是两个:SEGV_MAPERR表示访问的地址根本没有映射到物理内存,SEGV_ACCERR表示地址映射了但访问权限不够,典型场景是往只读内存段写数据。SIGBUS的code常见是BUS_ADRALN表示地址对齐错误,BUS_ADRERR表示mmap文件映射区域之外。SIGILL的code常见是ILL_ILLOPC非法操作码、ILL_PRVOPC特权指令、ILL_ILLADR非法寻址模式。SIGFPE的code常见是FPE_INTDIV整数除零、FPE_INTOVF整数溢出。

SIGABRT有点特别,它的code经常是-6,对应SI_TKILL。负的信号值代表这是一个由tgkill、tkill或kill系统调用发送的signal,而不是硬件异常触发的。看到-6配合SIGABRT,基本就是某段代码主动调用了abort或者检查失败,顺着这个方向找Abort message就对了。如果看到SI_USER,说明信号来自另一个进程的kill,比如被调试器杀死。

3.2 三行日志定位法

我在团队里一直推一套读tombstone的顺序,先看signal和code的组合,再看fault addr,最后才看backtrace。不要一上来就去翻栈,那样容易被系统库帧带偏。

举个例子,看到这样三行:

signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 Cause: null pointer dereference backtrace: #00 pc 0x00000000000284a4 /system/lib64/libc.so (memcpy+132)

如果只看backtrace,大概率会去查memcpy的调用方,但fault addr 0x0已经说明了问题:memcpy的目标地址是空指针。真正的问题是某个对象的指针没有被正确初始化,而不是memcpy本身有问题。这种组合在内存拷贝、字符串拼接、Java层传入的ByteBuffer没有正确GetDirectBufferAddress时非常常见。

反过来,如果signal是11,code是SEGV_ACCERR,fault addr落在某个so的代码段地址范围内,那就是代码段被写了,重点排查缓冲区溢出或者内核态的某些特殊操作。fault addr配合maps文件里的内存段信息,能帮你判断出错地址属于堆、栈、so映射还是空区域,这个判断比去猜函数调用链可靠得多。

4. 从signal到根因:常见原因和排查思路

4.1 每个信号对应的标准排查动作

看懂了signal和code,下一步就是按图索骥。我按信号整理了标准的排查动作,可以直接拿来当作SOP用。

SIGSEGV的处理是:先看fault addr是否0x0,是就搜所有空指针解引用的可能;不是就看fault addr落在maps里哪一段,堆段重点查use-after-free和越界写,栈段重点查栈上数组越界,匿名段重点查已释放的mmap区域。然后用AddressSanitizer或HWAddressSanitizer重编出问题的模块,跑一遍复现场景,ASan能直接报出是哪个分配点、哪个释放点、哪条访问路径。SIGABRT的处理是:第一步永远先看Abort message,结合表达式查失败原因;如果没有message,查调用栈里为什么调用了abort,常见的是断言、terminate、new_handler失败。

SIGBUS的处理是:看code是ADRALN还是ADRERR,ADRALN去检查结构体强转、缓冲区偏移、序列化方式,把非对齐访问改成memcpy;ADRERR去检查mmap的文件长度和实际访问范围。SIGILL的处理是:确认加载的so架构、确认pc指向的映射段是不是代码段、确认so的哈希和发布版本一致。SIGFPE的处理最直接:顶层栈帧找到除法或取模指令,检查分母来源,加上非零校验。

4.2 典型案例复盘

说几个虚构但很典型的案例,把上面的信号串联起来。第一个案例,某App的直播模块偶发闪退,tombstone大量出现signal 11,fault addr 0x0,backtrace顶层是某个网络回调函数。查下去发现回调函数里用了一个已经释放的Socket对象,对象的析构把内部指针置空了,但回调还没执行完。这个问题的本质是生命周期管理,跟网络库本身无关,把回调的注册和注销时机改成同一个生命周期作用域后,崩溃率直接清零。

第二个案例,某个图像处理模块持续上报signal 6,Abort message是“stack corruption detected”。查代码发现一处memcpy长度用了用户上传的原始图片宽度,没做strides对齐调整,拷贝越界写坏了栈上的canary。修好之后很久没有复现,后来把源码审计了一遍,又找出另外两处类似的拷贝点。这类问题的共性在于:堆或者栈的破坏不是当场暴露的,往往要等下一次函数调用或者返回时才被检测到。

第三个案例,某协议解析代码把一个字节流数组强转成结构体指针,结构体里有int32成员,数组起始位置是奇数偏移,arm64上直接signal 7。修复方案就是把强转改成逐字段memcpy解析,既解决了对齐问题,又顺便规避了潜在的小端大端适配问题。

第四个案例,一个NDK库发布后只在某款平板型号上崩溃,signal 4,pc地址指向so的代码段。排查发现那款平板的CPU不支持某些ARMv8.2扩展指令,而构建时编译器开启了该扩展。解决方案是把构建指令集参数降到通用armv8-a,并关闭特定CPU特性选项。

4.3 工具链:从tombstone到源码行

定位工作离不开工具链。当native crash发生时,debuggerd会把tombstone写到设备的/data/tombstones目录,同时logcat里也会输出一份。线上问题拿到的是tombstone文件,线下的第一步是用ndk-stack把PC地址符号化。

如果你有带符号表的so文件,最简单的是ndk-stack:

ndk-stack -sym ./obj/local/arm64-v8a -dump tombstone_01

它会自动读取tombstone里的模块基址,帮你把pc地址换算成符号和行号。如果你的so是release包且没有符号,就得保留一份带符号的版本单独存放,或者用llvm-addr2line手动处理:

$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line -f -C -e libdemo.so 0x12ab4

注意,直接拿tombstone里的pc地址去addr2line通常结果是错的,因为tombstone里的地址是运行时虚拟地址,等于so的加载基址加文件内偏移。你需要先从tombstone的maps段找出这个so的加载基址,把pc减去基址拿到文件偏移,再传给addr2line。ndk-stack自动帮你做了这一步,所以优先用它。

如果是需要反复复现的内存类问题,别犹豫,上AddressSanitizer。Android从NDK r21开始已经内置了ASan的支持,配置好wrap.sh或者用applicationId的android:debuggable方式启动,崩溃日志会非常清晰地告诉你:这是一次heap-use-after-free、heap-buffer-overflow还是stack-buffer-overflow,出错点在哪一行,分配点在哪一行,释放点在哪一行。我之前有个SIGSEGV反复排查三天找不到根因,切成ASan编译后五分钟就定位到一处在对象析构后延迟回调的访问,效率差距非常大。

5. 常见问题与排查技巧实录

5.1 signal和code对不上:比如signal 11但code是0

这个情况看着诡异,signal明明有对应的code枚举,怎么会是0?实际原因是code字段在不同架构和不同内核版本上的定义并不完全一致,某些ARM32的老内核或特定SoC上,SIGSEGV的siginfo里code可能没被正确填充。你只需要知道一件事:code是辅助信息,不是决定信息。code为0时,回归到fault addr和backtrace去分析,路径和正常SIGSEGV完全一样,不用过分纠结。

5.2 Backtrace只有系统库怎么查

这个问题在线上崩溃里出现频率极高,backtrace往往清一色是libc.so、libart.so,崩溃线程自己的so一帧都没有。原因通常是崩溃发生在一次系统调用里,比如memcpy、strlen、malloc内部检测到问题直接触发signal,而这条调用路径因为内联或栈帧损坏没有记录到业务符号。

处理方法是先看fault addr,如果是malloc相关的地址且signal是SIGABRT,很可能是堆损坏,去查所有越界写点。如果是SIGSEGV,把崩溃线程的寄存器值全部列出来,尤其关注r0/r1这些参数寄存器,它们对应的是系统函数的参数,往往能直接看出哪个指针是野指针。再不行就看tombstone里的“memory map around fault addr”段,出错地址附近的分配记录可能指向某个业务so的堆块归属。

5.3 多线程场景的崩溃信号到底属于谁

tombstone里pid是进程ID,tid是线程ID。崩溃信号只发给崩掉的那个线程,但整个进程都会随之终止。所以tid所在的那一行,就是你真正要找的崩溃线程。很多多线程问题看着像偶发race,其实只要确认崩溃线程名,就能缩小到几条代码路径。比如崩溃线程名叫“HWCThread”,那跟渲染线程无关,直接查硬件合成相关的调用。如果线程名被改成了业务语义,比如“VideoDecoder”,那就查视频解码链路的生命周期管理。

还有一个技巧:tombstone里崩溃线程的backtrace会有一个标记,通常形如“#00 pc ...”,而其他线程只有简化的栈信息。先确认你读的是崩溃线程的栈,再开始分析。很多新手拿到多线程tombstone,习惯性去看第一个线程的栈,结果被误导到完全无关的代码路径上。

5.4 信号速查表:signal、code、fault addr组合怎么定方向

把最常见的组合整理成一张实战速查表,遇到崩溃时可以快速对照:

信号codefault addr特征最可能根因排查动作
SIGSEGVSEGV_MAPERR0x0空指针解引用搜空指针路径,查未初始化成员
SIGSEGVSEGV_MAPERR非0堆地址use-after-free或越界写ASan重编,查对象释放时序
SIGSEGVSEGV_ACCERR落在代码段往只读段写数据查字符串拷贝和缓冲区溢出
SIGABRTSI_TKILL无assert/检查失败读Abort message
SIGABRTSI_TKILL无堆栈破坏检查查memcpy长度和数组边界
SIGBUSBUS_ADRALN奇偶地址未对齐访问强转改memcpy
SIGBUSBUS_ADRERR文件映射外mmap越界读检查文件长度和读取偏移
SIGILLILL_ILLOPCpc在so代码段指令集不匹配核对so架构和CPU特性
SIGFPEFPE_INTDIV无整数除零查除法分母来源,加非零校验

最后再分享一个个人习惯:我在实际排查中最常做的第一件事不是翻backtrace,而是先grep一下“signal”和“Abort message”,把这两行贴在一起看。很多时候只看这两行就能确定方向,比直接钻栈舒服多了。搞懂signal只是第一步,真正要把native崩溃治理好,还需要把信号、code、fault addr、abort message、backtrace串成一条完整的故事链——每一次分配、每一次释放、每一次回调都可能是崩溃的真正起点。希望这篇能把你的排查思路捋顺,下次再看到signal 11,别慌,先从fault addr开始读现场。

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

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

立即咨询