这篇文章其实想探讨一下 crash 都有哪些种类,以及如何解决酱紫,感觉自己之前好像有浅谈过log(https://www.jianshu.com/p/2df1418dd238),其实主要是这周给小姐姐看一个bug的时候觉得还是应该总结一下~
Exception Source
其实异常的来源有三种,分别是kernel、其他进程、以及App本身。
因此,crash异常也分为三种:
Mach异常:是指最底层的内核级异常。用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常。
Unix信号:又称BSD 信号,如果开发者没有捕获Mach异常,则会被host层的方法ux_exception()将异常转换为对应的UNIX信号,并通过方法threadsignal()将信号投递到出错线程。可以通过方法signal(x, SignalHandler)来捕获signal。
NSException:应用级异常,它是未被捕获的Objective-C异常,导致程序向自身发送了SIGABRT信号而崩溃,是app自己可控的,对于未捕获的Objective-C异常,是可以通过try catch来捕获的,或者通过NSSetUncaughtExceptionHandler()机制来捕获。
1. Mach异常
但是类,其实一般我们不会看到 Mach 异常有木有,因为其实如果 Mach 异常没有被捕获,它会被转为 Unix信号的~
Mach是一个XNU的微内核核心,Mach异常是指最底层的内核级异常 。每个thread,task,host都有一个异常端口数组,Mach的部分API暴露给了用户态,用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常,抓取Crash事件。
所有Mach异常未处理,它将在host层被ux_exception转换为相应的Unix信号,并通过threadsignal将信号投递到出错的线程。
所以其实所有我们看到的 Unix信号异常,都是从 Mach 传过来的,只是在 Mach 没有catch,所以转成Unix给我们处理。比如 Bad Access。
Q:那么这里会不会有个问题,既然 Unix 信号都是由 Mach Exception 转化的,为啥还要转呢,直接传 Mach 的异常不就行了?
A:其实这个是操作系统层面的问题,操作系统规定了一系列的标准,Unix也需要符合这个POSIX标准,所以无论是 Mac 手机还是 iPhone 都需要将 Mach 内核的异常转成 Unix 信号,作为一个 common 接口叭。这个其实也是一种适配器模式叭。
Q:那么如果我们想做 Crash 上报的库之类的,应该是监听Unix还是Mach异常呢?
A:按理说是优先监听Mach的,毕竟它会比较早的抛出来。而且如果Mach异常的handler让程序exit了,那么Unix信号就永远不会到达这个进程了。
Q:为什么第三方库PLCrashReporter即使在优选捕获Mach异常的情况下,也放弃捕获Mach异常EXC_CRASH,而选择捕获与之对应的SIGABRT信号?
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.
A:大部分捕获异常的库都用的 Mach + Unix信号 监听的方式,但是EXC_CRASH
是通过 Unix 信号监听的。这个其实我并木有特别理解为啥会死锁,猜测是因为其实我们日常的 OC Exception 在没有自己catch的时候其实会给 Mach 发个指令,让它 kill app,这个时候的 Mach 也会发出一个异常,就是EXC_CRASH
,那么如果我们告诉 mach 要 kill app 是需要等待 mach 发异常通知回来的话,那么 app 监听 Mach 异常的地方就没有办法被执行,因为主线程在等 Mach kill,而 Mach 异常在等它的监听者都执行完。(这段纯属瞎扯)
然是如果是监听 unix 信号,因为其实已经是 Mach 的部分执行完抛给了 unix,app就已经可以执行监听了,就不会死锁了叭...
iOS系统自带的 Apple’s Crash Reporter 记录在设备中的Crash日志,Exception Type项通常会包含两个元素: Mach异常 和 Unix信号。
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x041a6f3
因此,EXC_BAD_ACCESS (SIGSEGV)
表示的意思是:Mach层的EXC_BAD_ACCESS
异常,在host层被转换成SIGSEGV
信号投递到出错的线程。既然最终以信号的方式投递到出错的线程,那么就可以通过注册signalHandler来捕获信号:
signal(SIGSEGV,signalHandler); // 监听 Unix 信号
有个异常对应表挺好的可以参考:https://juejin.cn/post/6860022809646039053
※ 如何监听 Mach 异常呢?
#import <mach/mach.h>
+ (void)createAndSetExceptionPort {
mach_port_t server_port;
kern_return_t kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &server_port);
assert(kr == KERN_SUCCESS);
NSLog(@"create a port: %d", server_port);
kr = mach_port_insert_right(mach_task_self(), server_port, server_port, MACH_MSG_TYPE_MAKE_SEND);
assert(kr == KERN_SUCCESS);
kr = task_set_exception_ports(mach_task_self(), EXC_MASK_BAD_ACCESS | EXC_MASK_CRASH, server_port, EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES, THREAD_STATE_NONE);
[self setMachPortListener:server_port];
}
+ (void)setMachPortListener:(mach_port_t)mach_port {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
mach_msg_header_t mach_message;
mach_message.msgh_size = 1024;
mach_message.msgh_local_port = mach_port;
mach_msg_return_t mr;
while (true) {
mr = mach_msg(&mach_message,
MACH_RCV_MSG | MACH_RCV_LARGE,
0,
mach_message.msgh_size,
mach_message.msgh_local_port,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
if (mr != MACH_MSG_SUCCESS && mr != MACH_RCV_TOO_LARGE) {
NSLog(@"error!");
}
mach_msg_id_t msg_id = mach_message.msgh_id;
mach_port_t remote_port = mach_message.msgh_remote_port;
mach_port_t local_port = mach_message.msgh_local_port;
NSLog(@"Receive a mach message:[%d], remote_port: %d, local_port: %d",
msg_id,
remote_port,
local_port);
abort();
}
});
}
// 构造BAD MEM ACCESS Crash
- (void)makeCrash {
NSLog(@"********** Make a [BAD MEM ACCESS] now. **********");
*((int *)(0x1234)) = 122;
}
注意哦其实这里在没有发生异常的时候是block的,等有异常才会走到下一步哦~
所以有木有那位大佬知道为啥监听 EXC_CRASH 会死锁啊?明明监听的地方是其他线程啊... 懵逼树下懵逼果,懵逼果里你和我...
EXC_BAD_ACCESS 的处理
一般都是多线程造成的,某一个线程在操作一个对象时,另一个线程将此对象释放,此时就有可能造成野指针的问题。一种解决办法是如果都是UI操作则将这些操作都放在主线程去执行。
哪些情况会触发野指针异常呢?总的而言都是对内存的处理:
- 多线程对同一块内存读写
- 向已经释放的对象发送消息
贴个代码大家可以试试,包含两种由autoreleasepool
引发的向释放对象发消息导致的bad access哈:
- (void)testAutoRelease
{
__autoreleasing UIView* myView;
@autoreleasepool {
myView = [UIView new];
NSLog(@"inside autoreleasepool myView:%@", myView);
}
NSLog(@"outside autoreleasepool myView:%@", myView);
}
- (void)testAutoRelease2 {
NSError *error; //尽管这里默认是strong,但是downloadUrl函数里给error赋值的时候会根据函数的形参的修饰符来去决定是__strong还是__autorelease
[self downloadUrl:@"http://xxx.png" error:&error];
NSLog(@"error:%@", error); //crash,EXC_BAD_ACCESS
}
- (void)downloadUrl:(NSString*)url error:(NSError**)error {//这里的NSError*默认是autorelease的,相当于(NSError * __autorelease *)error, 要解决这个问题可以强制把它变成strong的,如(NSError* __strong*)error
@autoreleasepool {
*error = [[NSError alloc] init];
}
}
常用的解决方式有哪些呢?
比如Zombies;选择Product->Analyze,或者快捷方式Shift+Command+B,来启用Xcode对你的项目的分析等。
这里有个MRC环境下的野指针可以参考:https://blog.csdn.net/moto0421/article/details/84243272
2. Unix signal
Unix Signal 其实是由 Mach port 抛出的信号转化的,那么都有哪些信号呢?
SIGHUP
本信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联。SIGINT
程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。SIGQUIT
和SIGINT类似, 但由QUIT字符(通常是Ctrl-)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。SIGABRT
调用abort函数生成的信号。
SIGABRT is a BSD signal sent by an application to itself when an NSException or obj_exception_throw is not caught.
SIGBUS
非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。它与SIGSEGV的区别在于后者是由于对合法存储地址的非法访问触发的(如访问不属于自己存储空间或只读存储空间)。SIGFPE
在发生致命的算术运算错误时发出. 不仅包括浮点运算错误, 还包括溢出及除数为0等其它所有的算术的错误。SIGKILL
用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。SIGSEGV
试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据.SIGPIPE
管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。
我们这里看下一下是怎么监听它们好啦:
#include <execinfo.h>
void InstallSignalHandler(void) {
signal(SIGHUP, handleSignalException); // 注册监听
signal(SIGINT, handleSignalException);
signal(SIGQUIT, handleSignalException);
signal(SIGABRT, handleSignalException);
signal(SIGILL, handleSignalException);
signal(SIGSEGV, handleSignalException);
signal(SIGFPE, handleSignalException);
signal(SIGBUS, handleSignalException);
signal(SIGPIPE, handleSignalException);
}
void handleSignalException(int signal) {
NSMutableString * crashInfo = [[NSMutableString alloc]init];
[crashInfo appendString:[NSString stringWithFormat:@"signal:%d\n",signal]];
[crashInfo appendString:@"Stack:\n"];
void* callstack[128];
int i, frames = backtrace(callstack, 128);
char** strs = backtrace_symbols(callstack, frames);
for (i = 0; i <frames; ++i) {
[crashInfo appendFormat:@"%s\n", strs[i]];
}
NSLog(@"%@", crashInfo);
}
// 构造BAD MEM ACCESS Crash
- (void)makeCrash {
NSLog(@"********** Make a [BAD MEM ACCESS] now. **********");
*((int *)(0x1234)) = 122;
}
unix监听和mach的不太一样,mach的如果打断点在监听的地方,crash的时候是可以走到断点的,但是unix signal不会。于是只能不用调试的方式看控制台的输出啦:
signal的ID可以参考这个:https://blog.csdn.net/github_33873969/article/details/77744382
3. Objective-C Exception
非主线程刷新UI
NSInvalidArgumentException
非法参数异常(NSInvalidArgumentException)是 Objective – C 代码最常出现的错误,所以平时在写代码的时候,需要多加注意,加强对参数的检查,避免传入非法参数导致异常,其中尤以nil参数为甚。
- 集合数据的参数传递
比如NSMutableArray, NSMutableDictionary的数据操作
(1) NSDictionary不能删除nil的key
(2) NSDictionary不能添加nil的对象
(3) 不能插入nil的对象
(4) 其他一些nil参数 - 其他一些API的使用
APP一般都会有网络操作,免不了使用网络相关接口,比如NSURL的初始化,不能传入nil的http地址: - 未实现的方法
(1) .h文件里函数名,却忘了修改.m文件里对应的函数名
(2) 使用第三方库时,没有添加”-ObjC” flag
(3) MRC时,大部分情况下是因为对象被提前release了,在你心里不希望他release的情况下,指针还在,对象已经不在 了。
- NSRangeException
越界异常(NSRangeException)也是比较常出现的异常,有如下几种类型:
- 数组最大下标处理错误
比如数组长度count, index的下标范围[0, count -1], 在开发时,可能index的最大值超过数组的范围; - 下标的值是其他变量赋值
这样会有很大的不确定性, 可能是一个很大的整数值 - 使用空数组
如果一个数组刚刚初始化,还是空的,就对它进行相关操作
所以,为了避免NSRangeException的发生,必须对传入的index参数进行合法性检查,是否在集合数据的个数范围内。
NSGenericException
NSGenericException这个异常最容易出现在foreach操作中,在for in循环中如果修改所遍历的数组,无论你是add或remove,都会出错 “for in”,它的内部遍历使用了类似 Iterator进行迭代遍历,一旦元素变动,之前的元素全部被失效,所以在foreach的循环当中,最好不要去进行元素的修改动作,若需要修改,循环改为for遍历,由于内部机制不同,不会产生修改后结果失效的问题。NSInternalInconsistencyException
不一致导致出现的异常
比如NSDictionary当做NSMutableDictionary来使用,从他们内部的机理来说,就会产生一些错误
NSMutableDictionary *info = method return to NSDictionary type;
[info setObject:@“sxm” forKey:@”name”];
比如xib界面使用或者约束设置不当NSFileHandleOperationException
处理文件时的一些异常,最常见的还是存储空间不足的问题,比如应用频繁的保存文档,缓存资料或者处理比较大的数据:
所以在文件处理里,需要考虑到手机存储空间的问题。NSMallocException
这也是内存不足的问题,无法分配足够的内存空间
此外还有KVO Crash
移除未注册的观察者
重复移除观察者
添加了观察者但是没有实现-observeValueForKeyPath:ofObject:change:context:
方法
添加移除keypath=nil
添加移除observer=nilunrecognized selector send to instance
下面来看下如何监听 OC 的异常:
#include <execinfo.h>
void InstallUncaughtExceptionHandler(void) {
NSSetUncaughtExceptionHandler(&handleUncaughtException);
}
void handleUncaughtException(NSException *exception) {
NSString * crashInfo = [NSString stringWithFormat:@"yyyy Exception name:%@\nException reason:%@\nException stack:%@",[exception name], [exception reason], [exception callStackSymbols]];
NSLog(@"%@", crashInfo);
}
于是控制台输出是酱紫的:
※ 多个Crash日志收集服务共存的坑
是的,在自己的程序里集成多个Crash日志收集服务实在不是明智之举。通常情况下,第三方功能性SDK都会集成一个Crash收集服务,以及时发现自己SDK的问题。当各家的服务都以保证自己的Crash统计正确完整为目的时,难免出现时序手脚,强行覆盖等等的恶意竞争,总会有人默默被坑。
1)拒绝传递 UncaughtExceptionHandler
如果同时有多方通过NSSetUncaughtExceptionHandler
注册异常处理程序,和平的作法是:后注册者通过NSGetUncaughtExceptionHandler
将先前别人注册的handler取出并备份,在自己handler
处理完后自觉把别人的handler
注册回去,规规矩矩的传递。不传递强行覆盖的后果是,在其之前注册过的日志收集服务写出的Crash日志就会因为取不到NSException
而丢失Last Exception Backtrace
等信息。(P.S. iOS系统自带的Crash Reporter不受影响)
在开发测试阶段,可以利用 fishhook 框架去hookNSSetUncaughtExceptionHandler
方法,这样就可以清晰的看到handler的传递流程断在哪里,快速定位污染环境者。不推荐利用调试器添加符号断点来检查,原因是一些Crash收集框架在调试状态下是不工作的。
2)Mach异常端口换出+信号处理Handler覆盖
和NSSetUncaughtExceptionHandler的情况类似,设置过的Mach异常端口和信号处理程序也有可能被干掉,导致无法捕获Crash事件。
3)影响系统崩溃日志准确性
应用层参与收集Crash日志的服务方越多,越有可能影响iOS系统自带的Crash Reporter。由于进程内线程数组的变动,可能会导致系统日志中线程的Crashed 标签标记错位,可以搜索abort()等关键字来复查系统日志的准确性。 若程序因NSException而Crash,系统日志中的Last Exception Backtrace信息是完整准确的,不会受应用层的胡来而影响,可作为排查问题的参考线索。