这篇文档用于总结日常工作中如何分析Crash Report的一些思路,首先我们可以从官方文档得到一些关于Crash Report的基本情况。
一份完整的Crash Report(已符号化过)中包含的几部分信息中,头部描述信息和线程调用堆栈是我们最为关注的两个部分。
通过这两部分可以获知这次崩溃现场的触发原因和线程回溯,然后通过这些信息我们就可以开始动手修复这份Crash了。接下来先来看几类在工作中遇到的Crash修复示例。
未捕获异常(EXC_CRASH)
1 | Exception Type: EXC_CRASH (SIGABRT) |
头部信息中告知是一个未捕获的异常崩溃,是由于Objective-C/C/C++的Exception或者调用Abort()引起的崩溃。这类问题也是线上/自动化Crash最为常见的一类,需要仔细分析才能看出端倪。比如下面:
1 | Exception Type: EXC_CRASH (SIGKILL) |
这是一个比较有意思的Crash,在头部信息中Termination Reason: Namespace ASSERTIOND, Code 0xbada5e47
这句话就可以发现这个问题的具体原因是由于后台任务过多导致的崩溃,对于这种Crash只能去调整相关业务代码才能避免,总的方向就是需要去降低相关业务的后台申请数量。
1 | Exception Type: EXC_CRASH (SIGKILL) |
这个崩溃显然触发了看门狗超时,这种类型的问题出现的一般原因是主线程中存在死锁路径。比如这个例子就是由于dispatch_once的底层实现策略实际上存在不安全情况,同时在并发调用dispatch_once初始化过程可能会出现阻塞主线程导致死锁情况。
1 | Exception Type: 00000020 |
这是一个比较有意思的崩溃,原因上写着SIMULATED (this is NOT a crash)
表明并不像上面的主线程阻塞死锁导致的。分析完崩溃上下文并在Xcode动态联调起来后发现是由于主线程代码上存在逻辑循环,在父控件的layoutSubviews里面绘制子控件,同时子控件的绘制动作又会触发父控件的layoutSubviews导致主线程处于逻辑循环状态,表现上是界面卡死最终导致崩溃现象。
1 | Exception Type: EXC_CRASH (SIGABRT) |
这个崩溃从堆栈上看比较奇怪,是在系统进行malloc内存分配的时候出现了异常触发了abort()函数。同时这段业务代码调用并不存在可能触发这类异常的逻辑,所以可以大胆猜测这份Crash实际上堆栈已经被破坏了(Heap corruption),最重要的是堆错误的时候可能是在指针出现损坏(野指针)后很久才会被访问到导致崩溃。所以这份奇怪的Crash实际上并非真正导致崩溃的现场,这种情况就需要借助其他内存排查工具去解决。
内存访问错误(EXC_BAD_ACCESS)
1 | Exception Type: EXC_BAD_ACCESS (SIGSEGV) |
当进程试图访问无效的内存(野指针释放、访问错误类型指针)或试图以内存的保护级别所不允许的方式去访问内存(例如写入到只读存储器)的时候会触发内存访问错误。这种现象也被称作堆栈错误(Heap Corruption),同时这份Crash堆栈可能并非是导致这个错误的直接原因,只是正好它访问了这个无效内存导致Crash。
当出现这种情况我们可能需要到诸如:Zombies Instrument、Xcode Zombie Object(僵尸对象)、Address Sanitizer(地址消毒剂)、Thread Sanitizer(多线程资源访问检测)等工具来排查问题。
1 | Exception Type: EXC_BAD_ACCESS (SIGSEGV) |
这个Crash的Exception Subtype
部分告诉我们这是一个由访问野指针造成的崩溃。同时我们再看一看它的崩溃线程堆栈,在这一段业务代码逻辑的[NSString stringWithFormat:]
是在ARC环境中栈对象,所以理论上并不是它产生了这个野指针。这时候要解决这个Crash就必须要借助Address Sanitizer或者Zombie Object来找到产生野指针的对象。
1 | Exception Type: EXC_BAD_ACCESS (SIGSEGV) |
这是一个比较奇怪的Crash,表现上是使用Apple内购IAP的时候触发支付动作点击取消低概率崩溃。从它的崩溃堆栈上看表明是KVO对象已经释放但仍然给它发送消息导致的内存访问崩溃。对于这个Crash的原因推测是在StoreKit(另外一个系统进程)出现观察者对象释放异常,当在非当次访问时就出现Crash。
这里涉及到IAP业务中的编码逻辑,原来的做法在一个单例中全局观察StoreKit的操作类似如下逻辑:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15+ (instancetype)shared {
static dispatch_once_t onceToken;
static id shared;
dispatch_once(&onceToken, ^{
shared = [[self alloc] init];
});
return shared;
}
- (instancetype)init {
self = [super init];
if (self) {
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
}
return self;
}
也就是这个App程序进程释放前都会一致观察着StoreKit,大概率是这段业务存在异常情况。故在后续版本中修改为支付、恢复业务单次观察单次移除的情况。
解决Crash的一点思路
在解决一个Crash的思路核心总结起来大概就这么两点:
- 动态联调与场景复现
- 逻辑分析与合理推测
对于一个Crash,假如它的复现路径是明确的,那基本上就是把Xcode开起来动态联调一下相关的业务代码分析一下逻辑就解决的事情了:)。然而我们常常拿到的Crash Report都是线上难以复现的问题,并没有十分明确的复现路径(毕竟在编码、自测与自动化测试阶段就已经规避掉一波了)。
就我目前遇到的Crash Report来说,均为EXC_CRASH和EXC_BAD_ACCESS这两类。对于EXC_BREAKPOINT、EXC_BAD_INSTRUCTION等等类型的Crash还没见识到。故在这里总结一下解决这两类问题的一点点思路和方法。
在解决Crash的路上最重要一点的是要善用现有工具,比如在Xcode联调时我们进行代码调试的时候可以在Code Diagnostics中开启内存、线程调试选项。
对于未捕获异常(EXC_CRASH)
在我们连上Xcode开始调试的时候一定要记得加一个全局的异常断点(Breakpoint navigator —> Create a breakpoint —> Exception Breakpoint),然后就可以到对应的业务场景流程中尝试复现问题路径了。
比如当我们写一段数组访问越界崩溃代码,Xcode就能立马帮我们定位出问题代码的位置。1
2NSArray *array = [NSArray new];
id object = [array objectAtIndex:0];
当然了现实中当我们遇到低概率路径崩溃的时候,这种联调找复现路径是一种十分低效的方式。那能怎么办呢?我们可以直接到Crash出现的崩溃上下文附近进行合理的代码逻辑分析与崩溃原因推测,然后再不引入副作用的前提下通过自动化场景复现这类问题。
对于内存问题(EXC_BAD_ACCESS)
这类问题是线上崩溃中最让人头疼的一类问题,原理还是当出现内存访问问题而导致崩溃的时候上报的Crash Report有可能并非出现野指针的真凶。崩溃现场仅有一些参考意义,在大型多人合作的项目中还十分不容易定位到相应的模块。
当然了在ARC环境下Objective-C和Swift高级对象编码中一般而言是不容易存在访问野指针的内存操作(强行非法访问栈内存),反而在直接调用malloc()这种更底层分配堆内存的时候一不小心就有可能造成野指针内存。
所以在开发调试阶段我们就有必要有意识地利用好工具来规避可能存在内存问题,Address Sanitizer就是现阶段Xcode上最有效的工具(比Zombie Objects适用范围更广)。
从文档上看,这个工具可以帮助我们发现以下几种问题:
访问已释放内存(Use of Deallocated Memory)
1
2
3
4
5
6
7__unsafe_unretained MyClass *unsafePointer;
@autoreleasepool {
MyClass *object = [MyClass new];
unsafePointer = object;
}
NSLog(@"%d", unsafePointer->instanceVariable);
// Error: unsafePointer is deallocated in autorelease pool再次释放已释放的内存(Deallocation of Deallocated Memory)
1
2
3int *pointer = malloc(sizeof(int));
free(pointer);
free(pointer); // Error: free called twice with the same memory address释放未分配的内存(Deallocation of Nonallocated Memory)
1
2int value = 42;
free(&value); // Error: free called on stack allocated variable在函数返回后访问函数内的栈上内存(Use of Stack Memory After Function Return)
1
2
3
4
5
6int *integer_pointer_returning_function() {
int value = 42;
return &value;
}
int *integer_pointer = integer_returning_function();
*integer_pointer = 43; // Error: invalid access of returned stack memory访问非同一代码块作用域的栈上内存(Use of Out-of-Scope Stack Memory)
1
2
3
4
5
6int *pointer = NULL;
if (bool_returning_function()) {
int value = integer_returning_function();
pointer = &value;
}
*pointer = 42; // Error: invalid access of stack memory out of declaration scope缓存区溢出(Overflow and Underflow of Buffers)
1
2
3
4
5
6
7
8
9int global_array[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
void foo() {
int idx = 10;
global_array[idx] = 42; // Error: out of bounds access of global variable
char *heap_buffer = malloc(10);
heap_buffer[idx] = 'x'; // Error: out of bounds access of heap allocated variable
char stack_buffer[10];
stack_buffer[idx] = 'x'; // Error: out of bounds access of stack allocated variable
}C++容器溢出(Overflow of C++ Containers)
1
2
3std::vector<int> vector;vector.push_back(0);vector.push_back(1);vector.push_back(2);
auto *pointer = &vector[0];
return pointer[3]; // Error: out of bounds access for vector
其中我们最为可能遇到的问题是:访问已释放内存和缓存区溢出这两种问题。比如这里造一个缓存区溢出场景,当开启这个功能后能够抓到相应的运行上下文。
额外的,现实中还存在内存泄漏(Memory Leak)和OOM(Out of Memory)这两种内存问题同样会导致程序崩溃且收集不到对于的Crash报告,这两个问题从开发阶段就要借助Xcode Debug Memory Graph或者Instrument Leak模板重点关注。
总的来说就是对于Crash不要担心解决不掉,更需要我们善用到Apple提供的调试工具。从Xcode、Instrument到LLDB总有一款能够帮助你找到崩溃时的一些线索再推断出它出现的原因从而解决它。
参考
WWDC2018-414,Understanding Crashes and Crash Logs
WWDC2017-414,Engineering for Testability
Code Diagnostics
iOS App 后台任务的坑
UIApplication Background Task Notes
滥用单例之dispatch_once死锁
iOS 内存调试技巧
浅谈 Zombie Objects
如何用Xcode8解决多线程问题
【迁移】Xcode7新特性AddressSanitizer