
应用上线后,崩溃是开发者最不愿面对的问题之一。线下测试稳定的应用,为何线上会出现崩溃?崩溃日志又是如何采集的?常见的编程疏忽往往是罪魁祸首。
典型崩溃场景分析:
- 数组越界:访问数组时索引超出范围。
- 多线程问题:在子线程更新UI,或数据读写时机冲突(如一个线程置空数据时另一线程正在读取)。
- 主线程无响应:主线程长时间阻塞会被系统Watchdog终止。
- 野指针:访问已释放对象的内存区域。
为了系统性地解决这些问题,深入理解并构建有效的监控方案至关重要。
iOS异常体系分层解析
iOS的异常处理是一个从硬件到应用的层次化体系。理解这一架构,是设计有效监控方案的基础。
-
硬件层异常
- CPU异常:由硬件直接触发,如非法指令、内存访问错误,是所有异常的源头。
-
系统层异常
- Mach异常:macOS/iOS系统最底层的异常机制,源于Mach微内核架构。
- Unix信号:Mach异常通常会被转换为Unix信号,如SIGSEGV、SIGABRT。这是应用层监控的主要捕获点。
-
运行时层异常
- NSException:Objective-C运行时抛出的异常,如数组越界、空指针。
- C++异常:C++代码抛出的异常,最终通过
std::terminate()处理。
-
应用层异常
- 业务逻辑异常:应用自定义的错误。
- 性能异常:如主线程死锁、内存泄漏。
- 僵尸对象访问:访问已释放对象导致的异常。

异常捕获的传递链条:
- 硬件异常被Mach内核捕获,转为Mach异常消息。
- Mach异常可被转换为对应的Unix信号。
- 未处理的NSException或C++异常会触发系统层信号(如SIGABRT)。
- 应用层异常需主动监控。
监控策略对应:
- 系统层监控:通过捕获Mach异常和Unix信号,覆盖所有底层崩溃。
- 运行时监控:设置
NSUncaughtExceptionHandler和C++ terminate handler。
- 应用层监控:主动实现死锁检测、僵尸对象检测等机制。
主流异常监控方案选型:KSCrash
在iOS侧异常监控领域,PLCrashReporter与KSCrash是两个主流选择。两者均开源且经过生产环境检验。

综合对比,KSCrash的核心优势在于:
- 监测全面:同时支持C++异常、死锁检测、僵尸对象检测。
- 异步安全:崩溃处理流程设计为完全异步安全,采用双重异常处理线程确保可靠性。
- 技术先进:具备堆栈游标抽象、内存内省、模块化架构等技术优势。
因此,选择基于KSCrash构建崩溃监控的核心方案。
基于KSCrash的监控方案实现
架构设计
异常采集模块是数据采集层的一部分,其分层架构如下:

- 监控器管理层:统一管理各类监控器。
- 异常捕获层:多种监控器分别捕获Mach异常、信号、运行时异常等。
- 异常处理层:构建崩溃上下文,收集堆栈、内存等信息。
- 报告生成层:将上下文转换为JSON报告。
以下详述各层异常捕获原理。
系统层异常捕获:Mach异常与Unix信号
1. Mach异常捕获
Mach异常是系统最底层的异常。监控步骤如下:

- 创建异常端口:申请一个Mach端口作为异常消息的接收通道。创建前需保存旧端口以保证兼容性。
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &g_exceptionPort);
mach_port_insert_right(mach_task_self(), g_exceptionPort, g_exceptionPort, MACH_MSG_TYPE_MAKE_SEND);
- 注册异常处理器:将任务(进程)的异常端口设置为新端口。
task_set_exception_ports(mach_task_self(), EXC_MASK_ALL, g_exceptionPort, EXCEPTION_DEFAULT, MACHINE_THREAD_STATE);
- 创建异常处理线程:为防止处理线程自身崩溃,需创建主、备两个独立线程。备用线程平时挂起,主线程工作时将其恢复。

- 处理异常消息:线程通过
mach_msg()接收异常消息,随后挂起所有线程、收集机器状态、构建异常上下文并统一处理。
2. Unix信号捕获
作为Mach异常的补充,直接捕获信号能防止Mach处理失败时漏报。信号可能来自未处理的Mach异常,也可能直接由abort()等调用产生。

3. 机器上下文与堆栈还原
崩溃后需从CPU寄存器和堆栈内存还原调用栈。每个函数调用会形成一个堆栈帧,包含返回地址、帧指针(FP)等。

堆栈遍历从程序计数器(PC)和链接寄存器(LR)开始,随后通过帧指针链在内存中回溯。

关键点:需安全访问内存、检测堆栈溢出、对不同CPU架构的地址进行规范化处理。
运行时异常捕获:NSException与C++异常
1. NSException捕获
通过设置NSUncaughtExceptionHandler捕获未处理的Objective-C异常。
NSUncaughtExceptionHandler *previous = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&handle_uncaught_exception);
处理完成后,应调用保存的旧handler,并最终调用abort()终止程序。可通过[exception callStackReturnAddresses]获取调用栈地址。
2. C++异常捕获
通过设置C++ terminate handler来拦截未捕获的异常。
std::terminate_handler original = std::get_terminate();
std::set_terminate(cpp_exception_terminate_handler);

当异常未被捕获时,std::terminate()会被调用,进而执行我们设置的handler。处理完毕后需调用原始的handler。
应用层异常捕获:主动检测
1. 主线程死锁检测
通过独立的监控线程和“心跳”机制实现。

- 监控线程定期向主队列派发一个“心跳”任务。
- 若在设定超时时间内未得到响应,则判定为死锁。
- 注意:需合理设置超时阈值,避免因主线程执行长任务而产生误报。
2. 僵尸对象检测
僵尸对象指已被释放但指针仍被访问的对象,常导致EXC_BAD_ACCESS崩溃。主要原因包括unsafe_unretained指针、多线程竞争、桥接不当等。
检测思路:
- Hook
NSObject 和 NSProxy 的 dealloc 方法。
- 对象释放时,计算其哈希值并记录类信息。
- 当异常发生时,检查该地址是否曾记录为已释放对象。

- 权衡设计:为控制开销,通常限定记录数量上限(如32768个),且哈希冲突可能导致误报。
运行时符号化与异步安全
1. 运行时符号化
崩溃堆栈是内存地址,需转换为可读信息。dladdr()可用于运行时获取函数名和镜像名。
- 地址调整:堆栈存储的是返回地址,需减去指令偏移量才能得到调用地址。例如ARM64架构:
uintptr_t address = (return_address &~ 3UL) - 1;。

2. 异步安全
在信号或Mach异常处理函数中,必须使用异步安全函数。因为崩溃时系统状态不稳定,堆可能已损坏,调用malloc、free、NSLog或Objective-C方法可能导致死锁或二次崩溃。
总结与展望
本文系统介绍了iOS异常监控的分层体系、基于KSCrash的方案选型,以及各层异常(Mach、信号、NSException、C++异常、死锁、僵尸对象)的捕获实现原理。监控能力仍在持续演进,未来可探索实时上传、崩溃回调、结合App日志、dump寄存器附近内存等优化方向。该方案已应用于阿里云用户体验监控RUM iOS SDK中。