第3章:iOS崩溃日志采集:Mach异常与信号机制、崩溃捕获实现、符号化配置
3.1 iOS崩溃的两层机制:Mach异常与Unix信号
iOS / macOS 的底层内核是 XNU(X is Not Unix),它采取了双层次异常架构:Mach异常 位于内核态,Unix信号 位于用户态。崩溃发生在 App 进程中时,首先触发的是 Mach 层异常,如果 Mach 层未被处理,内核会将其转换为对应的 Unix 信号抛向用户进程。
| 层级 | 机制名称 | 触发场景 | 捕获优势 |
|---|---|---|---|
| 内核态 | Mach 异常 | EXC_BAD_ACCESS / EXC_BAD_INSTRUCTION / EXC_ARITHMETIC | 最早捕获点,可获取更完整的线程栈 |
| 用户态 | Unix 信号 | SIGSEGV / SIGABRT / SIGBUS / SIGTRAP | 兼容 POSIX 标准,被第三方库广泛使用 |
设计原则: 崩溃采集 SDK 通常优先注册 Mach 异常端口,作为备份再注册 signal handler。Mach 异常处理失败时回退到信号处理,保证捕获率接近 100%。
3.2 崩溃捕获的实现流程
实现一个最小的崩溃捕获模块需要以下 5 个步骤:
- 创建 Mach 异常端口: 使用
mach_port_allocate申请一个接收异常的端口。 - 设置任务异常处理: 调用
task_set_exception_ports,将该端口绑定到当前任务(mach_task_self()),指定关注的异常掩码(EXC_MASK_ALL)。 - 启动异常处理线程: 在子线程中循环调用
mach_msg接收异常消息,提取线程状态、寄存器信息。 - 安装信号处理器: 使用
signal(SIGSEGV, handler)等方式注册传统信号处理函数,作为 Mach 异常未处理情况下的兜底。 - 保存崩溃上下文: 在 Handler 中将捕获到的
ucontext或thread_state序列化为 JSON,写入沙盒缓存文件,等待下次启动上报。
核心代码示例(C 函数,注册 Mach 异常端口):
// 创建异常端口
mach_port_t exception_port;
kern_return_t kr = mach_port_allocate(mach_task_self(),
MACH_PORT_RIGHT_RECEIVE,
&exception_port);
if (kr != KERN_SUCCESS) return kr;
// 将端口注册为当前任务的异常处理端口
kr = task_set_exception_ports(mach_task_self(),
EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION,
exception_port,
EXCEPTION_DEFAULT,
THREAD_STATE_NONE);
if (kr != KERN_SUCCESS) return kr;
// 在独立线程中等待异常消息
mach_msg_header_t *msg = ...;
kr = mach_msg(msg, MACH_RCV_MSG, 0, sizeof(msg),
exception_port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
// 获取发生异常的线程状态
thread_state_t state;
mach_msg_type_number_t state_count = ARM_THREAD_STATE64_COUNT;
thread_get_state(exception_thread, ARM_THREAD_STATE64,
(thread_state_t)&state, &state_count);
3.3 符号化配置:从空虚地址到可读符号
崩溃捕获模块收集到的是 空虚地址(Slide Address) 和 偏移量。要转换成人类可读的函数名 + 行号,必须依赖 dSYM 符号文件(DWARF with DSYM Bundle)。
符号化流程:
- 查找 dSYM: 根据 Bundle ID、UUID 匹配对应版本的 .dSYM 包。
- 计算基址: 从崩溃日志或捕获上下文获取 App 的
ASLR Slide(虚拟地址偏移)。 - 解析地址: 使用
atos工具或dwarfdump将地址解析出符号。
常见错误: dSYM 与 Arch 不匹配(例如 Release 模式未生成 dSYM 或上传了错误的 UUID)。符号化结果全部变成
<redacted> 或缺失行号,通常是 dSYM 缺失或配置路径错误。
3.4 符号化自动化与原生集成
推荐在 Xcode Build Phases 或 CI/CD 管道 中自动化上传 dSYM。下面是主流崩溃平台所需的配置步骤:
| 平台 / SDK | 脚本语言 | 关键 API 或命令 |
|---|---|---|
| Bugly(腾讯) | Python | buglySymboliOS.py -i <dSYM> -o <output> |
| Firebase Crashlytics | Bash + Run Script | ${PODS_ROOT}/FirebaseCrashlytics/upload-symbols + -gsp |
| Sentry(自建或 SaaS) | Python / Bash | sentry-cli upload-dif --include-sources *.dSYM |
| 自建符号化 | Swift / C | atos -arch arm64 -o App.dSYM/... -l slide_addr addr |
命令行示例:手动符号化单个崩溃地址
# 获取二进制的 load address(从崩溃日志或 dyld_info 获取)
load_addr=0x100000000
# 崩溃地址(来自捕获日志)
crash_addr=0x100123abc
# 计算真实偏移
offset=$((crash_addr - load_addr))
# 使用 atos 解析
atos -arch arm64 -o MyApp.dSYM/Contents/Resources/DWARF/MyApp \
-l 0x0 $offset
3.5 全局捕获流程与设计结构
下图展示了从 Mach 异常 / Unix 信号发生,到捕获模块、符号化、上报的完整链路:
3.6 常见错误与稳定性优化
- Mach 端口泄漏: 每次 crash 后未回收
mach_port_t,导致进程重启后端口耗尽。应复用单例端口,不要每崩溃一次就新建一个。 - 符号化失败: 超过 95% 的原因是没有在打包时保留或上传 dSYM。在 Xcode 中设置
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym,并确认 Build Phase 中调用了上传脚本。 - 信号处理器重入: 在 signal handler 中调用非 async-signal-safe 的函数(如 malloc、printf、ObjC 消息发送)会导致死锁。建议使用
write()直接写入预分配缓冲区。 - 性能优化: 不要在捕获处理线程中进行网络上报、文件解压等耗时操作。捕获线程应只做最轻量的数据保存,上报任务延迟到应用下次启动时执行。
调试提示: 手动触发崩溃测试捕获是否生效时,务必在真机上进行(模拟器行为与真机不完全一致),且需在 Release 模式下验证 dSYM 符号化是否正常。关闭 Xcode Debugger 以避免调试器抢先处理异常。
3.7 部署检查清单
| # | 检查项 | 操作 |
|---|---|---|
| 1 | dSYM 生成 | 确认 Build Settings → DEBUG_INFORMATION_FORMAT = dwarf-with-dsym |
| 2 | 异常端口注册 | 在 +load 或 UIApplicationMain 前完成注册 |
| 3 | 信号处理器 | 注册 SIGSEGV / SIGABRT / SIGBUS / SIGTRAP 四种信号 |
| 4 | 符号化脚本 CI | 将 upload-symbols 脚本加入 CI/CD 流程 |
| 5 | 沙盒写入权限 | 确认 crash 缓存路径在 NSCachesDirectory 下 |