1. 崩溃工程基础
崩溃(Crash)是软件运行时因非法操作或未捕获异常导致的非正常终止。理解崩溃类型、日志结构与平台差异,是设计采集、归因与修复流水线的第一道门槛。本章从技术实现角度拆解这三个基础模块,后续所有章节的落地方案都建立在此之上。
1.1 崩溃的核心分类
从底层触发机制和上层暴露形式,崩溃可分为三大类:
| 崩溃类别 | 典型原因 | 特征指标 | 常见平台 |
|---|---|---|---|
| 信号级(Signal / Fault) | SIGSEGV(空指针/越界)、SIGABRT(断言失败)、SIGFPE(除零) | unix 信号编号 + fault address + 指令寄存器 | Linux / Android / iOS / macOS |
| 异常级(Exception) | Java NullPointerException、C++ std::bad_alloc、Objective-C NSException | 异常类型 + message + throw stack | Android / iOS / Windows |
| 资源/状态级 | OOM(内存耗尽)、Watchdog 超时、UI 线程阻塞 ANR | 内存快照/时间戳/主线程调用栈 | Android / iOS / macOS |
工程判断口诀:信号级查指针与内存;异常级查业务逻辑与第三方库;资源级查泄漏与并发瓶颈。三类崩溃的采集与归因策略有显着差异,不可混用同一套解析流程。
1.2 崩溃日志的标准化结构
一份完整的崩溃日志通常包含以下六个域,缺一不可。采集端需要保证每个域的完整性,归因端依赖这些域进行符号化与上下文推理。
- Header:app 版本、build id、操作系统版本、设备型号、语言、启动时间。
- 异常信息:信号编号 / 异常类型 / 异常描述、触发线程 id、挂起线程列表。
- 调用栈(Stack Trace):线程栈帧列表(地址 + 符号名 + 源文件:行号),通常包含未符号化的 memory addresses。
- 寄存器快照:关键寄存器值(PC, LR, SP, FP, Exception Address),用于恢复执行现场。
- 应用状态:最近用户行为序列、当前 activity/controller 栈、网络状态、电量等。
- 二进制映射:加载的动态库列表及其加载基址,用于地址符号化回退。
实战日志示例(iOS Mach Exception → JSON 结构)
{
"header": {
"app_version": "2.3.1",
"os_version": "16.5",
"device": "iPhone14,2",
"build_id": "A12345"
},
"exception": {
"type": "EXC_BAD_ACCESS",
"code": "KERN_INVALID_ADDRESS",
"address": "0x0000000100abc000",
"thread_id": 5632
},
"stacktrace": [
{"frame": 0, "address": "0x1000e3f4c", "symbol": "-[UIView layoutSublayers] + 68"},
{"frame": 1, "address": "0x1000e2a10", "symbol": "CALayerLayout + 144"},
{"frame": 2, "address": "0x1a03b4e08", "symbol": "CA::Context::commit_transaction"}
],
"binary_images": [
{"name": "MyApp", "base": "0x100000000", "uuid": "ABCD-1234"},
{"name": "CoreFoundation", "base": "0x1a0200000", "uuid": "EFGH-5678"}
]
}
1.3 常见平台差异与采集策略
不同操作系统对崩溃的捕获机制、日志格式与符号化流程有本质差异。归因系统必须根据平台选择适配器(adapter)进行归一化处理。
| 维度 | Android (Java/Kotlin + Native) | iOS / macOS (Objective-C + Swift) | Linux (C/C++) |
|---|---|---|---|
| 异常捕获机制 | Thread.setDefaultUncaughtExceptionHandler + signal(SIGSEGV) 监听 | NSSetUncaughtExceptionHandler + mach_exception_self 端口 | signal() 或 sigaction() + SA_SIGINFO |
| 日志格式 | Java stack trace + logcat + tombstone(native) | Exception call stack + CrashReport plist / JSON | core dump + dmesg + backtrace() 输出 |
| 符号化方案 | ProGuard mapping + addr2line + llvm-symbolizer | dSYM Bundle + atos / lldb | DWARF debug info + addr2line / gdb |
| 稳定性保障 | Java 层 OOM 后信号处理可能不可用;需 fork 子进程采集 | Mach handler 在守护线程中运行,不阻塞主线程 | 信号处理函数必须可重入,禁止使用 malloc/printf |
常见工程陷阱:
- iOS 上使用 NSUncaughtExceptionHandler 只能捕获 Objective-C 异常,无法捕获 Mach 信号(如 EXC_BAD_ACCESS),必须同时注册 Mach exception 端口;
- Android Native 崩溃通过 debuggerd 生成 tombstone 文件,应用进程不能再写文件,建议使用 pipe 或 socket 传递数据;
- Linux 上信号处理函数中调用非 async-signal-safe 函数(如 free、log)会导致死锁,日志采集应使用 write() 直接写入预分配的 ring buffer。
- iOS 上使用 NSUncaughtExceptionHandler 只能捕获 Objective-C 异常,无法捕获 Mach 信号(如 EXC_BAD_ACCESS),必须同时注册 Mach exception 端口;
- Android Native 崩溃通过 debuggerd 生成 tombstone 文件,应用进程不能再写文件,建议使用 pipe 或 socket 传递数据;
- Linux 上信号处理函数中调用非 async-signal-safe 函数(如 free、log)会导致死锁,日志采集应使用 write() 直接写入预分配的 ring buffer。
1.4 崩溃工程整体流程
下图展示了从崩溃触发到灰度验证的完整链路。本章聚焦前两个阶段(采集与归一化),后续章节分别拆解每个子步骤的实现细节。
1.5 日志采集的最小可行实现策略
结合平台差异,设计采集模块时应遵循以下原则:
- 多级挂载:同时注册 UncaughtExceptionHandler + 信号监听,防止一种方式失效后无日志可用。
- 局部隔离:采集进程与业务进程分离(fork 子进程写日志),避免业务状态污染日志。
- 自愈与降级:若日志文件写入失败(磁盘满/权限问题),退而使用 mmap 环形缓冲区,或通过网络 socket 直传。
- 隐私剪裁:在采集端预先过滤路径、用户 ID、 等敏感信息,避免日志泄露隐私。
实验代码片段:
// 混合注册示例(精简 C 代码)
void register_crash_handlers() {
// 1. 注册信号处理
struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = crash_signal_handler;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, NULL);
sigaction(SIGABRT, &sa, NULL);
// 2. 线程挂起监听(iOS Mach)
mach_port_t self = mach_task_self();
exception_mask_t mask = EXC_MASK_BAD_ACCESS | EXC_MASK_CRASH;
mach_port_t exception_port;
mach_port_allocate(self, MACH_PORT_RIGHT_RECEIVE, &exception_port);
task_set_exception_ports(self, mask, exception_port,
EXCEPTION_DEFAULT, THREAD_STATE_NONE);
}