APM_KSCrash 原理

前言

KSCrash 是一个开源的崩溃捕获框架,我们写的 APM 的崩溃检测模块,底层就是 KSCrash,这篇文章就来剖析一下 KSCrash 的原理。

崩溃采集,主要就是关注:

  • 会有哪些崩溃,哪些是可以采集的?
  • 如何采集,需要采集哪些东西?
  • 如何记录?
  • 以及,如何解读报告

采集完成后,报告怎么符号化,上报,最终能够帮助定位到问题,这是后话,这里就先不展开。

如何解读报告也是需要知道的,在说完怎么记录之后,会列出各种崩溃的真实报告,说明如何去解读一份报告。

下面先一个个来看。

采集了哪些崩溃

KSCrash 主要采集的崩溃有 4 类:

  • Mach 异常:非法内存访问、非法指令、算术异常、断点/ Trap 等
  • 致命 Signal:如 SIGSEGV、SIGBUS、SIGABRT、SIGILL、SIGTRAP
  • 未捕获的 Objective-C 异常:NSException
  • 未捕获的 C++ 异常:未捕获异常及进入 std::terminate 的情况

Mach 异常

KSCrash 采集 Mach 异常的核心是:提前注册异常端口,由专门的线程接收内核发来的异常消息,读取故障线程的现场,保存报告,然后回复内核。

这里要先理解 Mach 异常是怎么产生的,比如线程执行一条读取内存的指令,但目标地址无法访问:

  1. 当业务线程内存访问触发硬件故障
  2. 内核会尝试处理内存故障,如果无法解决时会进入 Mach 异常分发
  3. 向已注册的异常端口发送消息
  4. KSCrash 会提前注册好异常端口
  5. KSCrash 收到消息后,会读取线程状态,暂停其他线程,并回溯调用栈,写入报告
  6. 处理完成后恢复原异常端口并回复内核,内核会根据回复继续执行或继续异常处理

KSCrash 会在初始化的时候,把自己的端口注册进去:

  1. 用 task_get_exception_ports() 保存原来的异常端口配置
  2. 用 mach_port_allocate() 创建端口,取得接收权
  3. 用 mach_port_insert_right() 建立发送权
  4. 用 task_set_exception_ports() 注册到当前 task

核心调用是:

swift 复制代码
task_set_exception_ports(
    taskSelf,
    kInterestingExceptions,
    ctx->exceptionPort,
    EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
    THREAD_STATE_NONE
);

这里的 kInterestingExceptions 有五种:

  • EXC_BAD_ACCESS:内存访问异常
  • EXC_BAD_INSTRUCTION:指令异常
  • EXC_ARITHMETIC:算术异常
  • EXC_SOFTWARE:软件异常
  • EXC_BREAKPOINT:断点异常

KSCrash 创建了一个专门的独立线程(pthread):

mach_msg(..., MACH_RCV_MSG | MACH_RCV_LARGE, ...);

平时阻塞在这里,收到异常消息后才继续处理,也就是说:故障发生在线程 A,采集工作由线程 B 执行。

消息主要包含:

  • 哪个 thread、哪个 task 发生了异常
  • 异常类型
  • code、subcode 等具体原因信息

收到消息后,KSCrash 再把它扩展成完整的崩溃现场。

handleException() 的主要工作是:

  1. 通知统一监控层
  2. 暂停其他线程,尽量稳定现场
  3. 读取故障线程的寄存器
  4. 从寄存器和内存中回溯调用栈
  5. 填写异常类型、错误码、故障地址等信息
  6. 进入统一报告写入流程

寄存器 和回溯调用栈 的原理,可以看看这两篇文章。

KSCrash 处理完之后,需要让流程继续,也就是恢复端口,然后回复内核。

KSCrash 会先恢复之前保存的 task 异常端口,然后:

  • 如果原来的端口配置存在该异常类型的有效端口,KSCrash 的回复是 KERN_SUCCESS,让故障线程恢复执行,使故障再次触发,进入原处理器
  • 如果没有匹配的有效端口,回复是 KERN_FAILURE,告诉内核本层没有处理异常,继续后续异常处理

要注意的是 KERN_SUCCESS 不代表 KSCrash 修复了程序,而是利用再次触发故障的方式来衔接之前的处理器,并不是直接转发收到的异常消息。

致命 Signal

也是提前加了一个 Signal 的处理函数,就是接收到 Signal 信号的时候,采集现场,然后写报告,处理完了,让之前的 Signal 处理流程继续。这里 KSCrash 采用的方式是处理完了之后再发一次相同的 Signal 信号出去。

会采集下面这些 Signal:

信号 含义
SIGABRT 主动中止,例如调用 abort()
SIGBUS 总线或内存访问错误
SIGFPE 算术异常
SIGILL 非法指令
SIGPIPE 向没有读端的管道写入
SIGSEGV 非法内存访问
SIGSYS 非法系统调用
SIGTRAP 断点、跟踪陷阱
SIGTERM 终止请求,KSCrash 对它有特殊处理
  • SIGTERM 默认不写崩溃报告,代码将它标记为 isCleanExit = true,并关闭全线程采集和报告写入。

同样的,KSCrash 初始化的时候,通过 sigaction() 注册处理函数。

核心配置是:

c 复制代码
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
action.sa_sigaction = &handleSignal;

另外,还会在安装时调用 sigaltstack() 配置调用栈。

怎么理解呢:

c 复制代码
action.sa_sigaction = &handleSignal:收到这个信号时,执行 handleSignal
SA_SIGINFO:调用时,把信号详情和原来的执行现场一起传给它
sigaltstack(...):为当前调用线程登记一块备用栈
SA_ONSTACK:处理这个信号时,如果接收线程配置了备用栈,就使用它
配置或调用 含义
action.sa_sigaction = &handleSignal 收到这个信号时,执行 handleSignal
SA_SIGINFO 调用时,把信号详情和原来的执行现场一起传给它
sigaltstack(...) 为当前调用线程登记一块备用栈
SA_ONSTACK 处理这个信号时,如果接收线程配置了备用栈,就使用它

备用栈可以理解为:专门留给信号处理函数使用的一块内存。

线程平时执行代码使用自己的栈。信号处理函数也需要栈空间,系统可以让它继续使用原来的栈,也可以切换到提前登记的备用栈。执行处理函数的仍然是接收信号的那个线程,只是使用的栈换了。

这是为了防止如果原来的栈已经耗尽了,毕竟都崩溃了,备用栈可以给异常处理代码提供另外一块执行空间。

接下来,当信号来的时候,由接收信号的线程执行处理函数。

这里有一个和 Mach 机制的一个关键区别,Mach 是专门创建了一个接收线程来执行采集,原始现场是根据故障线程读取机器状态。而 Signal 是在接收到信号的线程进行采集,从回调参数中的上下文来读取原始现场。

Signal 送达时,系统会保存线程原来的状态,然后将其转入信号处理入口,KSCrash 的入口是:

c 复制代码
static void handleSignal(
    int sigNum,
    siginfo_t *signalInfo,
    void *userContext
);
  • sigNum:收到的信号
  • signalInfo:描述发生了什么,包括信号编号、具体原因、相关故障地址
  • userContext:保存被打断时的执行上下文,其中包含机器寄存器状态。KSCrash 从这里复制出机器上下文,作为回溯的起点

调用链是:

c 复制代码
handleSignal()
    ↓
callbacks.notify():进入异常处理,按需暂停其他线程
    ↓
ksmc_getContextForSignal():从 userContext 复制机器上下文
    ↓
kssc_initWithUnwind():准备调用栈回溯
    ↓
填写信号编号、原因、故障地址等信息
    ↓
callbacks.handle():进入统一报告处理流程

这里最重要的是:回溯的起点来自信号到来前保存的现场,这样才能定位被打断的位置。

记录完成后,恢复旧配置,再显式发送信号,处理函数的末尾直接执行:

c 复制代码
restoreHandlers();
raise(sigNum);

Signal 会直接显式调用 raise(),再次发送同一个信号。Mach 会判断存在处理该异常的有效端口时才会重新发送。

先恢复旧配置,是为了让后续信号按原来的方式处理,避免再次进入自己的采集入口。

另外关于 Mach 和 Signal 需要注意的是:同一个底层故障可能先表现为 Mach 异常,随后转换为 Signal。

这个怎么理解呢,其实就是如果 Mach 没有处理,继续往下分发,就会分发到 Signal 这一层。

在 XNU 的实现中:

  1. ux_handler_setup() 把 Unix 异常处理器注册到 host 层
  2. 它收到 Mach 异常后,调用 handle_ux_exception()
  3. handle_ux_exception() 先计算对应的信号,再调用 threadsignal(),把信号发给发生异常的线程

注意,转换不只是换了一个名称,还包含了实际发送信号这个动作。

ux_exception() 的通用映射分支包括:

Mach 异常及条件 对应 Signal
EXC_BAD_INSTRUCTION SIGILL
EXC_ARITHMETIC SIGFPE
EXC_BREAKPOINT SIGTRAP
EXC_BAD_ACCESS,错误码为 KERN_INVALID_ADDRESS SIGSEGV
EXC_BAD_ACCESS,其他错误码 SIGBUS
EXC_SOFTWARE 根据具体错误码,可能是 SIGSYS、SIGPIPE、SIGABRT 等

那么问题来了,同一个异常,会不会出现重复采集的情况呢,就是 Mach 采集了一次,Signal 又采集了一次?

KSCrash 针对这种情况是做了处理的,有一个 Signal 的采集开关,比如同一个故障,在 Mach 收到异常并处理完报告之后,会将这个开关设置为 false,这样即使进入 handleSignal((),也会跳过本次采集。

未捕获的 Objective-C 异常:NSException

如果异常已经被 @catch 处理了,并且没有继续抛出,就不会采集这个异常。

安装时,KScrash 保存原来的处理函数,再注册自己的函数:

c 复制代码
g_state.previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();

NSSetUncaughtExceptionHandler(&handleUncaughtException);

因此,它接收到的参数直接就是异常对象:

c 复制代码
static void handleUncaughtException(NSException *exception)
{
    handleException(exception, NO, YES);
}

这里的 NO 表示不是用户手动上报,YES 表示需要记录其他线程。

进入处理函数后,先从异常对象中获取:

信息 来源
异常名称 exception.name
异常原因 exception.reason
附加信息 exception.userInfo,本地实现转换成字符串
异常调用栈 exception.callStackReturnAddresses

在这里,异常对象中已经携带了抛出时的调用栈。

Apple 对 callStackReturnAddresses 的定义是:一组调用帧的返回地址,从异常首次被抛出的位置开始。因此,KSCrash 可以读取这个地址数组,用它初始化游标,如果数组为空,才退回到采集当前线程的调用栈。

也就是 KSCrash 会保留这两条栈:

  • 异常对象携带的栈
  • 进入处理回调时的当前栈

如果异常对象携带的栈为空,就用进入处理回调时的那个栈。

随后进入统一处理流程:

c 复制代码
handleUncaughtException(exception)
    ↓
读取异常信息,准备两条调用栈
    ↓
callbacks.notify()
标记为致命事件,暂停其他线程
    ↓
填写报告上下文
    ↓
callbacks.handleWithResult()
进入统一报告处理流程
    ↓
如果保存过旧的未捕获异常处理函数,调用它

比如一个典型的例子,NSArray 的数组越界:

c 复制代码
- (void)loadData {
    NSArray *items = @[@"苹果", @"香蕉"];

    // 只有两个元素,有效索引为 0、1,却访问索引 5
    NSString *item = [items objectAtIndex:5];

    NSLog(@"%@", item);
}

objectAtIndex: 的索引超出范围时,会抛出名称为 NSRangeException 的异常。

这个异常对象携带的信息:

  • name:NSRangeException
  • reason:索引 5 超出了只有两个元素的数组范围,具体文字由系统生成
  • callStackReturnAddresses:异常首次抛出时的调用栈地址,可用于定位到 loadData 中的调用位置

如果异常一直没有被捕获,进入 KSCrash 注册的未捕获异常回调,流程就是:

c 复制代码
调用 loadData
    ↓
objectAtIndex:5 抛出 NSException
    ↓
异常未被捕获,进入顶层处理
    ↓
KSCrash 的 handleUncaughtException(exception)
    ↓
读取异常名称、原因、调用栈
    ↓
写入崩溃报告

这里 KSCrash 拿到的就是前面那个异常对象。

未捕获的 C++ 异常

同样的,这是 C++ 层面的异常,当找不到匹配的异常处理函数,会进入 std::terminate()。

这里的核心是两个时机:抛出异常时,尝试保存调用栈;进入终止流程时,采集异常信息并写报告。

比如:

c 复制代码
#include <stdexcept>

void loadData() {
    throw std::runtime_error("配置文件解析失败");
}

int main() {
    loadData(); // 没有 catch 接住异常
}

找不到匹配的异常处理函数,会进入 std::terminate()。

KSCrash 同样的是在安装时,接管 terminate 回调,注册自己的处理函数,同时保存旧函数:

c 复制代码
g_state.originalTerminateHandler =
    std::set_terminate(CPPExceptionTerminate);

因此,进入终止流程后,就有机会执行 CPPExceptionTerminate()。

在抛出时,尝试提前保存异常发生位置的栈。

在这里使用的 C++ 异常 ABI 中,编译器通过 __cxa_throw() 将新抛出的异常交给运行时。KSCrash 在这个入口加了个取调用栈的逻辑:当拦截生效、当前线程的采集标记开启时,调用:

c 复制代码
kssc_initSelfThread(&g_exceptionStackCursor, 2);

随后仍然调用原来的 __cxa_throw(),继续正常的异常处理流程。

这一步只保存栈,即使异常随后被正常 catch,也可以经过这里,保存栈本身不会生成崩溃报告。

然后等进入 terminate 后,去提取异常的信息。

CPPExceptionTerminate() 获取当前异常的类型信息,并尝试转换成可读名称,比如在前面的例子中,异常类型就是 std::runtime_error。

获取描述时,会重新抛出当前异常,再在函数内部接住它:

c 复制代码
try {
    throw; // 重新抛出当前正在处理的异常
} catch (std::exception &exception) {
    // 将 exception.what() 复制到报告描述中
}

KSCrash 会尝试在截获 throw 时,提前保存当前线程的调用栈,此时还不能确定异常是否被捕获,所以只把地址保存在内存中。

进入 terminate 处理函数后,如果此前保存了有效的异常栈,就使用,如果此前没保存到有效的,比如是空的,就会采集当前的线程栈作为回退的方案。

然后就是填写异常类型、描述等信息,调用统一的报告入口,最后调用保存的旧 terminate handler。

如何记录?

记录的内容可以大概分为两类:

一个是主报告,主要包含异常类型、原因、故障地址、线程调用栈等,这个是在崩溃产生后,生成 JSON,通过 write() 写入文件。

一个是配套的状态文件,记录比如系统信息、资源状态、生命周期信息等,这种是通过提前创建文件,并建立 mmap 映射,通过 mmap 的方式记录到文件中。

mmap 是个什么东西下面会单独列个小节来讲,是一种写入到文件的方式。

继续大局观,前面我们提到的四个异常,各自都有对应的监控器:

异常入口 对应监控器
Mach 异常 MachException
致命 Signal Signal
未捕获的 NSException NSException
C++ terminate 处理入口 CPPException

这些监控器各自负责自己的崩溃现场采集,然后会接入到一个公共的报告处理流程。

整体过程可以分成四步:

  1. 正常运行时,提前保存配套状态

KSCrash 还会有一些状态的监控器,比如 System、Resource、Lifecycle,这些监控器启用后,各自创建配套文件,并建立 mmap 映射,它们在初始化或运行过程中,把记录到的信息写入到映射区域。

因为崩溃发生后各种资源都是很紧张的,所以这里是在提前记录一些东西,所以,崩溃发生前,配套文件已经存在,并保存着此前记录的状态。

  1. 发生崩溃时,由对应的入口取得异常的现场

Mach、Signal、NSException、C++ 监控器通过各自的入口,取得异常信息,并准备调用栈回溯所需的现场或地址列表。

四种入口的区别,主要在于 怎样取得这些信息,下面会在单独的小节里展开看每个入口是怎么取它们所需要的信息的。

  1. 这里它们会有一个共同的处理流程,更新配套状态,再写主报告

四种监控器取得异常现场之后,都会调用同一个方法,在 KSCrashMonitor.c 中的 handleException() 方法。它接收崩溃上下文 ctx,组织后面的补充信息、写报告和收尾工作。

这个方法主要按顺序做三件事:

a. 让已启动的监控器补充信息

调用 kscmr_addContextualInfoToEvent(),遍历已启用的监控器,执行各自的补充回调。比如 System 更新内存统计、Lifecycle 更新致命事件标记,就发生在这个阶段。

b. 调用报告处理函数

通过事件回调进入 KSCrashC.c 的 onExceptionEvent()。在需要写标准报告的正常路径中,再调用 kscrashreport_writeStandardReport(),由它组织 JSON 并写入主报告文件。

c. 进行收尾

恢复此前暂停的线程,对于致命事件,禁用相应的异步安全监控器,结束本次异常处理,并按需释放上下文。

  1. 后续读取的时候,把两部分信息组合起来

对于还没有合并的报告,会在这里合并,先解析主 JSON,然后根据运行 ID,找到配套文件,将可用的信息补充进报告,然后再返回组合后的结果。

这是采集到记录报告的一个总体流程,我们这里先不关注具体的报告内容,先看记录的方式,也就是 mmap 和 write()。

mmap

mmap 是操作系统提供的 "内存映射" 接口。用于文件时,它把文件的一段内容,关联到进程的一段虚拟地址空间,让程序通过指针访问这段文件内容。

啥意思呢,就是其实一个文件,可以看作是连续排列的字节:

c 复制代码
文件偏移量: 0 1 2 3 4 5 ...  
文件内容: 00 00 00 00 00 00 ...

文件偏移量就是一个文件距离文件开头有多远。

内存也有自己的地址:

c 复制代码
内存地址: P P+1 P+2 P+3 P+4 P+5 ...  
内存内容: 00 00 00 00 00 00 ...

这里的 P 代表系统选择的一段虚拟内存的起始地址。

mmap 所做的事情,就是建立一套映射:

c 复制代码
内存地址 P ↔ 文件偏移量 0  
内存地址 P+1 ↔ 文件偏移量 1
内存地址 P+2 ↔ 文件偏移量 2

这样,当程序修改比如内存地址 P+2 对应的字节时,操作系统就会知道:

这个内存位置属于文件映射,对应的是文件偏移量为 2 的位置。

write()

这里有两点,一个是,KSCrash 使用了 C 语言写的 JSON 编码器,KSJSONCodec.c,因为最终的输出是 json 文件。第二个关键点是,准备了一块 1024 字节的临时内存,作为写入的缓冲区。

工作流程是这样的:

编码器会逐步生成 JSON 片段,较小的片段先放进缓冲区,下一段数据放不下的时候,就写入文件,如果单个片段本身超过缓冲区的容量,就直接写入文件。

写完报告基本信息、异常信息、线程信息后,代码还会主动写出缓冲区中的内容。关闭文件前,如果有剩余的数据,也会写入到文件中来。

为什么要用缓冲区,因为编码器其实会产生很多很小的片段。比如:

一个引号 -> address -> 一个引号 -> 冒号 -> 66

如果每产生一个片段就调用一次 write(),连一个引号都需要单独调用一次文件写入。

所以攒着一批再合并提交,可以减少 write() 的调用次数。

接下来,就是怎么解读报告。

如何解读报告

Mach 异常报告

先来看一个真实的 Mach 异常:

json 复制代码
{
  "report": {
    "id": "23509580-E100-4F5B-B139-6EE654A63F7A",
    "run_id": "7d136149-b52e-4d6a-9582-73e81b93cc29",
    "monitor_id": "MachException",
    "process_name": "HTAPMKitDemo",
    "timestamp": "2026-10-19T07:00:51.366074Z",
    "type": "standard",
    "version": "3.9.0"
  },

  "crash": {
    "error": {
      "type": "mach",
      "is_fatal": true,
      "is_clean_exit": false,
      "address": 66,
      "mach": {
        "exception": 1,
        "exception_name": "EXC_BAD_ACCESS",
        "code": 1,
        "code_name": "KERN_INVALID_ADDRESS",
        "subcode": 66
      },
      "signal": {
        "signal": 11,
        "name": "SIGSEGV",
        "code": 0,
        "code_name": "SEGV_NOOP"
      }
    },

    "threads": [
      {
        "index": 0,
        "crashed": true,
        "current_thread": false,

        "registers": {
          "basic": {
            "pc": 4354163936,
            "lr": 4354163108,
            "sp": 6126295936,
            "fp": 6126296000,
            "x8": 42,
            "x9": 66,
            ...
          },
          "exception": {
            "exception": 0,
            "esr": 2449473606,
            "far": 66
          }
        },

        "backtrace": {
          "contents": [
            {
              "instruction_addr": 4354163936,
              "object_addr": 4353851392,
              "object_name": "HTAPMKitDemo.debug.dylib",
              "object_uuid": "F5B492F1-467E-37A1-81FE-227A362F42C1",
              "symbol_addr": 4354163824,
              "symbol_name": "$s12HTAPMKitDemo17HTCrashValidationO19triggerEXCBadAccesss5NeverOyFZ"
            },
            {
              "instruction_addr": 4354163108,
              "object_name": "HTAPMKitDemo.debug.dylib",
              "symbol_name": "$s12HTAPMKitDemo24HTCrashValidationTriggerO7performyyF",
              ...
            },
            ...
          ],
          "skipped": 0
        },

        "stack": {
          "stack_pointer": 6126295936,
          "dump_start": 6126295856,
          "dump_end": 6126296096,
          "contents": "00F49B260100000080581D0301000000C0CFA92601000000A85D18900100000080BAAE0801000000080000000000000080DB276D01000000AC53CB8401000000683438F001000000080000000000000090DB276D01000000544ECB84010000004200000000000000F89EC20301000000C051C20301000000420000000000000042000000000000004200000000000000E0DB276D01000000A441870301000000683438F00200000002BECA210100000030DC276D01000000E8558403010000000000000002000000683438F001000000381A38F00100000080BAAE08010000007823DAF1010000000200000000000000",
          "grow_direction": "-",
          "overflow": false
        },

        "state": "TH_STATE_WAITING"
      },
      ...
    ]
  },

  "binary_images": [
    ...,
    {
      "name": "\/private\/var\/containers\/Bundle\/Application\/27606199-CC6B-417C-BFEE-D2BE0C5D8D98\/HTAPMKitDemo.app\/HTAPMKitDemo.debug.dylib",
      "image_addr": 4353851392,
      "image_size": 4489216,
      "uuid": "F5B492F1-467E-37A1-81FE-227A362F42C1",
      ...
    },
    ...
  ],

  "system": {
    ...
  },

  "process": {},
  "debug": {},
  "user": {}
}

眼花缭乱就对了,已经省略了很多内容了,不急,一个个看。

报告主要有 5 块内容:

内容 对应字段 主要记录什么
报告基本信息 report 报告 ID、运行 ID、时间、采集监控器
异常信息 crash.error 异常类型、错误原因、故障地址
线程现场 crash.threads 哪个线程崩溃,以及寄存器、调用栈、栈内存
镜像信息 binary_images App 和动态库的名称、加载地址、UUID
运行环境与状态 system 设备、系统、应用信息,以及内存、前后台等配套状态

异常信息和线程现场都是放在 crash 里的。

1. 基本信息

json 复制代码
  "report": {
    "id": "23509580-E100-4F5B-B139-6EE654A63F7A",
    "run_id": "7d136149-b52e-4d6a-9582-73e81b93cc29",
    "monitor_id": "MachException",
    "process_name": "HTAPMKitDemo",
    "timestamp": "2026-10-19T07:00:51.366074Z",
    "type": "standard",
    "version": "3.9.0"
  }
  • id:报告的唯一 id
  • run_id:本次 App 运行的唯一 id
  • monitor_id:采集本次事件的监控器,这里是 MachException
  • process_name:发生异常的进程名称,这里是 HTAPMKitDemo
  • timestamp:写入报告基本信息时取的时间
  • type:报告类型,standard 表示这是标准报告
  • version:报告格式版本

这些主要是 KSCrash 自己生成或填写的。

2. 异常信息 crash.error

json 复制代码
"crash": {
    "error": {
      "type": "mach",
      "is_fatal": true,
      "is_clean_exit": false,
      "address": 66,
      "mach": {
        "exception": 1,
        "exception_name": "EXC_BAD_ACCESS",
        "code": 1,
        "code_name": "KERN_INVALID_ADDRESS",
        "subcode": 66
      },
      "signal": {
        "signal": 11,
        "name": "SIGSEGV",
        "code": 0,
        "code_name": "SEGV_NOOP"
      }
    }

这个主要是从系统的 Mach 异常消息中提取出来的信息。

  • type:mach,本次记录的事件属于 Mach 异常
  • is_fatal:true,致命事件
  • is_clean_exit:false,非正常退出
  • address:66,本次故障地址,十六进制为 0x42

再看 mach 里面:

  • exception、exception_name:1、EXC_BAD_ACCESS,发生了内存访问异常
  • code、code_name:1、KERN_INVALID_ADDRESS,访问的目标地址当前无效
  • subcode:66,对于本次 EXC_BAD_ACCESS,它携带的出错的内存地址,也就是 0x42

还有一个 signal,这个是 KSCrash 自己根据 Mach 异常补的,并不是 Signal 采集器又采集了一次崩溃。

  • signal、name:11、SIGSEGV
  • code、code_name:0、SEGV_NOOP,没有 Signal 层的详细原因,0 对应的 code_name 被转成了 SEGV_NOOP

3. 线程现场 crash.threads

json 复制代码
"threads": [
  {
    "index": 0,
    "crashed": true,
    "current_thread": false,

    "registers": {
      "basic": {
        "pc": 4354163936,
        "lr": 4354163108,
        "sp": 6126295936,
        "fp": 6126296000,
        "x8": 42,
        "x9": 66,
        ...
      },
      "exception": {
        "exception": 0,
        "esr": 2449473606,
        "far": 66
      }
    },

    "backtrace": {
      "contents": [
        {
          "instruction_addr": 4354163936,
          "object_addr": 4353851392,
          "object_name": "HTAPMKitDemo.debug.dylib",
          "object_uuid": "F5B492F1-467E-37A1-81FE-227A362F42C1",
          "symbol_addr": 4354163824,
          "symbol_name": "$s12HTAPMKitDemo17HTCrashValidationO19triggerEXCBadAccesss5NeverOyFZ"
        },
        {
          "instruction_addr": 4354163108,
          "object_name": "HTAPMKitDemo.debug.dylib",
          "symbol_name": "$s12HTAPMKitDemo24HTCrashValidationTriggerO7performyyF",
          ...
        },
        ...
      ],
      "skipped": 0
    },

这里主要是寄存器,和还原出来的 backtrace,也就是调用栈。

因为 Mach 异常,系统并没有给调用栈的信息,所以这里是 KSCrash 根据寄存器的状态,结合栈内存和展开的规则,还有二进制的镜像信息,也就是 binary_images,来逐层还原调用链。

c 复制代码
故障线程的寄存器:PC、LR、SP、FP
↓
结合栈内存和展开规则,逐层回溯
↓
得到各帧的代码地址
↓
查询所属镜像和符号信息
↓
得到报告中的 backtrace

但是这里的 binary_images 说的是进程中的,而不是报告中的,报告中的 binary_images 没有完整的展开规则和符号表。

  • instruction_addr:这一帧的代码地址,比如 4354163936,这里是第 0 帧,对应发生异常时的 PC,也就是出错指令的位置
  • object_addr:所属二进制镜像在内存中的加载基地址,可以理解为这个镜像在进程内存中的起始位置
  • object_name:所属二进制镜像的名称
  • object_uuid:所属二进制镜像的 UUID
  • symbol_addr:解析出的符号的起始地址
  • symbol_name:解析出的符号名称

4. 镜像信息 binary_images

json 复制代码
  "binary_images": [
    ...,
    {
      "name": ".../HTAPMKitDemo.app/HTAPMKitDemo.debug.dylib",
      "image_addr": 4353851392,
      "image_size": 4489216,
      "uuid": "F5B492F1-467E-37A1-81FE-227A362F42C1",
      ...
    },
    ...
  ]

binary_images 是一个列表,其中的每一项对应一份加载到当前进程中的可执行文件或动态库。

我刚刚跑的 Mach 异常,有 1064 条数据,因为 App 运行时会加载很多系统框架,而这些框架又依赖其他库。这些直接、间接依赖都会带来更多镜像,在运行过程中还可以继续加载动态库:

所在位置 数量
Demo 的 App 包内 2
系统 Framework 目录,含系统内部框架 875
/usr/lib 下的动态库 182
其他位置 5

绝大多数都是系统框架和库。

  • name:二进制镜像的完整路径
  • image_addr:镜像在内存中的加载基地址
  • image_size:这份实现记录的镜像区域大小
  • uuid:标识这份二进制镜像的 UUID(与调用栈里的 object_uuid 相同)

在 backtrace.contents 中的 symbol_name,比如这个例子中的 $s12HTAPMKitDemo17HTCrashValidationO19triggerEXCBadAccesss5NeverOyFZ,就是 KSCrash 在崩溃处理时,从已加载的 HTAPMKitDemo.debug.dylib 中查出来,再写进报告的。

具体过程是:

c 复制代码
故障指令地址:4354163936
          ↓
找到所属镜像:HTAPMKitDemo.debug.dylib
          ↓
查镜像的符号表,找到对应符号
          ↓
从字符串表取出名称
          ↓
写入 backtrace 中的 symbol_name

从 $s12HTAPMKitDemo17HTCrashValidationO19triggerEXCBadAccesss5NeverOyFZ 中能大概看出是 HTAPMKitDemo.HTCrashValidation.triggerEXCBadAccess() 方法,这一步其实已经是符号化后的结果了,KSCrash 会在写入主报告的时候,利用 App 进程中已经加载的二进制符号表,查到这个方法的符号,然后再写入报告。不过这里的名称还保留着 Swift 编码的方式,后续可以通过符号解码转换成可读名称。然后利用匹配的 dSYM 可以进一步解析代码地址,补充函数名以及源码的位置。

5. 运行状态 system

前面太长了我就省略了 system,它是长这样的:

json 复制代码
  "system" : {
    "app_memory" : {
      "memory_footprint" : 46516544,
      "memory_level" : "normal",
      "memory_limit" : 3221225472,
      "memory_pressure" : "normal",
      "memory_remaining" : 3174708928
    },
    "app_start_time" : "2026-10-19T06:58:21Z",
    "app_uuid" : "259631BB-E0BC-3F40-AF78-B8067407C070",
    "application_stats" : {
      "active_time_since_last_crash" : 86.879099999999994,
      "active_time_since_launch" : 86.879099999999994,
      "app_transition_state" : "active",
      "application_active" : true,
      "application_in_foreground" : true,
      "background_time_since_last_crash" : 60.597200000000001,
      "background_time_since_launch" : 60.597200000000001,
      "launches_since_last_crash" : 1,
      "sessions_since_last_crash" : 3,
      "sessions_since_launch" : 3,
      "task_role" : "FOREGROUND_APPLICATION",
      "user_perceptible" : true
    },
    "battery_level" : 95,
    "battery_state" : 2,
    "binary_arch" : "arm64",
    "binary_cpu_subtype" : 0,
    "binary_cpu_type" : 16777228,
    "build_type" : "debug",
    "CFBundleExecutable" : "HTAPMKitDemo",
    "CFBundleExecutablePath" : "\/private\/var\/containers\/Bundle\/Application\/27606199-CC6B-417C-BFEE-D2BE0C5D8D98\/HTAPMKitDemo.app\/HTAPMKitDemo",
    "CFBundleIdentifier" : "com.xxx.HTAPMKitDemo",
    "CFBundleName" : "HTAPMKitDemo",
    "CFBundleShortVersionString" : "0.1.0",
    "CFBundleVersion" : "1",
    "clang_version" : "21.0.0 (clang-2100.1.1.101)",
    "cpu_arch" : "arm64e",
    "cpu_average_usage_permil" : 0,
    "cpu_core_count" : 6,
    "cpu_state" : "normal",
    "cpu_subtype" : 2,
    "cpu_type" : 16777228,
    "cpu_usage_system" : 21,
    "cpu_usage_user" : 152,
    "data_protection_active" : false,
    "device_app_hash" : "b98a95437658b1abfe2a3949b09f090c05bf4fe7",
    "jailbroken" : false,
    "kernel_version" : "Darwin Kernel Version 25.6.0: Thu Jul 30 12:45:07 PDT 2026; root:xnu-12377.162.14~4\/RELEASE_ARM64_T8101",
    "low_power_mode_enabled" : false,
    "machine" : "iPhone13,4",
    "memory" : {
      "free" : 194215936,
      "size" : 5967052800,
      "usable" : 4752932864
    },
    "model" : "D54pAP",
    "os_version" : "23G90",
    "parent_process_id" : 1,
    "proc_translated" : false,
    "process_id" : 61887,
    "process_name" : "HTAPMKitDemo",
    "process_start_monotonic_ns" : 1888207157414708,
    "process_start_wall_clock_ns" : 1792393101932930000,
    "system_name" : "iOS",
    "system_version" : "26.6.2",
    "thermal_state" : 0,
    "thread_count" : 17,
    "time_zone" : "GMT+8"
  },

这部分就是 App 运行环境和运行状态的记录,用来辅助判断崩溃是否与某个版本、设备或资源状态有关,这里大概分四类:

  • 应用、设备和系统
  • 内存
  • CPU、电量等资源状态
  • 应用的生命周期

是由三个监控器负责采集,都是 mmap 的方式去写文件,然后在后续读报告的时候,通过 run_id 找到对应的配套文件,合并到 system 中。

监控器 主要负责的信息
System 应用、设备、系统、进程等基础信息,以及系统内存统计
Resource App 内存、CPU、线程数量、电量、温度状态等
Lifecycle 前后台、活跃状态、运行时长、启动与会话次数等

Signal 报告

Signal 事件的报告结构也 Mach 是差不多的:

json 复制代码
{
  "report": {
    "monitor_id": "Signal",
    ...
  },

  "crash": {
    "error": {
      "address": 9622024656,
      "is_clean_exit": false,
      "is_fatal": true,
      "mach": {
        "code": 0,
        "exception": 10,
        "exception_name": "EXC_CRASH",
        "subcode": 0
      },
      "signal": {
        "code": 0,
        "name": "SIGABRT",
        "signal": 6
      },
      "type": "signal"
    },

    "threads": [
      {
        "index": 0,
        "crashed": true,
        "current_thread": true,

        "registers": {
          "basic": {
            "pc": 9622024656,
            "lr": 8283248604,
            "sp": 6159440704,
            "fp": 6159440736,
            ...
          },
          "exception": {
            "far": 0,
            ...
          }
        },

        "backtrace": {
          "contents": [
            {
              "instruction_addr": 9622024656,
              "object_name": "libsystem_kernel.dylib",
              "symbol_name": "__pthread_kill",
              ...
            },
            {
              "instruction_addr": 8283248604,
              "object_name": "libsystem_pthread.dylib",
              "symbol_name": "pthread_kill",
              ...
            },
            {
              "instruction_addr": 6876327884,
              "object_name": "libsystem_c.dylib",
              "symbol_name": "abort",
              ...
            },
            {
              "instruction_addr": 4311466704,
              "object_name": "HTAPMKitDemo.debug.dylib",
              "symbol_name": "$s12HTAPMKitDemo17HTCrashValidationO14triggerSIGABRTs5NeverOyFZ",
              ...
            },
            {
              "instruction_addr": 4311466240,
              "object_name": "HTAPMKitDemo.debug.dylib",
              "symbol_name": "$s12HTAPMKitDemo24HTCrashValidationTriggerO7performyyF",
              ...
            },
            ...
          ],
          "skipped": 0
        },

        "stack": {
          ...
        },

        "state": "TH_STATE_RUNNING"
      },
      ...
    ]
  },
  
  "binary_images": [ ... ],
  "system": { ... },
  "process": {},
  "debug": {},
  "user": {}
}
报告 含义
monitor_id: "Signal"、type: "signal" 由 Signal 监控器采集
signal: 6、name: SIGABRT 捕获的信号是 SIGABRT
is_fatal: true、is_clean_exit: false 被记录为致命事件、非正常退出
index: 0、crashed: true 报告中编号为 0 的线程是故障线程
current_thread: true 这个线程同时也是执行报告写入的线程

然后重点看 backtrace,找到谁触发了终止流程。

第 0 帧是 __pthread_kill,下面是 pthread_kill、abort,再下面就是 Demo 中的 triggerSIGABRT(),perform。

也就是:

swift 复制代码
HTCrashValidationTrigger.perform()
    ↓
HTCrashValidation.triggerSIGABRT()
    ↓
abort()
    ↓
pthread_kill()
    ↓
__pthread_kill()

所以,这里就是专门通过 abort() 方法触发了一次崩溃。

Signal 的调用栈也是 KSCrash 自己回溯出来的,根据现场,拿到寄存器的信息,以寄存器信息为起点,逐帧去回溯,然后再查各帧对应的镜像和符号,写进报告:

c 复制代码
系统保存的线程上下文
    ↓
取得 PC、LR、SP、FP 等寄存器
    ↓
结合栈内存和展开规则,得到各帧代码地址
    ↓
查询镜像和符号信息
    ↓
写入 backtrace

NSException 报告

json 复制代码
{
  "report": { "monitor_id": "NSException", ... },
  "crash": {
    "error": {
      "address": 0,
      "is_clean_exit": false,
      "is_fatal": true,
      "mach": { "code": 0, "exception": 10, "exception_name": "EXC_CRASH", "subcode": 0 },
      "nsexception": { "name": "HTAPMKitDemoNSException" },
      "reason": "HTAPMKit Demo 主动触发的未捕获 NSException",
      "signal": { "code": 0, "name": "SIGABRT", "signal": 6 },
      "type": "nsexception"
    },
    "last_exception_backtrace": {
      "contents": [
        ...,
        { "instruction_addr": 6624596516, "object_name": "libobjc.A.dylib", "symbol_name": "objc_exception_throw", ... },
        ...,
        { "instruction_addr": 4345529240, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "$s12HTAPMKitDemo17HTCrashValidationO18triggerNSExceptionyyFZ", ... },
        { "instruction_addr": 4345528604, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "$s12HTAPMKitDemo24HTCrashValidationTriggerO7performyyF", ... },
        ...
      ],
      "skipped": 0
    },
    "threads": [
      {
        "backtrace": { "contents": [...], "skipped": 0 },
        "crashed": true, "current_thread": true, "index": 0,
        "state": "TH_STATE_RUNNING"
      },
      ...
    ]
  },
  "binary_images": [...],
  "system": { ... },
  "process": {}, "debug": {}, "user": {}
}
  • nsexception.name:HTAPMKitDemoNSException
  • reason:HTAPMKit Demo 主动触发的未捕获 NSException

因为我的触发代码是:

swift 复制代码
static func triggerNSException() {
    NSException(
        name: NSExceptionName("HTAPMKitDemoNSException"),
        reason: "HTAPMKit Demo 主动触发的未捕获 NSException",
        userInfo: nil
    ).raise()
}

这里也有 mach 和 signal,这个是 NSException 监控器自己填进去的,作为配套信息,而不是 Mach、Signal 监控器又采集了一次。

这里重点是多了一个 last_exception_backtrace,这是异常抛出时的调用栈。

NSException 对象可以通过 callStackReturnAddresses 拿到异常首次抛出时的调用帧地址。这些地址已经按调用栈排列好了,然后 KSCrash 只需要去读取这些地址,然后查询每个地址所属的镜像和符号,写成 last_exception_backtrace:

c 复制代码
异常对象保存的调用帧地址
↓
KSCrash 读取 callStackReturnAddresses
↓
查询各帧所属的镜像和符号
↓
写入 last_exception_backtrace

还有一个是 threads[].backtrace,这个是 KSCrash 额外采集的一个调用栈,在处理异常的时候采集当前线程的调用栈。

C++ 崩溃报告

json 复制代码
{
  "report": { "monitor_id": "CPPException", ... },
  "crash": {
    "error": {
      "address": 0,
      "cpp_exception": { "name": "std::runtime_error" },
      "is_clean_exit": false,
      "is_fatal": true,
      "mach": { "code": 0, "exception": 10, "exception_name": "EXC_CRASH", "subcode": 0 },
      "reason": "HTAPMKit Demo C++ exception",
      "signal": { "code": 0, "name": "SIGABRT", "signal": 6 },
      "type": "cpp_exception"
    },
    "last_exception_backtrace": {
      "contents": [
        { "instruction_addr": 4372873348, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "_ZN13htapmkit_demo12CPPException5crashEv", ... },
        { "instruction_addr": 4372873256, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "+[HTCrashValidationTriggers triggerUncaughtCPPException]", ... },
        { "instruction_addr": 4373169596, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "$s12HTAPMKitDemo17HTCrashValidationO19triggerCPPExceptions5NeverOyFZ", ... },
        { "instruction_addr": 4373168476, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "$s12HTAPMKitDemo24HTCrashValidationTriggerO7performyyF", ... },
        ...
      ],
      "skipped": 0
    },
    "threads": [
      {
        "backtrace": {
          "contents": [
            { "instruction_addr": 6625702392, "object_name": "libc++abi.dylib", "symbol_name": "<redacted>", ... },
            ...,
            { "instruction_addr": 6625629024, "object_name": "libc++abi.dylib", "symbol_name": "__cxa_throw", ... },
            { "instruction_addr": 4372873348, "object_name": "HTAPMKitDemo.debug.dylib", "symbol_name": "_ZN13htapmkit_demo12CPPException5crashEv", ... },
            ...
          ],
          "skipped": 0
        },
        "crashed": true, "current_thread": true, "index": 0,
        "state": "TH_STATE_RUNNING"
      },
      ...
    ]
  },
  "binary_images": [...],
  "system": { ... },
  "process": {}, "debug": {}, "user": {}
}

这里我主动触发了一个未捕获的 std::runtime_error。

report.monitor_id 是 CPPException,crash.error.type 是 cpp_exception,说明这份报告是由 C++ 异常监控器采集的。

cpp_exception.name 是 std::runtime_error,这是抛出的 C++ 异常类型,reason 是 HTAPMKit Demo C++ exception,是异常的描述,我的代码是:

c 复制代码
static void crash() {
    throw std::runtime_error("HTAPMKit Demo C++ exception");
}

和 NSException 一样,这里的 EXC_CRASH、SIGABRT 是监控器填入的配套信息。

异常来源的调用栈是 last_exception_backtrace,它的第一帧:

c 复制代码
{
  "instruction_addr": 4372873348,
  "object_name": "HTAPMKitDemo.debug.dylib",
  "symbol_name": "_ZN13htapmkit_demo12CPPException5crashEv",
  ...
}

这个符号对应:

htapmkit_demo::CPPException::crash()

也就是刚才执行 throw std::runtime_error(...) 的函数。

继续往下就是 Objective-C++ 的辅助入口和 Swift 的触发函数,调用路径是:

c 复制代码
HTCrashValidationTrigger.perform()
↓
HTCrashValidation.triggerCPPException()
↓
+[HTCrashValidationTriggers triggerUncaughtCPPException]
↓
htapmkit_demo::CPPException::crash()
↓
throw std::runtime_error("HTAPMKit Demo C++ exception")

C++ 的异常对象没有像 NSException 那样,可以通过方法拿到调用栈地址,所以要靠自己去获取,KSCrash 会在两个时机去获取。

  • 执行 throw 时,对应 crash.last_exception_backtrace
  • 进入 terminate 处理时,对应 crash.threads[0].backtrace

throw 是异常刚刚被抛出的时候,这个时候还不知道有没有catch,此时还不确定会不会崩溃,到这里的时候,KSCrash 就会采集调用栈,通过系统提供的 backtrace() 方法。在这个位置取,是为了保留抛出时的现场,等进入 terminate 的时候,调用链中已经增加了异常处理相关的函数。但是由于还不确定是否崩溃,所以这里采集的数据只是放在内存中。

进入 terminate 的时候,说明本次异常未被捕获,此时才会生成崩溃报告,这个时候还会采集一次调用栈,也是通过线程的 backtrace() 方法。

在这个例子里,terminate 采集的调用栈比 throw 的时候多了三帧,其余的部分完全一致。

获取调用栈

其实我们查崩溃,可以说大部分都是看调用栈,我在这里单独列一个小节,来说明这四种异常的调用栈的差异。

他们的主要区别在于:这些调用栈地址从哪里来,以及要还原哪个线程、哪个时刻的现场。

先说获取调用栈的方式,系统提供了一个函数 backtrace(),可以获取当前线程的调用栈。

KSCrash 有自己的调用栈回溯器,因为 Mach 和 Signal 有点特殊,要根据寄存器等状态来自己回溯出崩溃时的调用栈。

1. Mach:采集线程和发生异常的线程不同

例如,线程 A 发生异常,KSCrash 是有一个异常接收线程 B,是在这里收到的消息。

如果直接在 B 调用 backtrace(),拿到的就是 B 的而不是 A 的,所以 KSCrash 会根据异常消息中的线程标识,查询 A 的寄存器状态,再结合栈内存和展开规则进行回溯。

2. Signal:线程相同,但是要以被打断前的现场为起点

Signal 的信号,是在 handleSignal() 方法

进入 handleSignal() 后,线程已经在执行信号处理代码。

此时直接调用 backtrace(),回溯会从处理函数所在的当前调用现场开始。而 KSCrash 是想明确的从 Signal 到来前被打断的位置 开始,因此读取系统传入的 userContext,取出里面保存的寄存器,再从那个现场回溯。

Mach 和 Signal 都是 KSCrash

3. NSException:异常对象已经提供了调用帧地址

NSException 的 callStackReturnAddresses 属性,提供异常首次抛出时的调用帧地址。因此,KSCrash 可以直接读取这些地址,随后再查镜像和符号信息,就可以生成调用栈。

这个栈记录成 last_exception_backtrace,如果 last_exception_backtrace 拿到的数组是空的,则回退到采集当前线程的栈,所以它不一定就是最初抛出的现场。

还有一个另外的栈,threads[].backtrace,这个是 KSCrash 在异常处理函数中,调用了 backtrace() 采集当前线程的栈,记录的是处理异常时的调用路径。

4. C++:采集的点就在要观察的当前调用链里

两个时机:

  • 截获 throw 时,采集,放到 last_exception_backtrace
  • 进入 terminate 处理函数时,采集,放到 threads[].backtrace

和 Mach 的区别就在于,C++ 采集的点就在当前的调用链中,所以可以直接通过 backtrace() 拿到需要的调用栈。

收个尾

这篇文章主要是介绍 KSCrash 会去采集哪些异常,怎么采集,然后报告是怎么写到文件里的,还有就是四种异常,他们的调用栈分别是怎么来的,另外报告具体要怎么读。

四种异常:

  • Mach 异常
  • Signal 致命事件
  • NSException
  • C++ 异常

报告写入的方式:

  • write()
  • mmap

不同的异常怎么拿调用栈:

  • Mach 异常:根据异常消息的线程标识,拿寄存器状态,回溯
  • Signal 异常:通过 userContext 拿到里面的寄存器,回溯
  • NSException:两个栈,异常对象会带一个,如果拿不到,会回退到采集当前线程的调用栈,然后到处理异常的时候再采集当前线程的栈
  • C++:两个栈,异常抛出时采集一次,异常处理时采集一次

不过 KSCrash 其实不只是有崩溃采集的这部分功能,它还有:

  • 主动记录异常
  • 卡顿检测
  • 异常终止推断
  • Zombie 辅助诊断
  • 报告处理和发送

这些有需要再看吧。

相关推荐
茶底世界之下10 小时前
窗光静物一定要更亮吗?用 Awaking 把曝光收回一点
ios
ii_best11 小时前
ios手机开发安卓脚本开发软件[按键精灵教程]从「找最小值」到「Boss 最近刷新」再到「屏幕范围极值」
android·ios·智能手机·自动化·按键精灵
茶底世界之下1 天前
林间步道要不要加暗角?用 Awaking 把强度收在20%
前端·ios·图片资源
season_zhu1 天前
Skills For Real Engineers 系列之一:先配好项目,再让 ask-matt 帮我选技能
ios·openai·ai编程
疯狂敲代码的老刘1 天前
d2r-mobile:3.2千星开源暗黑破坏神2 iOS兼容层,3天冲上GitHub日榜
ios·github
黑科技iOS上架2 天前
App Store5.6隐藏功能究竟怎么排查?
ios·审核
Yannik2 天前
iOS Swift Package Manager(SPM)私有组件完整实操
ios·swift
随遇丿而安2 天前
第 4 周:28KB 的 PNG 占了 8MB 内存,因为图片内存看尺寸不看文件大小
ios
leizhen198408092 天前
Vibe Coding:门槛降了,但判断力成了新的门槛
人工智能·安全·ios