iOS内功篇:浅谈Crash

iOS开发中,解决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异常:
  1. EXC_BAD_INSTRUCTION运行了非法的指令,往往是运行指令的参数不对(0或者nil的参数)
  2. EXC_RESOURCE程序资源上限(cpu占用过高或者内存不足)。
  3. EXC_GUARD一些C函数访问错误导致的异常。
  4. 0x00000020奇怪异常集合,常见的是由于主线程阻塞看门狗杀死了APP《Exception Type: 00000020:什么是看门狗机制》

Unix Signal Exception

从Mach异常最终会转化成Unix信号投递到出错的线程(具体原理可以学习《漫谈iOS Crash收集框架》, 各种信号的含义可以学习《iOS异常捕获》

  1. OC异常并不是真正的异常,但是当一个OC异常被抛出到最外层还没被捕获,程序会强行发送SIGABRT信号中断程序。
  2. 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%复现)、难以定位(往往崩溃时的堆栈信息并不能定位到具体的代码行)、难以消除(往往负责的业务和代码逻辑使其隐藏得很深,在测试的覆盖范围外出现)。

既然不能根治,那就尽量把它抑制吧,下面是一些优秀的文章:

参考文章:

1、iOS异常捕获
2、iOS Crash文件的解析
3、漫谈iOS Crash收集框架
4、iOS开发质量的那些事

推荐阅读更多精彩内容