崩溃捕获的三把利刃:Mach异常、Unix信号、NSException

启动阶段的崩溃,说白了就是App还没站稳就倒了。要抓住它,你得先搞懂iOS底层是怎么通知咱们「出事了」的。我个人习惯把这三套机制看作一个层层递进的报警系统:Mach异常是硬件级别的哨兵,Unix信号是内核翻译官,NSException是OC层面的管家。你想想看,一个崩溃从CPU触发到你的try-catch,中间走了多少路?

Mach异常:最底层的硬件报警

这是XNU内核的看家本领。当CPU遇到非法指令、内存访问错误、除零等问题时,会触发一个trap,内核把这件事包装成Mach异常消息,发给对应的线程或任务。我在项目中遇到过一种诡异的闪退——只在A12及以上芯片复现,后来定位到是NEON指令某条边界没对齐,Mach异常直接把线程干掉了。

⚠️ 记住:Mach异常是最原始的崩溃形态,Unix信号和NSException最终都源于它。

Unix信号:内核给进程的明信片

Mach异常很快会被内核转换为对应的Unix信号。比如EXC_BAD_ACCESS -> SIGSEGV,EXC_ARITHMETIC -> SIGFPE。你能捕获的信号有限,常用的有 SIGABRT、SIGSEGV、SIGBUS、SIGILL、SIGFPE。设置一个信号处理函数,就能在进程被杀死前做点收尾工作。

// 注册信号处理
signal(SIGSEGV, signalHandler);
signal(SIGABRT, signalHandler);

void signalHandler(int signal) {
    // 记录崩溃堆栈
    NSLog(@"捕获到信号: %d", signal);
    // 注意:执行到这里时,进程已经处于不安全的异步状态
    // 我建议只做最轻量的日志写入,然后退出
    _exit(signal);
}
⚠️ 信号处理函数是异步调用的,你不能在里面加锁、分配内存、调用printf等非异步安全的函数。我曾经踩过这个坑,在信号处理里调用了NSLog,结果直接死锁了。

NSException:OC程序员的老熟人

这是Foundation框架提供的面向对象的异常处理机制。无论你是因为数组越界触发了NSRangeException,还是KVC调用错误,最终都会抛出一个NSException对象。但要注意,OC的异常并不等于Mach异常——很多底层的EXC_BAD_ACCESS根本不会触发NSException。

@try {
    // 可能抛出异常的代码
    NSArray *arr = @[@1];
    id obj = arr[5];
} @catch (NSException *exception) {
    // 捕获异常
    NSLog(@"异常名称: %@, 原因: %@", exception.name, exception.reason);
    // 做崩溃上报
} @finally {
    // 清理资源
}

但说实话,@try 不能包治百病。我记得有一次线上崩溃,堆栈停在 objc_msgSend 里,根本走不到 @catch——那是野指针触发的Mach异常。

三者的关系与实战选择

我用一张图帮你理清它们之间的关系:

崩溃捕获三层体系 Mach异常(内核级) EXC_BAD_ACCESS · EXC_ARITHMETIC · EXC_BREAKPOINT 内核自动转换 Unix信号(进程级) SIGSEGV · SIGABRT · SIGBUS · SIGILL · SIGFPE 异常转译(部分) NSException(OC层) NSRangeException · NSInvalidArgumentException · … 应用层捕获 @try-@catch NSSetUncaughtExceptionHandler 无法捕获 信号处理 signal()函数 ⚠️ 实战要点 • Mach异常必须用 task_set_exception_handlers 注册,否则直接被内核处理 • 信号处理函数内不能调用非异步安全函数(malloc、printf、NSLog... • NSException只覆盖OC运行时异常,不覆盖Mach异常

实战捕获方案对比

下面这张表我结合自己修复过的几个线上崩溃总结的,直接拿去用:

捕获手段 能抓什么 局限 我的建议
@try-@catch OC运行时异常(数组越界、KVC错误等) 捕获不到Mach异常和信号;会影响编译器优化(少量) 只包在最外层的启动逻辑,不要包在每个函数
NSSetUncaughtExceptionHandler 未捕获的NSException 线程已异常退出,只能做最后上报 启动时立即注册,用于记录最后一次崩溃
signal() 注册处理函数 SIGSEGV、SIGABRT等6种信号 异步安全限制;无法恢复执行 配合Mach异常端口监控一起用,做兜底日志
Mach异常端口 (task_set_exception_handlers) 所有异常(包括野指针、指令错误) 需要另开线程处理,实现复杂 成熟的三方库(如PLCrashReporter)已经封装好了

我自己的避坑经验

我曾经在一个日活千万的App上接入了全量@try-@catch,以为万无一失。结果上线第二天,凌晨3点报警飙升——全部是SIGSEGV,一个都没被@try抓到。查了一整天,发现是某个第三方SDK里有个野指针,触发了Mach异常,直接走了信号流程。

从那以后,我的启动崩溃捕获方案变成了三层防守:

  1. 第一层:用 task_set_exception_handlers 注册Mach异常端口,确保最底层的崩溃也能被记录
  2. 第二层:注册 SIGABRT、SIGSEGV 等信号处理函数,做一些轻量级日志
  3. 第三层:调用 NSSetUncaughtExceptionHandler,兜住OC层面的异常

嗯,这里要注意:Mach异常处理函数里不能调用OC方法,必须用纯C写。我习惯用mmap开一块固定内存来写崩溃日志,保证不会二次崩溃。

启动阶段为什么特别容易崩溃?

启动时很多类还没加载完成,runtime的初始化顺序敏感。我记得有一次,在+load方法里做了Runtime方法交换,但交换的目标类还没加载,导致EXC_BAD_INSTRUCTION。这种时候信号处理函数能抓到,但堆栈信息往往被截断。我个人推荐用几行汇编内联来保存寄存器上下文——当然,这个已经超出了本节范围,你如果感兴趣,我们后面专门讲。

✅ 一句话总结:Mach异常是根,Unix信号是茎,NSException是叶。你只摘叶不抓根,很多崩溃你就永远看不见。