背景介绍
App 上线后, 作为开发同学,最怕出现的情况就是应用崩溃了。但是,线下测试好好的 App,为什么上线后就发生崩溃了呢?这些崩溃日志信息是怎么采集的?
先看看几个常见的编写代码时的疏忽,是如何让应用崩溃的。
- 数组越界:在取数据索引时越界,App 会发生崩溃。
- 多线程问题:在子线程中进行 UI 更新可能会发生崩溃。多个线程进行数据的读取操作,因为处理时机不一致,比如有一个线程在置空数据的同时另一个线程在读取这个数据,可能会出现崩溃情况。
- 主线程无响应:如果主线程超过系统规定的时间无响应,就会被 Watchdog 杀掉。
- 野指针:指针指向一个已删除的对象访问内存区域时,会出现野指针崩溃。
为了解决这个问题,我们可以进行了一些 iOS 异常监控方向的探索。
iOS 异常体系介绍
iOS 异常体系采用分层架构,从底层硬件到上层应用,异常在不同层次被捕获和处理。理解异常体系的分层结构,有助于我们更好地设计和实现异常监控方案。iOS 异常体系主要分为以下几个层次:
1. 硬件层异常
- CPU 异常:由硬件直接产生的异常,如非法指令、内存访问错误等
- 这是最底层的异常来源,所有其他异常最终都源于此
2. 系统层异常
- Mach 异常:macOS/iOS 系统最底层的异常机制,源于 Mach 微内核架构
- Unix 信号:Mach 异常会被转换为 Unix 信号,如 SIGSEGV、SIGABRT 等
- 系统层异常是应用层异常监控的主要捕获点
3. 运行时层异常
- NSException:Objective-C 运行时异常,如数组越界、空指针等
- C++ 异常:C++ 代码抛出的异常,通过 std::terminate() 处理
- 运行时层异常通常由编程错误引起
4. 应用层异常
- 业务逻辑异常:应用自定义的异常和错误
- 性能异常:主线程死锁、内存泄漏等
- 僵尸对象访问:访问已释放对象导致的异常
异常体系的分层关系如下图所示:
flowchart TD
%% 方向:自上而下
%% ================= L0:硬件 / CPU 层 =================
subgraph L0[硬件 / CPU 层]
A[硬件层异常
CPU故障 / 总线错误]
end
%% ================= L1:内核 / 系统异常机制层 =================
%% 注意:C定义在D之前,有助于Mermaid将C放在左侧
subgraph L1[内核 / 系统异常机制层]
direction LR
C[Mach 异常
EXC_BAD_ACCESS 等]
D[Unix 信号
SIGSEGV / SIGABRT / SIGBUS]
end
%% ================= L2:语言运行时异常层 =================
subgraph L2[语言运行时异常层]
E[运行时异常抽象层
Bad Access / 栈溢出等]
F[Objective-C 运行时异常
NSException]
G[C++ 异常终止
std::terminate]
end
%% ================= L3:应用层崩溃 / 错误处理层 =================
subgraph L3[应用层崩溃 / 错误处理层]
H[应用层异常
未捕获异常 / 崩溃点]
end
%% ================= L4:业务与质量问题层 =================
subgraph L4[业务与质量问题层]
I[业务逻辑异常
状态机错误 / 断言失败]
J[性能异常
卡顿 / ANR / 内存泄漏]
K[非法对象访问
僵尸对象 / 野指针]
end
%% ================= 流向关系 =================
%% Mach异常可以转换为Unix信号(在系统层内部转换)
C -->|转换| D
%% 硬件层到系统层:先定义主要路径,确保C在左侧
A --> C
A --> D
%% 系统层到运行时层:确保路径清晰
C --> E
D --> E
%% 运行时层内部流转
E --> F
E --> G
%% 运行时异常到应用层
F --> H
G --> H
%% 应用层异常到业务层
H --> I
H --> J
H --> K
%% ================= 背景层(黄色大框) =================
style L0 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style L1 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style L2 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style L3 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style L4 fill:#FFF8DC,stroke:#E0D9B5,color:#333
%% ================= 节点配色:更专业的调色板 =================
%% 硬件层:深红(Fatal / 不可控)
style A fill:#B53A3A,stroke:#8B2525,color:#ffffff
%% 内核层:深蓝(内核 / OS)
style C fill:#2E6CA8,stroke:#234F7A,color:#ffffff
style D fill:#2E6CA8,stroke:#234F7A,color:#ffffff
%% 运行时层:深紫(VM / Runtime)
style E fill:#6A4CA3,stroke:#4E387A,color:#ffffff
style F fill:#6A4CA3,stroke:#4E387A,color:#ffffff
style G fill:#6A4CA3,stroke:#4E387A,color:#ffffff
%% 应用层:墨绿(代码执行)
style H fill:#2F7A54,stroke:#21553A,color:#ffffff
%% 业务与质量层:蓝绿(SLO / 质量指标)
style I fill:#1D8F87,stroke:#166860,color:#ffffff
style J fill:#1D8F87,stroke:#166860,color:#ffffff
style K fill:#1D8F87,stroke:#166860,color:#ffffff
异常捕获的层次关系:
- 硬件异常 → Mach 异常:CPU 异常被 Mach 内核捕获,转换为 Mach 异常消息
- Mach 异常 → Unix 信号:Mach 异常处理机制会将异常转换为对应的 Unix 信号
- 运行时异常:NSException 和 C++ 异常在运行时层被捕获,如果未处理会触发系统层异常
- 应用层异常:业务异常和性能问题需要应用层主动监控和检测
异常监控策略:
- 系统层监控:通过 Mach 异常和 Unix 信号捕获,可以捕获所有底层异常
- 运行时层监控:通过设置异常处理器(NSUncaughtExceptionHandler、terminate handler)捕获运行时异常
- 应用层监控:通过主动检测机制(死锁检测、僵尸对象检测)发现潜在问题
理解这个分层体系,有助于我们:
- 选择合适的异常捕获机制
- 理解不同异常类型的来源和处理方式
- 设计完整的异常监控方案
主流异常监控方案
在 iOS 端侧异常监控领域,PLCrashReporter 与 KSCrash 是最常用的两个内核库。两者都是开源、生产可用,且被多家平台化产品或 SDK 采用作为底层能力。
| 特性 | PLCrashReporter | KSCrash |
|---|---|---|
| 开源协议 | Apache 2.0 | MIT |
| Mach 异常 | ✅ | ✅ |
| Unix 信号捕获 | ✅ | ✅ |
| NSException | ✅ | ✅ |
| C++ 异常 | ❌ | ✅ |
| 死锁检测 | ❌ | ✅ |
| 僵尸对象检测 | ❌ | ✅ |
| 内存内省 | ❌ | ✅ |
| 自定义扩展日志 | ❌ | ✅ |
| 报告格式 | Apple 格式 | JSON |
| 符号化 | 手动 | 运行时/手动 |
基于以上对比分析,KSCrash相比其他崩溃监控框架的核心优势在于:
- 异常类型监测支持更全面(唯一同时支持C++异常、死锁检测、僵尸对象检测的开源框架)
- 异步安全设计(崩溃处理完全异步安全,双重异常处理线程确保可靠性)
- 技术优势明显(堆栈游标抽象、内存内省、模块化架构等)
基于以上优势,我们选择基于 KSCrash 作为崩溃异常监控的核心方案。
异常监控方案实现
架构设计
异常采集模块,是我们 SDK 数据采集层一个模块的具体实现,如下:
@startuml
skinparam backgroundColor #FFFFFF
skinparam componentStyle rectangle
skinparam defaultFontName "PingFang SC, Microsoft YaHei, Arial"
skinparam defaultFontSize 11
skinparam linetype ortho
package "监控器管理层" #FFF8DC {
component [监控器管理器\n统一管理所有监控器\n提供统一异常处理入口] as Manager
}
package "异常捕获层" #FFF8DC {
component [Mach异常监控器] as MachMonitor
component [Unix信号监控器] as SignalMonitor
component [NSException监控器] as NSExceptionMonitor
component [C++异常监控器] as CppMonitor
component [死锁检测监控器] as DeadlockMonitor
component [僵尸对象检测监控器] as ZombieMonitor
}
package "异常处理层" #FFF8DC {
component [崩溃上下文构建器] as ContextBuilder
component [堆栈收集器] as StackCollector
component [符号收集器] as SymbolCollector
component [内存信息收集器] as MemoryCollector
}
package "报告生成层" #FFF8DC {
component [JSON报告生成器] as ReportGenerator
}
' 监控器管理层到异常捕获层
Manager --> MachMonitor
Manager --> SignalMonitor
Manager --> NSExceptionMonitor
Manager --> CppMonitor
Manager --> DeadlockMonitor
Manager --> ZombieMonitor
' 异常捕获层到异常处理层
MachMonitor --> ContextBuilder
SignalMonitor --> ContextBuilder
NSExceptionMonitor --> ContextBuilder
CppMonitor --> ContextBuilder
DeadlockMonitor --> ContextBuilder
ZombieMonitor --> ContextBuilder
ContextBuilder --> StackCollector
ContextBuilder --> SymbolCollector
ContextBuilder --> MemoryCollector
' 异常处理层到报告生成层
StackCollector --> ReportGenerator
SymbolCollector --> ReportGenerator
MemoryCollector --> ReportGenerator
ContextBuilder --> ReportGenerator
' 样式定义
skinparam package {
BackgroundColor #FFF8DC
BorderColor #E0D9B5
FontStyle bold
FontSize 12
}
skinparam component {
BackgroundColor #2E6CA8
BorderColor #234F7A
FontColor #ffffff
ArrowColor #2E6CA8
}
@enduml
- 监控器管理层:统一管理所有监控器,提供统一的异常处理入口
- 异常捕获层:多种监控器,分别捕获不同类型的异常和状态信息
- 异常处理层:构建崩溃上下文,收集堆栈、符号、内存等信息
- 报告生成层:将崩溃上下文转换为JSON格式报告
接下来,我们介绍各种类型异常的捕获原理,以及对应监控器是如何实现的。
系统层异常捕获
系统层异常包括 Mach 异常和 Unix 信号,是应用层异常监控的主要捕获点。我们需要同时捕获这两种异常,确保不遗漏任何底层异常。
Mach 异常捕获
Mach 异常是 macOS/iOS 系统最底层的异常机制,源于 Mach 微内核架构。Mach 是 macOS/iOS 内核的基础,提供了进程间通信(IPC)和异常处理的核心机制。硬件异常(CPU 异常)会被 Mach 内核捕获并转换为 Mach 异常消息。Mach 异常与特定线程关联,可以精确捕获异常发生的线程。Mach 异常通过 Mach 消息异步传递异常信息,需要使用 Mach 端口(Mach Port)作为异常处理的通信通道。
sequenceDiagram
participant 应用层 as 应用层
participant 内核 as Mach内核
participant 端口 as 异常端口
participant 线程 as 异常处理线程
Note over 应用层: 初始化阶段
应用层->>端口: 1. 创建异常端口
应用层->>内核: 2. 注册异常处理器
应用层->>线程: 3. 创建异常处理线程
线程->>端口: 4. 监听异常消息
Note over 内核,线程: 异常捕获阶段
内核->>内核: 5. 捕获硬件异常
内核->>内核: 6. 转换为Mach异常消息
内核->>端口: 7. 发送异常消息
端口->>线程: 8. 传递异常消息
线程->>线程: 9. 处理异常
监控 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);
为了与三方 SDK 兼容,在创建新的异常处理端口之前,需要对旧的异常处理端口进行保存,并在异常处理完毕后恢复旧的异常端口。
- 注册异常处理器
把异常处理端口设置为刚才创建的:
// 设置异常端口,捕获所有异常类型
task_set_exception_ports(
mach_task_self(),
EXC_MASK_ALL,
g_exceptionPort,
EXCEPTION_DEFAULT,
MACHINE_THREAD_STATE
);
- 创建异常处理线程
为了防止异常处理线程本身崩溃,需要创建两个独立的异常处理线程:
- 主处理线程:正常处理异常
- 备用处理线程:主处理线程崩溃时的后备份方案
// 主异常处理线程
pthread_create(&g_primaryPThread, &attr, handleExceptions, kThreadPrimary);
// 备用异常处理线程(防止主线程崩溃)
pthread_create(&g_secondaryPThread, &attr, handleExceptions, kThreadSecondary);
主备线程之间的关系如下:
sequenceDiagram
participant 应用层 as 应用层
participant 主线程 as 主处理线程
participant 备用线程 as 备用处理线程
participant 异常端口 as 异常端口
应用层->>主线程: 创建主处理线程
应用层->>备用线程: 创建备用处理线程(挂起)
异常端口->>主线程: 异常消息到达
主线程->>备用线程: 恢复备用线程
主线程->>主线程: 处理异常并收集上下文
- 备用处理线程在创建后会立即挂起
- 主线程在处理异常之前会通过thread_resume()函数恢复备用处理线程
- 备用处理线程恢复后,会进入 mach_msg() 等待
- 如果主线程在处理异常时发生崩溃,备用处理线程可以继续处理崩溃信息(由于异常端口已恢复,此时备用线程可能也收不到消息)。
- 处理异常消息
异常处理线程通过 mach_msg() 接收异常消息:
mach_msg_return_t kr = mach_msg(
&exceptionMessage.header,
MACH_RCV_MSG | MACH_RCV_LARGE,
0,
sizeof(exceptionMessage),
g_exceptionPort,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL
);
flowchart TD
subgraph Phase1["系统层:异常接收与线程管理"]
A[接收异常消息]
B[挂起所有线程
确保状态一致性]
end
subgraph Phase2["运行时层:状态收集与上下文构建"]
C[读取异常线程的机器状态]
D[构建异常上下文
异常类型/机器状态/地址信息/堆栈游标]
end
subgraph Phase3["应用层:统一处理与恢复"]
E[统一异常处理
不同异常类型统一处理]
F[恢复线程]
end
A --> B
B --> C
C --> D
D --> E
E --> F
%% 系统层:深蓝(内核 / OS)
style Phase1 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style A fill:#2E6CA8,stroke:#234F7A,color:#ffffff
style B fill:#2E6CA8,stroke:#234F7A,color:#ffffff
%% 运行时层:深紫(VM / Runtime)
style Phase2 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style C fill:#6A4CA3,stroke:#4E387A,color:#ffffff
style D fill:#6A4CA3,stroke:#4E387A,color:#ffffff
%% 应用层:墨绿(代码执行)
style Phase3 fill:#FFF8DC,stroke:#E0D9B5,color:#333
style E fill:#2F7A54,stroke:#21553A,color:#ffffff
style F fill:#2F7A54,stroke:#21553A,color:#ffffff
- 挂起所有线程:确保状态一致性
- 标记已捕获异常:进入异步安全模式
- 激活备用处理线程
- 读取异常线程的机器状态
- 初始化堆栈游标
- 构建异常上下文
- 异常类型
- 机器状态
- 地址信息等
- 堆栈游标
- 统一异常处理:不同异常类型统一处理
- 恢复线程
Unix 信号捕获
作为 Mach 异常捕获的补充,也需要直接捕获 Unix 信号,确保在 Mach 异常处理失败时,仍能捕获到崩溃。Unix 信号的捕获处理涉及:
sequenceDiagram
participant 应用层 as 应用层
participant 系统 as 系统内核
participant 信号处理器 as 信号处理器
Note over 应用层: 初始化阶段
应用层->>系统: 安装信号处理器
系统->>系统: 注册信号处理器
Note over 系统,信号处理器: 信号捕获阶段
alt 来自Mach异常
系统->>系统: Mach异常未处理
转换为Unix信号
else 直接产生
系统->>系统: 调用abort()
或运行时异常产生信号
end
系统->>信号处理器: 发送信号
SIGSEGV/SIGABRT等
信号处理器->>信号处理器: 收集异常信息
sig_num, signal_info, user_context
信号处理器->>应用层: 触发异常处理流程
为了能够通过Unix信号捕获到异常,需要先安装信号处理器:
// 获取信号列表
const int* fatal_signals = signal_fatal_signals();
// 配置信号动作
struct sigaction action = {{0}};
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
action.sa_sigaction = &signal_handle_signals;
// 安装信号处理器
sigaction(fatal_signal, &action, &previous_signal_handler);
Unix 信号的产生主要有以下情况:
- 来自 Mach 异常:如果 Mach 异常未被应用层处理,系统会将其转换为对应的 Unix 信号
- 直接产生:如调用 abort() 直接产生 SIGABRT,或 NSException/C++ 异常未捕获时产生的信号
当信号产生后,系统会找到我们安装的信号处理器,并调用我们注册的信号处理函数:
void signal_handle_signals(int sig_num, siginfo_t *signal_info, void* user_context)
{
// sig_num: 信号编码,如 SIGSEGV=11
// signal_info: 信号详细信息
// - si_signo: 信号编码
// - si_code: 信号代码,如 SEGV_MAPERR
// - si_addr: 异常地址
// user_context: CPU 寄存器状态
}
后续对异常的处理,同 Mach 异常处理流程。
注意:并非所有异常都源于 Mach 异常。例如,NSException 未捕获时通常会调用 abort() 产生 SIGABRT 信号,这个过程不经过 Mach 异常。因此,异常监控需要同时捕获 Mach 异常、Unix 信号和运行时异常处理器。
机器上下文堆栈
在崩溃发生时,堆栈追踪可以帮助开发者定位问题发生的代码位置。在基于 Mach 或 Unix 信号捕获的场景,需要从CPU寄存器和堆栈内存中恢复完整的调用栈。核心原理:每个函数调用,都会在堆栈上创建一个堆栈帧,包含:
- 返回地址:函数返回后继续执行的地址
- 帧指针(FP):指向当前堆栈帧的指针
- 局部变量:函数的局部变量
- 参数:传递给函数的参数
以ARM64架构为例,堆栈布局如下:

为了还原崩溃发生时的调用栈,我们需要对堆栈帧进行遍历。堆栈帧遍历的核心原理是通过帧指针链向上遍历:
- 第1帧:从 PC 寄存器获取当前崩溃点
- 第2帧:从 LR 寄存器获取调用者
- 第3帧及以后:通过帧指针链从堆栈内存中读取
堆栈帧遍历的完整流程如下:
sequenceDiagram
participant 遍历器 as 堆栈遍历器
participant 寄存器 as CPU寄存器
participant 堆栈 as 堆栈内存
遍历器->>寄存器: 读取PC寄存器
寄存器-->>遍历器: 第1帧地址
遍历器->>寄存器: 读取LR寄存器
寄存器-->>遍历器: 第2帧地址
loop 遍历后续帧
遍历器->>堆栈: 通过FP读取上一帧信息
堆栈-->>遍历器: 返回地址和上一帧FP
alt FP有效
遍历器->>遍历器: 记录地址,继续遍历
else FP无效
遍历器->>遍历器: 结束遍历
end
end
Note over 遍历器: 返回完整堆栈地址数组
在堆栈遍历过程中,有下面几个关键点需要注意:
- 在遍历堆栈时,必须安全地访问内存,防止访问无效内存导致崩溃
- 堆栈溢出检测,防止在堆栈损坏时无限遍历
- 地址规范化,不同CPU架构的地址可能有特殊标记,需要规范化处理
运行时异常捕获
运行时异常包括 NSException 和 C++ 异常,通常由编程错误引起。我们需要通过设置异常处理器来捕获这些未处理的异常。
NSException 异常捕获
iOS 需要通过设置NSUncaughtExceptionHandler来捕获未捕获的NSException。
// 在设置exception handler之前,先保存之前的设置
NSUncaughtExceptionHandler *previous_uncaught_exceptionhandler = NSGetUncaughtExceptionHandler();
// 设置我们的exception handler
NSSetUncaughtExceptionHandler(&handle_uncaught_exception);
当Objective-C代码抛出异常,且未被@catch块捕获时,Objective-C 运行时会调用我们设置的异常处理器。在处理完NSException异常后,还需要主动调用previous_uncaught_exceptionhandler,以便其他异常处理器能够正确处理异常。
注意:在异常监控场景中,通常需要在 handler 中收集完崩溃信息后,主动调用 abort() 来终止程序,确保程序不会在异常状态下继续运行。
在捕获到 NSException 异常之后,一般通过以下方式获取 Objective-C 的调用栈信息。
// NSException 提供了 callStackReturnAddresses
NSArray* addresses = [exception callStackReturnAddresses];
通过 [NSException callStackReturnAddresses] 获取到return address 之后,还需要进一步处理,如:过滤掉无效地址等。
C++ 异常捕获
通过设置 C++ terminate handler 可以捕获未处理的C异常。当 C异常未被捕获时,C++运行时会调用std::terminate(),我们通过拦截这个调用来捕获异常。
// 保存原始 terminate handler
std::terminate_handler original_terminate_handler = std::get_terminate();
// 设置我们的 terminate handler
std::set_terminate(cpp_exception_terminate_handler);
当C代码抛出异常时,throw 语句会调用 __cxa_throw(),C运行时会查找匹配的catch块,如果未找到异常会继续向上传播。当异常未被捕获时:
- C++ 运行时会调用std::terminate()
- std::terminate()会调用已注册的 terminate handler
- 我们设置的cpp_exception_terminate_handler会被调用
sequenceDiagram
participant 应用层 as 应用层
participant CPP代码 as C++代码
participant 运行时 as C++运行时
participant 处理器 as terminate handler
应用层->>运行时: 设置terminate handler
CPP代码->>运行时: throw异常
运行时->>运行时: 未找到catch块
运行时->>处理器: 调用terminate handler
处理器->>处理器: 收集异常信息
处理器->>应用层: 调用原始terminate handler
在我们的 terminate handler 中处理完异常后,还需要调用原始的terminate handler,以便其他异常处理器能正确处理异常。
应用层异常捕获
应用层异常包括业务逻辑异常和性能问题,需要应用层主动监控和检测。主要包括主线程死锁检测和僵尸对象检测。
主线程死锁检测
主线程死锁(Deadlock)是 iOS 开发中一种严重的运行时问题,会导致 App 界面完全卡死(无响应),最终通常会被系统的看门狗(Watchdog)强制终止。
针对这类问题,一种可行的方式是通过"看门狗"机制检测主线程死锁:

- 监控线程:独立的监控线程,定期检查主线程状态
- 心跳机制:向主线程发送"心跳"任务,检查是否及时响应
- 死锁判定:如果主队列在指定时间内未响应,则判定为死锁
需要注意:
- 误报风险:如果主线程有长时间运行的任务,可能产生误报
- 超时时间:需要根据应用实际情况,调整超时时间,避免误报
僵尸对象检测
iOS 僵尸对象 (Zombie Object) 是 iOS 开发中导致应用崩溃(Crash)最常见的内存问题之一。僵尸对象是指已经被释放(dealloc)的内存块,但对应的指针仍然指向这块内存,并且代码试图通过这个指针去访问它(发送消息)。访问僵尸对象可能会导致崩溃,通常表现为EXC_BAD_ACCESS崩溃。
- 这是一个内存访问错误,意味着你试图访问一块你无法访问或无效的内存。
- 因为这块内存可能已经被系统回收并分配给了其他对象,或者变成了一块杂乱的数据区域,所以访问结果是不可预知的。
产生僵尸对象的原因主要有以下几点:
- unsafe_unretained 或 assign 指针:如果一个属性被修饰为 assign(修饰对象时)或 unsafe_unretained,当对象被释放后,指针不会自动置为 nil(变成悬垂指针)。此时再次访问就会变成僵尸对象访问
- 多线程竞争:线程 A 刚刚释放了对象,但线程 B 几乎同时在尝试访问该对象
- CoreFoundation 与 ARC 的桥接不当:在使用__bridge,__bridge_transfer等转换时,所有权管理混乱导致对象过早释放
- Block 或 Delegate 循环引用:某些老旧代码中 Delegate 依然使用 assign 修饰
僵尸对象检测的主要思路是:
- hook NSObject 和 NSProxy 的 dealloc 方法
- 在对象释放时,计算对象的 hash,然后记录 class 信息
- 检测是否为NSException,如果是,则保存异常详情
- 各类异常发生时,读取保存的异常详情
flowchart TD
A[Hook dealloc方法] --> B[对象释放时触发]
B --> C[计算对象hash并记录class信息]
C --> D[异常记录表]
E[异常发生时] --> F[计算地址hash]
F --> G[查找异常记录表]
G --> H[获取class信息或异常详情]
H --> I[记录到崩溃报告]
D -.共享.-> G
style A fill:#3498DB,stroke:#2980B9,color:#fff
style B fill:#3498DB,stroke:#2980B9,color:#fff
style C fill:#2ECC71,stroke:#27AE60,color:#fff
style D fill:#3498DB,stroke:#2980B9,color:#fff
style E fill:#9B59B6,stroke:#8E44AD,color:#fff
style F fill:#9B59B6,stroke:#8E44AD,color:#fff
style G fill:#9B59B6,stroke:#8E44AD,color:#fff
style H fill:#2ECC71,stroke:#27AE60,color:#fff
style I fill:#3498DB,stroke:#2980B9,color:#fff
- 为了降低 CPU 和内存占用,僵尸对象的记录上限是 0x8000个,即:32768
- 计算哈希时,通过((uintptr_t)object >> (sizeof(uintptr_t) - 1)) & 0x7FFF计算
这是一种设计权衡的结果。因为这种检测方式并不是非常准确,不能捕获所有僵尸对象。因为 hash 的计算会产生一定的碰撞,导致对象被覆盖,可能会产生误报或错误的类型。
运行时符号化
在异常监控系统中,除了需要检测和记录异常类型(如僵尸对象访问、主线程死锁等),还需要处理异常发生时的堆栈信息。堆栈信息通常以内存地址的形式存在,这些地址对于开发者来说是不可读的。为了能够快速定位问题,我们需要将这些内存地址转换为可读的函数名、文件名和行号信息,这个过程就是符号化(Symbolication)。
符号化一般分为两种:
- 运行时符号化:使用 dladdr() 获取符号信息(函数名、镜像名等)
- 完整符号化:使用dSYM文件获取文件名和行号
运行时符号化只能获取公开符号。
我们主要讨论iOS平台上如何在运行时符号化。iOS平台主要通过 dladdr()进行运行时符号化,通过dladdr()可以获取到如下信息:
- imageAddress:image镜像基址
- imageName:image镜像路径
- symbolAddress:符号地址
- symbolName:符号名称
由于在符号化时,我们需要的是调用指令的地址,但堆栈上存储的是返回地址,因此需要对地址调整:
函数调用过程:
- 调用指令:call function_name (地址: 0x1000)
- 函数执行:function_name() (地址: 0x2000)
- 返回地址:0x1001 (存储在堆栈上)
堆栈上存储的是返回地址(0x1001),
但我们需要的是调用指令的地址(0x1000),所以需要减 1。
不同CPU架构对应的地址调整有所不同,以ARM64为例:
uintptr_t address = (return_address &~ 3UL) - 1;
运行时符号化的完整流程如下图所示:
flowchart TD
A[地址调整] --> B[调用dladdr解析]
B --> C{dladdr成功?}
C -->|是| D[计算偏移量并返回符号化结果]
C -->|否| E[返回失败]
style A fill:#6A4CA3,stroke:#4E387A,color:#ffffff
style B fill:#6A4CA3,stroke:#4E387A,color:#ffffff
style D fill:#2ECC71,stroke:#27AE60,color:#ffffff
style E fill:#E74C3C,stroke:#C0392B,color:#ffffff
style C fill:#FFF8DC,stroke:#E0D9B5,color:#333
异步安全
除了以上内容外,在处理iOS平台异常捕获时,我们还需要关注异步安全。
在Unix信号处理函数,或 Mach 异常处理中,只能使用异步安全函数,主要是因为:
- 崩溃时系统状态不稳定
- 可能持有锁,调用非异步安全函数可能导致死锁
- 堆可能已损坏,此时分配内存可能会失败
一般情况下,malloc()、free()、NSLog()、printf(),Objective-C方法的调用,任何可能分配内存的函数都不允许在处理异常过程中调用。
结语和展望
本文主要介绍了当下主流的 iOS 异常监控方案,和基于 KSCrash 的异常监控实现细节,包括Mach、Unix信号、NSException等异常类型的捕获的处理等。异常监控能力还在持续进化,后续还有不少可以优化和提升的点,如支持实时上传和崩溃回调,支持App日志记录,dump寄存器地址附近内存等。目前这套方案已经应用在阿里云用户体验监控 RUM iOS SDK中,您可以参考接入文档体验使用。阿里云 RUM SDK 当前也支持 Android 、 HarmonyOS 、Web 等平台下异常监控能力。相关问题可以加入“RUM用户体验监控支持群”(钉钉群号:67370002064)进行咨询。