简介
一、什么是Crash?
对于用户来说直观的感觉是崩溃闪退。
Crash常见的原因有哪些?
-
CPU无法执行的代码
-
语言层面抛出异常
-
被系统强杀
-
应用内存消耗过高OOM
-
主线程长时间无法响应ANR
-
资源异常
- 线程频繁唤醒
- 进程中的线程过多的占用了CPU,限制为50%,时间不超过180秒
- 线程短时间过多的磁盘写入
-
死锁
-
非法的应用签名
-
后台执行超时
-
设备总内存紧张
-
设备过热
-
-
开发者断言
如何分类?
一、系统层面
Mach异常
iOS基于ARM架构,分层如下:
Darwin的内核是XNU,XNU是兼具宏内核和微内核特性的混合内核,架构分层如下:
Mach微内核负责进程和线程抽象、虚拟内存管理、任务管理以及进程间通信和消息传递机制。
Mach微内核的主要抽象
- Tasks
- Threads
- Address space
- Memory objects
- IPC
- Time
关于Mach port
Mach Port与UNIX单向管道类似,是由内核管理的消息队列。有多个发送方和一个接收方。
-
Port
-
Port权限,Task信息是系统资源的集合,也可以说是资源的所有权。这些Task允许您访问Port(发送,接收,发送一次),称为Port权限。(也就是说,Port权限是Mach的基本安全机制。)
- 发送权限,不受限制地将数据插入到特定的消息队列中
- 一次发送权限,将单个消息数据插入到特定的消息队列中
- 接收权限,不受限制地从特定消息队列中提取数据
-
Port Set,一组有权限的端口,在接收来自某个成员的消息或事件时,可以将其视为单个单元。
-
Port Set权限
-
Message
中断
中断是一个硬件或者软件发出的请求,要求CPU暂停当前的工作去处理更加重要的事情。
比如你在输入的时候,不断地敲击键盘,CPU如何得知这一点的呢?
轮询(Poll)
CPU每隔一小段时间(几十或几百毫秒)去询问键盘是否有键按下,大部分轮询都会获得“没有键按下”的回应,这样操作就被浪费掉了。
中断(Interrupt)
还有一种方法是CPU不去理睬键盘,而当键盘上有键被按下时,键盘上的芯片发送一个信号给CPU,CPU收到信号之后,再去询问键盘被按下的是哪个键。
中断是重要的异步处理事件机制,否则对于外设需要通过轮询来确认外设的事件,造成CPU的浪费。中断的引入让外设能够“主动”通知操作系统,及打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后再恢复操作系统和应用的正常执行,同时也是实现进程/线程抢占式调度的一个重要基石。(不管是用户态还是内核态)程序运行时,若中断发生就会打断现在的程序,进行上下文切换转入内核态并进入中断服务程序,中断服务程序执行完成后恢复被打断的程序继续执行。
中断的类型及中断服务程序由“中断向量表”来决定,在系统启动时由内核负责加载这个向量。中断类型分为:
- 中断(Interrupt)
- 陷阱(Trap)
- 故障(Fault)
- 终止(Abort)
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
| 陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
| 故障 | 潜在可恢复的错误 | 同步 | 总是返回到下一条指令 |
| 终止 | 不可恢复的错误 | 同步 | 不会返回 |
中断:是来自处理器外部的I/O异常信号导致的,不是由任何一条专门的指令造成的,从这个层面上来讲,它是异步的。硬件中断的处理程序通常被称为中断处理程序。比如:插拔U盘、键盘输入。
陷阱:是指有意的异常,是执行一条指令的结果。和中断一样,陷阱处理程序将控制返回到下一条指令。陷阱最终的用途,是在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。比如用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个进程(fork)、加载一个新的程序(execve)或者终止当前进程(exit),这些操作都需要通过触发陷进异常,来执行系统内核的程序来实现。
故障:是由错误情况引起的,它可能被故障处理程序修正。当故障发生时,处理器将控制转移个故障处理程序。一般保护性故障,Unix不尝试修复,而是将这种保护性故障报告为段违规(Segmentation Violation)对应信号为SIGSEGV,然后终止程序。
终止:由不可恢复的致命错误造成的结果。如:奇偶校验错误。
中断中的异常和崩溃有何关系?
中断分类
硬件异常
程序的崩溃都会转换为异常被CPU通过中断向量表指定的异常类型捕获,进而触发异常处理程序处理,比如CPU无效指令、无效的地址或者无权限的访问。被系统强杀的崩溃最终会调用到kill函数发送SIGKILL信号而引发应用被杀。
软件异常
语言及开发者触发崩溃最终会通过abort函数,依然会最终调用到kill函数发送SIGABRT信号,引发应用被杀。
整个异常机制是构建在Mach异常之上,所有的硬件/软件异常都会首先转换成Mach异常,进而转换为信号。
UNIX信号
Mach异常处理机制提供了底层的异常处理,为了兼容POSIX,Mach异常会被转换为UNIX信号。
- SIGABRT,Signal Abort
- SIGILL, Signal Illegal
- SIGSEGV,Signal Segmentation Violation
- SIGFPE,Signal Floating Point Exception
- SIGINT,Signal Interrupt
- SIGTERM,Signal Terminate
Mach异常如何转换成UNIX信号
为什么捕捉Mach异常还需要捕捉UNIX信号
Mach异常和UNIX信号都可以被捕获,他们也几乎一一对应,但我们需要优先处理Mach异常,因为Mach异常更接近底层,而UNIX信号存在被Mach默认异常处理函数直接退出进程而无法生成的情况。
有一些异常如EXC_CRASH,在Mach异常的阶段没有捕捉,而是放到UNIX信号中捕捉,其中的解释在PLCrashReporter的注释中有详细的解释:
/* We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception
* to denote SIGABRT termination. In that case, catching the Mach exception in-process leads to process deadlock
* in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for
* EXC_CRASH. */
二、语言层面
Objective-C语言抛出异常/NSException
Objective-C抛出的具体异常如下:
/*************** Generic Exception names ***************/
FOUNDATION_EXPORT NSExceptionName const NSGenericException;
FOUNDATION_EXPORT NSExceptionName const NSRangeException;
FOUNDATION_EXPORT NSExceptionName const NSInvalidArgumentException;
FOUNDATION_EXPORT NSExceptionName const NSInternalInconsistencyException;
FOUNDATION_EXPORT NSExceptionName const NSMallocException;
FOUNDATION_EXPORT NSExceptionName const NSObjectInaccessibleException;
FOUNDATION_EXPORT NSExceptionName const NSObjectNotAvailableException;
FOUNDATION_EXPORT NSExceptionName const NSDestinationInvalidException;
FOUNDATION_EXPORT NSExceptionName const NSPortTimeoutException;
FOUNDATION_EXPORT NSExceptionName const NSInvalidSendPortException;
FOUNDATION_EXPORT NSExceptionName const NSInvalidReceivePortException;
FOUNDATION_EXPORT NSExceptionName const NSPortSendException;
FOUNDATION_EXPORT NSExceptionName const NSPortReceiveException;
FOUNDATION_EXPORT NSExceptionName const NSOldStyleException;
FOUNDATION_EXPORT NSExceptionName const NSInconsistentArchiveException;
其中NSRangeException,NSInvalidArgumentException等比较常见。
NSException/NSError的区别和Try Catch如何使用?
一般来说错误分为两种情况。
错误(error)
一种是可以预见的,可恢复。
异常(exception)
不可预见,不可恢复。
例如,文件无法打开是错误,数据访问越界是异常。
在Objective-C中,这两种错误分别以NSError和NSException表示。
Swift中把Objective-C中原本使用的NSError的代码标记成throws。原本会抛出异常的代码则使用fatalError直接崩溃。Swift中的try catch实际是使用Error protocol处理Error。
Objective-C中@try @catch是catch NSException的异常。不推荐这么做,因为Objective-C被设计为不能从异常中恢复,所以ARC默认不保证异常安全,抛出异常可能导致内存泄漏。务必只在必要的时候这样做。
C++抛出异常
iOS和OSX都会在CFRunloop中捕捉所有没有被捕捉的异常,包括C++异常。在OSX中,会通过对话框展示异常给用户,但是在iOS中,只是重新抛出异常。
为什么要捕捉C++异常?
系统在捕捉到C++异常后,如果能够将此C++异常转换成OC异常,则抛出OC异常处理机制。如果不能转换,则会立刻调用__cxa_throw重新抛出异常。
当系统在Runloop捕捉到C++异常时,此时的调用堆栈是异常发生时的堆栈,但当系统在不能转换为OC异常时调用__cxa_throw时,上层捕捉此再抛出的异常获取的调用堆栈是Runloop异常处理函数的堆栈,导致原始异常调用堆栈丢失。
Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0 libsystem_kernel.dylib 0x00007fff93ef8d46 __kill + 10
1 libsystem_c.dylib 0x00007fff89968df0 abort + 177
2 libc++abi.dylib 0x00007fff8beb5a17 abort_message + 257
3 libc++abi.dylib 0x00007fff8beb33c6 default_terminate() + 28
4 libobjc.A.dylib 0x00007fff8a196887 _objc_terminate() + 111
5 libc++abi.dylib 0x00007fff8beb33f5 safe_handler_caller(void (*)()) + 8
6 libc++abi.dylib 0x00007fff8beb3450 std::terminate() + 16
7 libc++abi.dylib 0x00007fff8beb45b7 __cxa_throw + 111
8 test 0x0000000102999f3b main + 75
9 libdyld.dylib 0x00007fff8e4ab7e1 start + 1
二、如何抓取Crash?
-
NSException异常捕获
NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);
void uncaughtExceptionHandler(NSException *exception) {
NSLog(@"uncaughtExceptionHandler %@", exception);
}
如何处理存在多个NSException异常捕获的问题?
先使用NSGetUncaughtExceptionHandler,保存Handler,之后在处理完成的时候,还原保存好的Handler句柄。
-
C++异常捕获
为了获得C++异常的调用堆栈,我需要模拟抛出NSException的过程并在此过程中保存调用堆栈。
- 设置异常处理函数
g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
调用std::set_terminate设置新的全局终止处理函数并保存旧的函数。
- 重写__cxa_throw
void __cxa_throw(void* thrown_exception, std::type_info* tinfo, void (*dest)(void*))
在异常发生时,会先进入此重写函数,应该先获取调用堆栈并存储。再调用原始的__cxa_throw函数。
- 异常处理函数
__cxa_throw往后执行,进入set_terminate设置的异常处理函数。判断如果检测是OC异常,则什么也不做,让OC异常机制处理。否则获取异常信息
-
Unix异常信号捕获
1. signal
#include <signal.h>
signal(int, void (*)(int));
//第一个参数是我们要捕获的信号类型,第二个参数是我们要绑定的信号处理函数。
示例
signal(SIGABRT, signalHandler);
signal(SIGILL, signalHandler);
signal(SIGSEGV, signalHandler);
void signalHandler(int signal) {
NSLog(@"signalHandler %d", signal);
}
2. sigaction
#include <signal.h>
int sigaction(int, const struct sigaction * __restrict, struct sigaction * __orestrict);
调试中让应用直接崩溃产生崩溃日志的话,可以在工程中Edit Scheme中,关闭Debug executable选项。
Xcode Debug模式运行App时,App进程signal被LLDB Debugger调试器捕获,需要使用LLDB调试命令将指定Signal信号抛到用户层处理。
查看全部信号传递配置:
// process handle缩写
pro hand
修改指定信号传递配置:
// option:
// -P: PASS
// -S: STOP
// -N: NOTIFY
pro hand -option false 信号名
// 例:SIGABRT信号处理在LLDB不停止,可继续抛到用户层
pro hand -s false SIGABRT
-
Mach异常信号捕获
mach message主要发送流程
mach_port_allocate();
//内核中创建一个消息队列,获取对应的port
mach_port_insert_right();
//授予Task对port的指定权限
mach_msg();
//通过设定MACH_RCV_MSG/MACH_SEND_MSG用于接收/发送mach message
Mach异常捕获主要流程
保存之前的异常端口
kr = task_get_exception_ports(thisTask,
mask,
g_previousExceptionPorts.masks, &g_previousExceptionPorts.count, g_previousExceptionPorts.ports, g_previousExceptionPorts.behaviors, g_previousExceptionPorts.flavors);
创建mach_port
kr = mach_port_allocate(thisTask,
MACH_PORT_RIGHT_RECEIVE,
&g_exceptionPort);
赋予Task对port的指定权限
kr = mach_port_insert_right(thisTask,
g_exceptionPort,
g_exceptionPort,
MACH_MSG_TYPE_MAKE_SEND);
把port作为异常处理handler
kr = task_set_exception_ports(thisTask,
mask,
g_exceptionPort,
(int)(EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES),
THREAD_STATE_NONE);
创建异常处理线程
error = pthread_create(&g_primaryPThread,
&attr,
&handleExceptions,
(void*)kThreadPrimary);
停止异常监控的时候,还原为原先的exception ports
kr = task_set_exception_ports(thisTask,
g_previousExceptionPorts.masks[i],
g_previousExceptionPorts.ports[i],
g_previousExceptionPorts.behaviors[i],
g_previousExceptionPorts.flavors[i]);
-
Runloop线程保活
- (void)handleException: (NSException *)exception {
CFRunLoopRef runloop = CFRunLoopGetMain();
CFArrayRef allModes = CFRunLoopCopyAllModes(runloop);
bool runloopStop = false;
while (!runloopStop) {
for (NSString *mode in (__bridge NSArray *)allModes) {
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}
CFRelease(allModes);
NSSetUncaughtExceptionHandler(NULL);
signal(SIGABRT, SIG_DFL);
signal(SIGILL, SIG_DFL);
signal(SIGSEGV, SIG_DFL);
signal(SIGFPE, SIG_DFL);
signal(SIGBUS, SIG_DFL);
signal(SIGPIPE, SIG_DFL);
}
三、流行方案是如何设计的?
PLCrashReporter
KSCrash
四、结合自身业务该如何设计或者选型?
Crash监控是从SDK捕获异常,获取堆栈信息,添加自定义业务信息,上报Crash信息。
从数据处理侧,需要设计数据存储,跟CICD结合上传符号表,对堆栈做符号化解析,有分钟级、小时级的数据处理支持。
从平台侧,需要分类和聚合Crash,需要有整体Crash率折线图,有Crash的分配、跟进、处理和衡量业务线指标,Crash告警等功能。
-
Firebase、Bugly和友盟等一体化解决方案
优点:技术投入成本低,双端统一,数据不敏感时适合使用。
缺点:数据处理不灵活,跟业务结合程度较低。
-
PLCrashReporter、KSCrash等第三方Crash收集方案
优点:客户端投入成本低,平台侧需要自己开发,可以为其他功能提供堆栈获取能力。
缺点:需要设计数据存储,符号化解析,平台侧展示以及数据聚合。
-
自己开发
优点:灵活程度高,可跟业务结合紧密。
缺点:客户端投入成本高,需要设计数据存储,符号化解析,平台侧展示以及数据聚合。