iOS开发中,解决Crash相信是开发者最为头疼的问题了,特别是对于已上线的应用,对其Crash的跟踪和修复显得尤其重要,本文主要总结了常见的Crash类型以及主流的Crash日志收集及解析的解决方案。
- 本文主要目录:
-
Objective-C Exception
** 常见的OC异常
** OC异常的抓取和分析 -
Mach Exception
** Unix Signal Exception -
Crash日志收集
** Crash日志收集方式
** Crash日志收集的冲突 -
堆栈符号解析
** Crash收集上报的堆栈信息解析
** 苹果自带Crash日志上报的堆栈信息解析 - 野指针Crash的解决方案
- 参考文章
Crash分为两种,未捕获的Objective-C异常和Mach异常。
一、Objective-C Exception
在OC层面(iOS库、第三方库出现错误抛出)的异常称为OC异常。
比如:
NSArray * array= @[@“s",@“x",@“m"];
[array objectAtIndex:4];
OC异常可以用try-catch抓住:
@try {
NSArray * array= @[@“s",@“x",@“m"];
[array objectAtIndex:4];
} @catch (NSException *exception) {
NSLog(@"%@",exception);
}
使用此方法可以抓到当前抛出的异常并阻止程序崩溃,然而苹果爸爸并不推荐这样去做。
-[__NSArrayI objectAtIndex:]: index 4 beyond bounds [0 .. 2]
常见的OC异常
以下内容来自文章《iOS开发质量的那些事》
从上图可以看到,iOS开发中常见的异常包括以下几种:
NSInvalidArgumentException
NSRangeException
NSGenericException
NSInternalInconsistencyException
NSFileHandleOperationException
NSInvalidArgumentException
非法参数异常(NSInvalidArgumentException)是 Objective - C 代码最常出现的错误,所以平时在写代码的时候,需要多加注意,加强对参数的检查,避免传入非法参数导致异常,其中尤以nil参数为甚。
1. 集合数据的参数传递
比如NSMutableArray, NSMutableDictionary的数据操作
(1) NSDictionary不能删除nil的key
(2) NSDictionary不能添加nil的对象
(3) 不能插入nil的对象
(4) 其他一些nil参数
2. 其他一些API的使用
APP一般都会有网络操作,免不了使用网络相关接口,比如NSURL的初始化,不能传入nil的http地址:
3. 未实现的方法
(1) .h文件里函数名,却忘了修改.m文件里对应的函数名
(2) 使用第三方库时,没有添加”-ObjC” flag
(3) MRC时,大部分情况下是因为对象被提前release了,在你心里不希望他release的情况下,指针还在,对象已经不在 了。
NSRangeException
越界异常(NSRangeException)也是比较常出现的异常,有如下几种类型:
1. 数组最大下标处理错误
比如数组长度count, index的下标范围[0, count -1], 在开发时,可能index的最大值超过数组的范围;
2. 下标的值是其他变量赋值
这样会有很大的不确定性, 可能是一个很大的整数值
3. 使用空数组
如果一个数组刚刚初始化,还是空的,就对它进行相关操作
所以,为了避免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
这也是内存不足的问题,无法分配足够的内存空间
OC异常的抓取和分析
在debug环境下,OC异常导致崩溃时Xcode控制台会输出完整的异常信息,比如:
Terminating app due to uncaught exception ‘NSRangeException’, reason: ‘this is reason description’
,包括Exception的类型、原因和发生异常的完整堆栈。
这些信息一般来说都足够详细,足够我们轻易地找到异常的位置并进行修复。
非debug环境下,可以通过注册 NSUncaughtExceptionHandler 捕获异常信息。虽然无法阻止APP崩溃,但是可以获取异常信息并进行收集,下次启动APP时进行上报,方便开发者进行错误跟踪及修复,这就是常用Crash收集工具所做的事情。
void InstallUncaughtExceptionHandler(void) {
NSSetUncaughtExceptionHandler(&handleUncaughtException);
}
void handleUncaughtException(NSException *exception) {
NSString * crashInfo = [NSString stringWithFormat:@"Exception name:%@\nException reason:%@\nException stack:%@",[exception name], [exception reason], [exception callStackSymbols]];
[WZCrashReporter saveCrash:crashInfo];
}
Mach Exception
Mach异常是指最底层的内核级异常。
最常见的Mach异常:EXC_BAD_ACCESS (Bad Memory Access)
这种内存访问异常分为访问非法地址(SIGBUS信号)和访问了被回收掉的内存(SIGSEGV信号),实际开发中遇到的错误通常令人莫名其妙,往往需要大量时间来排查,非常头疼。
EXC_BAD_ACCESS后面通常带有code来帮助我们判断到底是什么错误,比如EXC_I386_GPFLT指访问了一块已经不属于你的内存。
一些其他的Mach异常:
- EXC_BAD_INSTRUCTION运行了非法的指令,往往是运行指令的参数不对(0或者nil的参数)
- EXC_RESOURCE程序资源上限(cpu占用过高或者内存不足)。
- EXC_GUARD一些C函数访问错误导致的异常。
- 0x00000020奇怪异常集合,常见的是由于主线程阻塞看门狗杀死了APP《Exception Type: 00000020:什么是看门狗机制》
Unix Signal Exception
从Mach异常最终会转化成Unix信号投递到出错的线程(具体原理可以学习《漫谈iOS Crash收集框架》, 各种信号的含义可以学习《iOS异常捕获》。
- OC异常并不是真正的异常,但是当一个OC异常被抛出到最外层还没被捕获,程序会强行发送SIGABRT信号中断程序。
- Mach异常没有比较便利的捕获方式,既然它最终会转化成信号,我们也可以通过捕获信号,来捕获 Crash 事件。
iOS提供了signal方法来注册一个处理函数,在处理函数中,使用execinfo中的 backtrace_symbols取出汇编层程序的堆栈信息。
代码如下:
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]];
}
[WZCrashReporter saveCrash:crashInfo];
}
下面是一些常用信号代表的含义:
(1) SIGHUP
本信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联。
登录Linux时,系统会分配给登录用户一个终端(Session)。在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个 Session。当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号。这个信号的默认操作为终止进程,因此前台进 程组和后台有终端输出的进程就会中止。不过可以捕获这个信号,比如wget能捕获SIGHUP信号,并忽略它,这样就算退出了Linux登录, wget也 能继续下载。
此外,对于与终端脱离关系的守护进程,这个信号用于通知它重新读取配置文件。
(2) SIGINT
程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。
(3) SIGQUIT
和SIGINT类似, 但由QUIT字符(通常是Ctrl-)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。
(6) SIGABRT
调用abort函数生成的信号。
(7) SIGBUS
非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。它与SIGSEGV的区别在于后者是由于对合法存储地址的非法访问触发的(如访问不属于自己存储空间或只读存储空间)。
(8) SIGFPE
在发生致命的算术运算错误时发出. 不仅包括浮点运算错误, 还包括溢出及除数为0等其它所有的算术的错误。
(9) SIGKILL
用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。
(11) SIGSEGV
试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据.
(13) SIGPIPE
管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。此外用Socket通信的两个进程,写进程在写Socket的时候,读进程已经终止。
Crash日志收集
Crash日志收集方式
1.苹果Crash收集服务
新版iTunes Connect已不能看到APP的crash日志,只能在XCode 中Window->Organizer->Crashes可以看到crash日志。
当程序运行Crash的时候,系统会把运行的最后时刻的运行信息记录下来,存储到一个文件中,也就是我们所说的Crash文件,但收集crash功能需要用户设置->隐私->诊断与用量->诊断与用量数据选择自动发送,并与开发者共享,由于不是所有用户都会把这个功能打开,所以并不能保证收集到所有的Crash信息,推荐指数三颗星。
2.自行实现Crash收集及上报框架
实现原理上面已详细描述,适合人手充足,技术储备足够的团队使用,推荐指数五颗星。
3.第三方crash收集服务
腾讯bugly、友盟等Crash收集服务比较完善,作为开发者省心省力,适合个人或者对隐私性要求不高的团队使用,推荐指数五颗星。
Crash日志收集的冲突
以下内容来自网络文章
在我们自己研发 Crash 收集框架之前,最早肯定都会接入腾讯 Bugly、友盟等第三方日志框架来进行崩溃的收集和分析。如果多个 Crash 收集框架存在时,往往会存在冲突。
不管是对于 Signal 捕获还是 NSException 捕获都会存在 handler 覆盖的问题,正确的做法应该是先判断是否有前者已经注册了 handler,如果有则应该把这个 handler 保存下来,在自己处理完自己的 handler 之后,再把这个 handler 抛出去,供前面的注册者处理。
typedef void (*SignalHandler)(int signo, siginfo_t *info, void *context);
static SignalHandler previousSignalHandler = NULL;
+ (void)installSignalHandler {
struct sigaction old_action;
sigaction(SIGABRT, NULL, &old_action);
if (old_action.sa_flags & SA_SIGINFO) {
previousSignalHandler = old_action.sa_sigaction;
}
LDAPMSignalRegister(SIGABRT);
// .......
}
static void LDAPMSignalRegister(int signal) {
struct sigaction action;
action.sa_sigaction = LDAPMSignalHandler;
action.sa_flags = SA_NODEFER | SA_SIGINFO;
sigemptyset(&action.sa_mask);
sigaction(signal, &action, 0);
}
static void LDAPMSignalHandler(int signal, siginfo_t* info, void* context) {
// 获取堆栈,收集堆栈
........
LDAPMClearSignalRigister();
// 处理前者注册的 handler
if (previousSignalHandler) {
previousSignalHandler(signal, info, context);
}
}
上面的是一个处理 Signal handler 冲突的大概代码思路,下面是 NSException handler 的处理思路,两者大同小异。
static NSUncaughtExceptionHandler *previousUncaughtExceptionHandler;
static void LDAPMUncaughtExceptionHandler(NSException *exception) {
// 获取堆栈,收集堆栈
// ......
// 处理前者注册的 handler
if (previousUncaughtExceptionHandler) {
previousUncaughtExceptionHandler(exception);
}
}
+ (void)installExceptionHandler {
previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&LDAPMUncaughtExceptionHandler);
}
堆栈符号解析
分为两种情景讨论:
1、Crash收集上报的堆栈信息解析
这种信息一般还原度比较高,基本上都能给出崩溃的具体定位信息,一些需要解析堆栈符号的解决方法如下:
以下内容来自文章《iOS异常捕获-堆栈信息的解析》
异常信息有三种类型:
1.已标记错误位置的:
test 0x000000010bfddd8c -[ViewController viewDidLoad] + 8588
- 这种信息已经很明确了,不用解析
2.有模块地址的情况:
test 0x00000001018157dc 0x100064000 + 24844252
以上面为例子,从左到右依次是:
二进制库名(test),调用方法的地址(0x00000001018157dc),模块地址(0x100064000)+偏移地址(24844252)
3.无模块地址的情况:
test 0x00000001018157dc test + 24844252
解析堆栈信息
dSYM符号表获取,xcode->window->organizer->右键你的应用 show finder->右键.xcarchive 显示包内容->dSYMs->test.app.dYSM
然后使用atos命令来符号化某个特定模块加载地址
atos [-arch 架构名] [-o 符号表] [-l 模块地址] [方法地址]
使用终端,进到test.app.dYSM所在目录
一.如果是有模块地址的情况,运行:
atos -arch arm64 -o test.app.dSYM/Contents/Resources/DWARF/test -l 0x100064000 0x00000001018157dc
二.如果是无模块地址的情况
1.先将偏移地址转为16进制:
24844252 = 0x17B17DC
2.然后用方法的地址-偏移地址,得到的就是模块地址
0x00000001018157dc - 0x17B17DC = 0x100064000
3.最后运行:
atos -arch arm64 -o test.app.dSYM/Contents/Resources/DWARF/test -l 0x100064000 0x00000001018157dc
2、苹果自带Crash日志上报的堆栈信息解析
以下内容来自文章《iOS Crash 捕获及堆栈符号化思路剖析》
有四种常见的方法:
* symbolicatecrash
* mac 下的 atos 工具
* linux 下的 atos 的替代品 atosl
* 通过 dSYM 文件提取地址和符号的对应关系,进行符号还原
以上方案都有对应的应用场景,对于线上的 Crash 堆栈符号还原,主要采用的还是后三种方案。atos 和 atosl 的使用方法很类似,以下是 atos 的一个示例。
atos -o MonitorExample 0x0000000100062ac4 ARM-64 -l 0x100058000
// 还原结果
-[GYRootViewController tableView:cellForRowAtIndexPath:] (in GYMonitorExample) (GYRootViewController.m:41)
但是 atos 是Mac上一个工具,需要使用 Mac 或者黑苹果来进行解析工作,如果由后台来做解析工作,往往需要一套基于 Linux 的解析方案,这个时候可以选择 atosl,但是这个库已经有多年没有更新了,同时基于我司的尝试, atosl 好像不太支持 arm64 架构,所以我们放弃了该方案。
最终使用了第四个方案,提取 dSYM 的符号表,可以自己研发工具,也可以直接使用 bugly 和友盟的工具,下面是提取出来的符号表。第一列是起始内存地址,第二列是结束地址,第三列是对应的函数名、文件名以及行号。
a840 a854 -[GYRootViewController tableView:cellForRowAtIndexPath:] GYRootViewController.m:41
a854 a858 -[GYRootViewController tableView:cellForRowAtIndexPath:] GYRootViewController.m:42
a858 a87c -[GYRootViewController tableView:cellForRowAtIndexPath:] GYRootViewController.m:42
a87c a894 -[GYRootViewController tableView:cellForRowAtIndexPath:] GYRootViewController.m:42
a894 a8a0 -[GYRootViewController tableView:cellForRowAtIndexPath:] GYRootViewController.m:42
aa3c aa80 -[GYFilePreviewViewController initWithFilePath:] GYRootViewController.m:21
aa80 aaa8 -[GYFilePreviewViewController initWithFilePath:] GYFilePreviewViewController.m:23
aaa8 aab8 -[GYFilePreviewViewController initWithFilePath:] GYFilePreviewViewController.m:23
aab8 aabc -[GYFilePreviewViewController initWithFilePath:] GYFilePreviewViewController.m:24
aabc aac8 -[GYFilePreviewViewController initWithFilePath:] GYFilePreviewViewController.m:24
因为程序每次启动基地址都会变化,所以上面提到的地址是相对偏移地址,在我们获取到崩溃堆栈地址后,可以根据堆栈中的偏移地址来与符号表中的地址来做匹配,进而找到堆栈所对应的函数符号。比如下面的第四行,偏移为 43072 转换为十六进制就是 a840,用 a840 去上面的符号表中找对应关系,会发现对应着 -[GYRootViewController tableView:cellForRowAtIndexPath:],基于这种方式,就可以将堆栈地址完全还原为函数符号啦。
0 libsystem_kernel.dylib 0x0000000186cfd314 0x186cde000 + 127764
1 Foundation 0x00000001887f5590 0x1886ec000 + 1086864
2 GYMonitorExample 0x00000001000da4ac 0x1000d0000 + 42156
3 GYMonitorExample 0x00000001000da840 0x1000d0000 + 43072
野指针Crash的解决方案
野指针是最玄的Crash,表现在飘忽不定(往往不是100%复现)、难以定位(往往崩溃时的堆栈信息并不能定位到具体的代码行)、难以消除(往往负责的业务和代码逻辑使其隐藏得很深,在测试的覆盖范围外出现)。
既然不能根治,那就尽量把它抑制吧,下面是一些优秀的文章:
- 如何定位Obj-C野指针随机Crash(一):先提高野指针Crash率
- 如何定位Obj-C野指针随机Crash(二):让非必现Crash变成必现
- 如何定位Obj-C野指针随机Crash(三):加点黑科技让Crash自报家门
- 浅谈iOS Crash
网友评论