崩溃捕获的三把利刃:Mach异常、Unix信号、NSException
启动阶段的崩溃,说白了就是App还没站稳就倒了。要抓住它,你得先搞懂iOS底层是怎么通知咱们「出事了」的。我个人习惯把这三套机制看作一个层层递进的报警系统:Mach异常是硬件级别的哨兵,Unix信号是内核翻译官,NSException是OC层面的管家。你想想看,一个崩溃从CPU触发到你的try-catch,中间走了多少路?
Mach异常:最底层的硬件报警
这是XNU内核的看家本领。当CPU遇到非法指令、内存访问错误、除零等问题时,会触发一个trap,内核把这件事包装成Mach异常消息,发给对应的线程或任务。我在项目中遇到过一种诡异的闪退——只在A12及以上芯片复现,后来定位到是NEON指令某条边界没对齐,Mach异常直接把线程干掉了。
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);
}
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异常。
三者的关系与实战选择
我用一张图帮你理清它们之间的关系:
实战捕获方案对比
下面这张表我结合自己修复过的几个线上崩溃总结的,直接拿去用:
| 捕获手段 | 能抓什么 | 局限 | 我的建议 |
|---|---|---|---|
| @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异常,直接走了信号流程。
从那以后,我的启动崩溃捕获方案变成了三层防守:
- 第一层:用
task_set_exception_handlers注册Mach异常端口,确保最底层的崩溃也能被记录 - 第二层:注册
SIGABRT、SIGSEGV等信号处理函数,做一些轻量级日志 - 第三层:调用
NSSetUncaughtExceptionHandler,兜住OC层面的异常
嗯,这里要注意:Mach异常处理函数里不能调用OC方法,必须用纯C写。我习惯用mmap开一块固定内存来写崩溃日志,保证不会二次崩溃。
启动阶段为什么特别容易崩溃?
启动时很多类还没加载完成,runtime的初始化顺序敏感。我记得有一次,在+load方法里做了Runtime方法交换,但交换的目标类还没加载,导致EXC_BAD_INSTRUCTION。这种时候信号处理函数能抓到,但堆栈信息往往被截断。我个人推荐用几行汇编内联来保存寄存器上下文——当然,这个已经超出了本节范围,你如果感兴趣,我们后面专门讲。