iOS Crash 分析
iOS开发中,解决Crash相信是开发者最为头疼的问题了,特别是对于已上线的应用,对其Crash的跟踪和修复显得尤其重要.
通常来讲iOS系统中Crash分为两种,未捕获的Objective-C异常和Mach异常
Objective-C异常
在OC层面(iOS库、第三方库出现错误抛出)的异常称为OC异常。
比如:1
2NSArray * array= @[@“s",@“x",@“m"];
[array objectAtIndex:4];
OC异常可以用try-catch抓住:
1 | @try { |
使用此方法可以抓到当前抛出的异常并阻止程序崩溃,输出如下:
1 | -[__NSArrayI objectAtIndex:]: index 4 beyond bounds [0 .. 2] |
常见的OC异常
以下内容来自文章《iOS开发质量的那些事》
iOS开发中常见的异常包括以下几种:1
2
3
4
5NSInvalidArgumentException
NSRangeException
NSGenericException
NSInternalInconsistencyException
NSFileHandleOperationException
NSInvalidArgumentException
非法参数异常(NSInvalidArgumentException)是 Objective - C 代码最常出现的错误,所以平时在写代码的时候,需要多加注意,加强对参数的检查,避免传入非法参数导致异常,其中尤以nil参数为甚。
1. 集合数据的参数传递
比如NSMutableArray, NSMutableDictionary的数据操作
(1) NSDictionary不能删除nil的key
(2) NSDictionary不能添加nil的对象
(3) 不能插入nil的对象
(4) 其他一些nil参数1
2
3
4NSString *key = nil;
NSString *value = @"Hello";
NSMutableDictionary *mDic = [[NSMutableDictionary alloc] init];
[mDic setObject:value forKey:key];
运行后控制台输出日志:1
2*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '*** -[__NSDictionaryM setObject:forKey:]: key cannot be 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. 使用空数组
如果一个数组刚刚初始化,还是空的,就对它进行相关操作
1 | NSArray *array = @[@0, @1, @2]; |
运行后控制台输出日志:1
2*** Terminating app due to uncaught exception 'NSRangeException',
reason: '*** -[__NSArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 2]'
所以,为了避免NSRangeException的发生,必须对传入的index参数进行合法性检查,是否在集合数据的个数范围内。
NSGenericException
NSGenericException这个异常最容易出现在foreach操作中,在for in循环中如果修改所遍历的数组,无论你是add或remove,都会出错 “for in”,它的内部遍历使用了类似 Iterator进行迭代遍历,一旦元素变动,之前的元素全部被失效,所以在foreach的循环当中,最好不要去进行元素的修改动作,若需要修改,循环改为for遍历,由于内部机制不同,不会产生修改后结果失效的问题。1
2
3
4NSMutableArray *mArray = [NSMutableArray arrayWithArray:@[@0, @1, @2]];
for (NSNumber *num in mArray) {
[mArray addObject:@3];
}
运行后控制台输出日志:1
2*** Terminating app due to uncaught exception 'NSGenericException',
reason: '*** Collection <__NSArrayM: 0x600000c08660> was mutated while being enumerated.'
NSInternalInconsistencyException
不一致导致出现的异常.
比如NSDictionary当做NSMutableDictionary来使用,从他们内部的机理来说,就会产生一些错误1
2NSMutableDictionary *info = method return to NSDictionary type;
[info setObject:@“sxm" forKey:@"name"];
比如xib界面使用或者约束设置不当
NSFileHandleOperationException
处理文件时的一些异常,最常见的还是存储空间不足的问题,比如应用频繁的保存文档,缓存资料或者处理比较大的数据:1
2
3
4
5
6
7
8
9
10
11
12
13
14NSString *cacheDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
NSString *filePath = [cacheDir stringByAppendingPathComponent:@"1.txt"];
if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
NSString *str1 = @"Hello1";
NSData *data1 = [str1 dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:data1 attributes:nil];
}
NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:filePath];
[fileHandle seekToEndOfFile];
NSString *str2 = @"Hello2";
NSData *data2 = [str2 dataUsingEncoding:NSUTF8StringEncoding];
[fileHandle writeData:data2];
[fileHandle closeFile];
运行后控制台输出日志:
1 | *** Terminating app due to uncaught exception 'NSFileHandleOperationException', |
所以在文件处理里,需要考虑到手机存储空间的问题。
NSMallocException
这也是内存不足的问题,无法分配足够的内存空间1
2
3NSMutableData *mData = [[NSMutableData alloc] initWithCapacity:1];
NSUInteger len = 1844674407370955161;
[mData increaseLengthBy:len];
运行后控制台输出日志:1
2*** Terminating app due to uncaught exception 'NSMallocException',
reason: 'Failed to grow buffer'
OC异常的抓取和分析
在debug环境下,OC异常导致崩溃时Xcode控制台会输出完整的异常信息,比如:1
Terminating app due to uncaught exception ‘NSRangeException’, reason: ‘this is reason description’
包括Exception的类型、原因和发生异常的完整堆栈。
这些信息一般来说都足够详细,足够我们轻易地找到异常的位置并进行修复。
非debug环境下,可以通过注册 NSUncaughtExceptionHandler 捕获异常信息。虽然无法阻止APP崩溃,但是可以获取异常信息并进行收集,下次启动APP时进行上报,方便开发者进行错误跟踪及修复,这就是常用Crash收集工具所做的事情。
1 | void InstallUncaughtExceptionHandler(void) { |
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.
Unix Signal Exception
从Mach异常最终会转化成Unix信号投递到出错的线程
1. OC异常并不是真正的异常,但是当一个OC异常被抛出到最外层还没被捕获,程序会强行发送SIGABRT信号中断程序。
2. Mach异常没有比较便利的捕获方式,既然它最终会转化成信号,我们也可以通过捕获信号,来捕获 Crash 事件。
iOS提供了signal方法来注册一个处理函数,在处理函数中,使用execinfo中的 backtrace_symbols取出汇编层程序的堆栈信息。
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40+ (void)signalRegister {
InstallMySignalRegister(SIGABRT);
InstallMySignalRegister(SIGBUS);
InstallMySignalRegister(SIGFPE);
InstallMySignalRegister(SIGILL);
InstallMySignalRegister(SIGPIPE);
InstallMySignalRegister(SIGSEGV);
InstallMySignalRegister(SIGSYS);
InstallMySignalRegister(SIGTRAP);
}
static void InstallMySignalRegister(int signal) {
// Register Signal
struct sigaction action;
action.sa_sigaction = handleSignalException;
action.sa_flags = SA_NODEFER | SA_SIGINFO;
sigemptyset(&action.sa_mask);
sigaction(signal, &action, 0);
}
void handleSignalException(int signal) {
NSMutableString *mstr = [[NSMutableString alloc] init];
[mstr appendString:@"Signal Exception:\n"];
[mstr appendString:[NSString stringWithFormat:@"Signal %@ was raised.\n", signalName(signal)]];
[mstr appendString:@"Call Stack:\n"];
// 这里过滤掉第一行日志
// 因为注册了信号崩溃回调方法,系统会来调用,将记录在调用堆栈上,因此此行日志需要过滤掉
for (NSUInteger index = 1; index < NSThread.callStackSymbols.count; index++) {
NSString *str = [NSThread.callStackSymbols objectAtIndex:index];
[mstr appendString:[str stringByAppendingString:@"\n"]];
}
[mstr appendString:@"threadInfo:\n"];
[mstr appendString:[[NSThread currentThread] description]];
// 保存崩溃日志到沙盒cache目录
[XXXCrashReporter saveCrashLog:[NSString stringWithString:mstr] fileName:@"Crash(Signal)"];
}
测试过程中可以通过方法来模拟信号:1
raise(SIGPIPE);
下面是一些常用信号代表的含义:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20(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.1
signal(SIGPIPE, SIG_IGN);
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 抛出去,供前面的注册者处理。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59static SignalHandler previousABRTSignalHandler = NULL;
static SignalHandler previousBUSSignalHandler = NULL;
static SignalHandler previousFPESignalHandler = NULL;
static SignalHandler previousILLSignalHandler = NULL;
static SignalHandler previousPIPESignalHandler = NULL;
static SignalHandler previousSEGVSignalHandler = NULL;
static SignalHandler previousSYSSignalHandler = NULL;
static SignalHandler previousTRAPSignalHandler = NULL;
+ (void)backupOriginalHandler {
struct sigaction old_action_abrt;
sigaction(SIGABRT, NULL, &old_action_abrt);
if (old_action_abrt.sa_sigaction) {
previousABRTSignalHandler = old_action_abrt.sa_sigaction;
}
struct sigaction old_action_bus;
sigaction(SIGBUS, NULL, &old_action_bus);
if (old_action_bus.sa_sigaction) {
previousBUSSignalHandler = old_action_bus.sa_sigaction;
}
struct sigaction old_action_fpe;
sigaction(SIGFPE, NULL, &old_action_fpe);
if (old_action_fpe.sa_sigaction) {
previousFPESignalHandler = old_action_fpe.sa_sigaction;
}
struct sigaction old_action_ill;
sigaction(SIGILL, NULL, &old_action_ill);
if (old_action_ill.sa_sigaction) {
previousILLSignalHandler = old_action_ill.sa_sigaction;
}
struct sigaction old_action_pipe;
sigaction(SIGPIPE, NULL, &old_action_pipe);
if (old_action_pipe.sa_sigaction) {
previousPIPESignalHandler = old_action_pipe.sa_sigaction;
}
struct sigaction old_action_segv;
sigaction(SIGSEGV, NULL, &old_action_segv);
if (old_action_segv.sa_sigaction) {
previousSEGVSignalHandler = old_action_segv.sa_sigaction;
}
struct sigaction old_action_sys;
sigaction(SIGSYS, NULL, &old_action_sys);
if (old_action_sys.sa_sigaction) {
previousSYSSignalHandler = old_action_sys.sa_sigaction;
}
struct sigaction old_action_trap;
sigaction(SIGTRAP, NULL, &old_action_trap);
if (old_action_trap.sa_sigaction) {
previousTRAPSignalHandler = old_action_trap.sa_sigaction;
}
}
然后将自己的异常处理模块稍作修改1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24static void handleSignalException(int signal, siginfo_t* info, void* context) {
NSMutableString *mstr = [[NSMutableString alloc] init];
[mstr appendString:@"Signal Exception:\n"];
[mstr appendString:[NSString stringWithFormat:@"Signal %@ was raised.\n", signalName(signal)]];
[mstr appendString:@"Call Stack:\n"];
// 这里过滤掉第一行日志
// 因为注册了信号崩溃回调方法,系统会来调用,将记录在调用堆栈上,因此此行日志需要过滤掉
for (NSUInteger index = 1; index < NSThread.callStackSymbols.count; index++) {
NSString *str = [NSThread.callStackSymbols objectAtIndex:index];
[mstr appendString:[str stringByAppendingString:@"\n"]];
}
[mstr appendString:@"threadInfo:\n"];
[mstr appendString:[[NSThread currentThread] description]];
// 保存崩溃日志到沙盒cache目录
[XXXCrashReporter saveCrashLog:[NSString stringWithString:mstr] fileName:@"Crash(Signal)"];
MyClearSignalRigister();
// 调用之前崩溃的回调函数
previousSignalHandler(signal, info, context);
}
上面的是一个处理 Signal handler 冲突的大概代码思路,下面是 NSException handler 的处理思路,两者大同小异。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15static NSUncaughtExceptionHandler *previousUncaughtExceptionHandler;
static void LDAPMUncaughtExceptionHandler(NSException *exception) {
// 获取堆栈,收集堆栈
// ......
// 处理前者注册的 handler
if (previousUncaughtExceptionHandler) {
previousUncaughtExceptionHandler(exception);
}
}
+ (void)installExceptionHandler {
previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&LDAPMUncaughtExceptionHandler);
}
- 两种异常共存的问题
由于我们既捕获了Objective-C异常,又捕获了Mach异常,那么当发生Objective-C异常的时候就会出现两份Crash日志。
一份是通过NSSetUncaughtExceptionHandler设置异常处理函数生成的日志,另一份是通过捕获Unix信号产生的日志。这两份日志中,通过Unix信号捕获的日志是无法定位问题的,因此我们只需要NSSetUncaughtExceptionHandler中异常处理函数生成的日志即可。
那该怎么做才能阻止生成捕获Unix信号的日志呢?在DoraemonKit中采取的方式是在Objective-C异常捕获到Crash之后,主动调用exit(0)或者kill(getpid(), SIGKILL)等方式让程序退出。
堆栈符号解析
分为两种情景讨论:
Crash收集上报的堆栈信息解析
这种信息一般还原度比较高,基本上都能给出崩溃的具体定位信息,一些需要解析堆栈符号的解决方法如下:
异常信息有三种类型:
1.已标记错误位置的:
1 | test 0x000000010bfddd8c -[ViewController viewDidLoad] + 8588 |
- 这种信息已经很明确了,不用解析
2.有模块地址的情况:
1 | test 0x00000001018157dc 0x100064000 + 24844252 |
以上面为例子,从左到右依次是:
二进制库名(test),调用方法的地址(0x00000001018157dc),模块地址(0x100064000)+偏移地址(24844252)
3.无模块地址的情况:
1 | test 0x00000001018157dc test + 24844252 |
解析堆栈信息
dSYM符号表获取,xcode->window->organizer->右键你的应用 show finder->右键.xcarchive 显示包内容->dSYMs->test.app.dYSM
然后使用atos命令来符号化某个特定模块加载地址1
atos [-arch 架构名] [-o 符号表] [-l 模块地址] [方法地址]
使用终端,进到test.app.dYSM所在目录
一.如果是有模块地址的情况,运行:
1 | atos -arch arm64 -o test.app.dSYM/Contents/Resources/DWARF/test -l 0x100064000 0x00000001018157dc |
二.如果是无模块地址的情况
1.先将偏移地址转为16进制:
1
24844252 = 0x17B17DC
2.然后用方法的地址-偏移地址,得到的就是模块地址
1
0x00000001018157dc - 0x17B17DC = 0x100064000
3.最后运行:
1 | atos -arch arm64 -o test.app.dSYM/Contents/Resources/DWARF/test -l 0x100064000 0x00000001018157dc |
苹果自带Crash日志上报的堆栈信息解析
有四种常见的方法:
- symbolicatecrash
- mac 下的 atos 工具
- linux 下的 atos 的替代品 atosl
- 通过 dSYM 文件提取地址和符号的对应关系,进行符号还原
以上方案都有对应的应用场景,对于线上的 Crash 堆栈符号还原,主要采用的还是后三种方案。atos 和 atosl 的使用方法很类似,以下是 atos 的一个示例。1
2
3
4atos -o MonitorExample 0x0000000100062ac4 ARM-64 -l 0x100058000
// 还原结果
-[GYRootViewController tableView:cellForRowAtIndexPath:] (in GYMonitorExample) (GYRootViewController.m:41)
但是 atos 是Mac上一个工具,需要使用 Mac 或者黑苹果来进行解析工作,如果由后台来做解析工作,往往需要一套基于 Linux 的解析方案,这个时候可以选择 atosl,但是这个库已经有多年没有更新了, atosl 好像不太支持 arm64 架构,所以我们放弃了该方案。
最终使用了第四个方案,提取 dSYM 的符号表,可以自己研发工具,也可以直接使用 bugly 和友盟的工具,下面是提取出来的符号表。第一列是起始内存地址,第二列是结束地址,第三列是对应的函数名、文件名以及行号。1
2
3
4
5
6
7
8
9
10a840 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 去上面的符号表中找对应关系,会发现对应着1
2
3
4
5
6-[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
符号文件格式
OC中编译输出的dSYM文件是dwarf格式的, dwraf文件的格式说明