这篇文章其实想探讨一下 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将信号投递到出错的线程。
EXC_BAD_ACCESS is a Mach exception sent by the kernel to your application when you try to access memory that is not mapped for your application. If not handled at the Mach level, it will be translated into a SIGBUS or SIGSEGV BSD signal.
所以其实所有我们看到的 Unix信号异常,都是从 Mach 传过来的,只是在 Mach 没有catch,所以转成Unix给我们处理。比如 Bad Access。
所以其实总的而言,我们会处理的crash只有两种,一种是 Unix 信号的,一种是 OC 的~
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就已经可以执行监听了,就不会死锁了叭...
这里比较重要的是,其实 NSException 在没有在 app 内被 catch 消化的时候,就会让底层 Mach 把app kill掉。
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);
}
注意哦,OC 异常的监听是可以被断点的,但是之前那种内存野指针的是不会被监听到的,因为那个不属于 OC 异常,可以换用
array addObject:nil
来模拟~
于是控制台输出是酱紫的:
※ 多个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信息是完整准确的,不会受应用层的胡来而影响,可作为排查问题的参考线索。
Reference: