KSCrash 是 iOS 上一个知名的 crash 收集框架。包括腾讯刚开源的 APM 框架 Matrix,其中 crash 收集部分也是直接使用的 KSCrash。那么 KSCrash 到底是如何进行 crash 收集的呢?
总体结构
总体结构如下图:
可以看到总共分为三大部分:
- Crash Recording
- Crash Reporting
- Installation
其中 Installation 用来启动 KSCrash,并且指定了 Crash 收集的方式。Crash 收集方式可以包括:邮件发送,向指定服务器发送,向专门的 Crash 收集服务器发送等方式。每种方式对应于一个实现类:
Crash Reporting 包含了三个子文件夹,分别是 Filter,Sink 和 Tools,主要用来上报 Crash。本文不会对其做具体的研究。
其中 Filter 包含了将存储在设备上的 Crash 信息的 NSData 转为 NSString,JSON,GZip 等几种方式的实现类。
Sink 则是不同发送方式的真正的处理者,和 Installation 中的几种收集方式一一对应。
Tools 是一些工具类,以及发送请求相关的类的合集。
最最关键的还是要数 Crash Recording,它包含了捕捉各种类型 crash 的方式,将会在下文中做详细介绍。
基本使用
以官方 Demo 中的 Simple-Example 为例:
1 | @implementation AppDelegate |
如上面代码所示,在 AppDelegate 中注册 KSCrash。在注册 handler 期间,通过工厂模式选择一个上传方式的 installation 进行实例化。不同的 installation 会有不同的处理,比如上面的 email 上传方式,那就需要提供邮箱地址。
不同的 installation 有不同的上床日志的方式,但是它们注册监听的方式都是一样的。它们都继承于基类 KSCrashInstallation
,在基类中有统一的 install
方法。
开启 Crash 监控
在 install
的过程中,installation 创建了一个单例对象 KSCrash
,并且调用它的 install
方法:
1 | // KSCrash.m |
它又调用了 c 方法 kscrash_install
,这个方法在 KSCrashC.c
中:
1 | /// 真正开始的方法, 创建一个 monitor |
设置回调函数 onCrash
的部分我们也后面再看。先来看设置 monitor 的部分:
1 | /// 设置 monitor |
由于前面设置了 g_installed
,因此,这里就会通过 kscm_setActiveMonitors()
这个方法激活 monitor。这是整个激活步骤的核心方法:
1 | void kscm_setActiveMonitors(KSCrashMonitorType monitorTypes) |
先来看入参,外部传入的 g_monitoring
默认值为 KSCrashMonitorTypeProductionSafeMinimal
,它的定义如下:
1 |
再来看 KSCrashMonitorTypeAll
的定义:
1 | typedef enum |
可以看到,all 代表的就是所有异常方式:
- Mach 异常
- Signal 异常
- C++ 异常
- OC 异常
- 死锁
- 用户抛出的异常
设置 monitor 的时候,会判断是否在被调试,这是通过 sysctl
获取进程的信息的方式来进行判断的:
1 | /// 是否在被调试 |
最终通过 for 循环,遍历启动每一个 monitor。启动某个 monitor 的方法如下:
1 | /// 启动某个 monitor |
Monitor
是一个结构体,它保存了 Monitor 的类型以及启动 monitor 的方法:
1 | /// monitor 的数据结构 |
也就是说,每一种异常都会有一个自己的 setEnabled()
方法,用于启动自身。
Mach 异常
开启捕获
Mach 异常在 KSCrashMonitor_MachException.c
中被处理外部调用 setEnabled()
开启 Mach 异常的监听:
1 | static void setEnabled(bool isEnabled) |
其中包含两个 c 函数,installExceptionHandler()
和 uninstallExceptionHandler()
分别用于开启和关闭监听。
在说如何捕捉 Mach 异常前,先说一下什么是 Mach。Mach:[mʌk],是一种操作系统微内核,是许多新操作系统的设计基础。
Mach微内核中有几个基础概念:
- Tasks,拥有一组系统资源的对象,允许”thread”在其中执行。每一个BSD 进程都在底层关联了一个Mach 任务对象(BSD 层在 Mach 之上,提供了更高层次的功能,比如 UNIX 进程模型,POSIX 线程模型等)
- Threads,执行的基本单位,拥有task的上下文,并共享其资源。
- Ports,task之间通讯的一组受保护的消息队列;task可对任何port发送/接收数据。
- Message,有类型的数据对象集合,只可以发送到port。
关于什么是微内核?微内核把系统服务,比如文件管理、虚拟内存、设备I/O,单独包装为一个个模块。微内核作为底层使用进程间通信收发消息。这一来大量的内核代码可以转移到用户空间,使内核变得更小。这种方式拓展性强,但是通信有效率损耗。宏内核正相反,把所有系统服务放在一起。
UNIX 下,触发到内核态需要通过系统调用触发陷阱。Mach 可以通过申请 port,然后利用IPC机制向这个port发送消息。
陷阱是软中断,是主动触发的,立刻同步处理。异常是当前指令执行出现问题,是被动的,立刻同步处理。中断是外部硬件触发的,是异步的。
回到 Mach 异常的注册方法中,我们要知道,当异常发生时,内核会向当前 task 的某个专门处理异常的 port 发消息,该消息会依次被转为 signal,NSException 抛出。如果我们要在 Mach 层捕获异常,就需要注册自己的 port,来接收这个异常:
1 | /// 创建 mach 捕捉者 |
代码很长,关键地方做了注释:
- 通过
mach_task_self()
获取当前进程对应的 task。 - 通过
task_get_exception_ports()
获取原本处理异常的 port,并保存在g_previousExceptionPorts
中。 - 通过
mach_port_allocate()
创建新的异常处理端口。 - 通过
mach_port_insert_right()
给这个新创建的端口申请权限 - 通过
task_set_exception_ports()
把异常接收的 port 设置为自己新创建的 port - 创建好 port 之后,就可以创建自己的线程去一直读取 port 上的消息了。通过
pthread_create()
创建自己的线程,以及设置好执行的方法为handleExceptions
。
处理异常
现在就是关键的处理方法 handleExceptions()
了。这也是一个非常长的方法:
1 | /// 处理 Exception |
整体的处理方式如下:
- 不停循环通过
mach_msg()
读取 port 中传来的消息。 - 读取成功后挂起所有线程。
- 清除所有的 monitor,恢复原来的 port
- 抓取所有线程的信息保存到
KSMachineContext
结构体中 - 将各种信息交给
crashContext
- 把
crashContext
抛出给外部处理方法 - 恢复所有的线程
- 通过
mach_msg()
再发出一个消息告知没有处理这个异常
通过方法 task_threads()
获取当前 task 的所有线程,通过 thread_suspend()
方法挂起某个线程:
1 | /// suspend 当前 task 内非处理 crash 或者白名单内的线程。 |
通过 task_threads()
方法获取所有的 thread:
1 | /// 获取当前 task 所有的 thread,把它保存到 context 中 |
恢复线程的时候,使用相应的 thread_resume()
方法即可:
1 | /// 恢复所有线程 |
所有 Mach 异常都在 BSD 层被 ux_exception
转换为相应的 Unix 信号,并通过 threadsignal
将信号投递到出错的线程。在 Mach 层,我们可以直接通过 Mach 异常推测出 Signal 类型:
1 | // 将 mach 转为 signal |
取消监听
在捕获到异常后就要取消原本的监听,主要分为两步:
- 将原本用作监听异常的 port 恢复
- 结束自己创建的用于处理异常的线程
1 | static void uninstallExceptionHandler() |
Signal 异常
前面说过,Mach 异常会在 BSD 层转化为相应的 UNIX 信号,投递到相应的线程中。我们同样可以捕捉相应的 Signal。
开启捕获
同样是调用 setEnabled()
方法,它会执行到 installSignalHandler()
方法中。相关的参数设置比较多,坦白来说确实不太好理解它们的作用,不过其实粗略的看下来也不影响对于主流程的理解。总的来说就是通过 sigaction()
方法记录下某个 siganl 对应的处理方法,并且保存先前的处理方法:
1 | /// 创建 signal 的捕捉者 |
fatal_signal 包括如下:
1 | static const int g_fatalSignals[] = |
处理异常
处理异常的回调方法中会返回 signal 信息,以及一个 context:
1 | static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext) |
整个流程和 Mach 异常还是非常类似的,先暂停线程,然后读取线程信息,再把 signal 信息线程信息保存到 context 中,传递给外部的处理函数。最后恢复原来的环境。
取消监听
1 | /// 取消捕捉 signal |
取消捕捉的方式和启动捕捉类似,都是通过 sigaction()
方法,不同的是,现在将原本的处理方法传回。
C++ 异常
开启捕获
通过 set_terminate()
方法设置自己的捕获函数:
1 | static void setEnabled(bool isEnabled) |
处理异常
1 | /// c++ 处理函数 |
NSException 异常
开启捕获
到了 NSException 部分,很多文章中也都有提到,先通过 NSGetUncaughtExceptionHandler()
获取原先的异常处理函数,然后再通过 NSSetUncaughtExceptionHandler()
方法设置自己的处理函数。
1 | static void setEnabled(bool isEnabled) |
处理异常
关于调用堆栈的获取都不需要通过 task 了,直接从 NSException 就可以获取到:
1 | /// 处理方法 |
DeadLock
死锁部分我就不细说了,原理和在前一篇 “iOS开发高手课” 笔记中有说明,BeeHive 中也有类似的的实现。主要是通过子线程循环往复的判断一个标志位是否在主线程中被修改过。来达到监听的效果。
总结
至此,捕获异常相关的几个方法都已经分析完了。其实过程还是非常类似的:替换原来的捕获处理,捕获到异常后保存 context 信息,暂停所有线程获取所有线程信息,恢复原本的捕获处理方法,调用统一的异常处理函数。
下一篇将介绍异常捕获后,如何分析 context 以及线程信息。