在应用程序开发过程中,最棘手的问题莫过于crash。已经上线的crash无法看到崩溃现场,只能通过crash日志进行定位分析。通常情况下,可以使用苹果自带的crash log或者第三方的crash组件进行crash捕获。但是在一些场景下,需要我们手动实现crash捕获与符号化,比如开发SDK。

Crash捕获

iOS端的crash分为两类,一类是NSException异常,另外一类是Signal信号异常。这两类异常我们都可以通过注册相关函数来捕获,但是值得注意的是一个应用中如果注册了多个crash收集组件,必然会存在冲突问题。这个时候,我们需要在注册之前判断是否已经注册过handler,如果有注册过,需要把之前注册的handler函数指针保存,待处理完crash后,再把对应的handler抛出去。

1. NSException异常捕获

NSException异常是OC代码导致的crash,我们可以先调用NSGetUncaughtExceptionHandler获取之前注册的handler,如果有就保存起来,再通过NSSetUncaughtExceptionHandler方法注册自己的handler:

1
2
3
4
5
6
void RegisterExceptionHandler() {
if(NSGetUncaughtExceptionHandler() != MyExceptionHandler) {
OldHandler = NSGetUncaughtExceptionHandler();
}
NSSetUncaughtExceptionHandler(&MyExceptionHandler);
}

处理完成后再调用保存的handler,抛出异常:

1
2
3
4
5
6
7
8
9
10
void MyExceptionHandler(NSException *exception) {

// do something...

// 调用之前已经注册的handler
if(OldHandler) {
OldHandler(exception);
}
}

2. Signal信号捕获

Signal信号是由iOS底层mach信号异常转换后以signal信号抛出的异常。既然是兼容posix标准的异常,我们同样可以通过sigaction函数注册对应的信号。
因为signal信号有很多,有些信号在iOS应用中也不会产生,我们只需要注册常见的几类信号:

1
2
3
4
5
SIGILL	4	非法指令	  执行了非法指令. 通常是因为可执行文件本身出现错误, 或者试图执行数据段. 堆栈溢出时也有可能产生这个信号.
SIGABRT 6 调用abort 程序自己发现错误并调用abort时产生,一些C库函数中,如strlen
SIGSFPE 8 浮点运算错误 如除0操作
SIGSEGV 11 段非法错误 试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据,空指针,数组越界,栈溢出等

下面我们注册一个SIGABRT信号,在注册handler之前,需要保存之前注册的hander:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void RegisterSignalHandler() {
struct sigaction old_action;
sigaction(SIGABRT, NULL, &old_action);
if (old_action.sa_flags & SA_SIGINFO) {
SignalHandlerFunc handler = (SignalHandlerFunc)old_action.sa_sigaction;
if (handler != MySignalHandler) {
// 保存OldAbrtSignalHandler
OldAbrtSignalHandler = handler;
}
}

// 注册MySignalHandler
struct sigaction action;
action.sa_sigaction = MySignalHandler;
action.sa_flags = SA_NODEFER | SA_SIGINFO;
sigemptyset(&action.sa_mask);
sigaction(signal, &action, 0);
}

处理完成后,同样抛出handler:

1
2
3
4
5
6
7
8
9
10
11
static void MySignalHandler(int signal, siginfo_t* info, void* context) {

// do something...

// 处理前者注册的 handler
if (signal == SIGABRT) {
if (OldAbrtSignalHandler) {
OldAbrtSignalHandler(signal, info, context);
}
}
}

收集调用堆栈

调用堆栈的收集我们可以利用系统api,也可以参考PLCrashRepoter等第三方实现获取所有线程堆栈。使用系统api关键代码如下:

1
2
3
4
5
6
7
NSMutableString *text = [NSMutableString string];
void* callstack[128];
int i, frames = backtrace(callstack, 128);
char** strs = backtrace_symbols(callstack, frames);
for (i = 0; i < frames; ++i) {
[text appendFormat:@"%@\n", [NSString stringWithCString:strs[i] encoding:NSUTF8StringEncoding]];
}

堆栈符号化

通过系统api获取的堆栈信息可能只是一串内存地址,很难从中获取有用的信息协助排查问题,因此,需要对堆栈信息符号化。
符号化的思路是找到当前应用对于的dsym符号表文件,利用dwarfdump,atos等工具还原crash堆栈内存地址对应的符号名。需要注意如果应用中使用了自己或第三方的动态库,应用崩溃在动态库Image而不是主程序Image中,我们需要有对应动态库的dsym符号表才能符号化。
思路明确之后,接下来面临的是两个问题。一个问题是如何把当前crash的应用和dsym符号表对应上。另一个问题是如何通过内存地址符号化。在解决这两个问题之前,我们需要先了解可执行文件的二进制格式和加载过程。

1. Mach-O文件格式

不同操作系统都会定义不同的可执行文件格式。如Linux平台的ELF格式,Windows平台的PE格式,iOS的可执行文件格式被称作Mach-O。可执行文件,动态库,dsym文件都是这种文件格式。
下图是官方的Mach-O格式结构:
Mach-O文件格式
可以看到,Mach-O文件分为三部分。
第一部分是header,hander定义了文件的基本信息,包括文件大小,文件类型,使用的平台等信息。我们可以从loader.h头文件中找到相关定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* The 64-bit mach header appears at the very beginning of object files for
* 64-bit architectures.
*/
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};

其次是load commands,这一部分定义了详细的加载指令,指明如何加载到内存。从头文件定义可以看到,基础的load_command结构体只包含了cmd以及cmdsize,通过cmd类型,可以转义成不同类型的load command 结构体:

1
2
3
4
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};

最后的数据部分,包括了代码段,数据段,符号表等具体的二进制数据。
我们可以用otool查看二进制文件的具体内容,更直观的,可以用Mach-O View来浏览可执行文件的具体内容。
下图是一个可执行文件与其所对于的符号表文件。可执行文件的load command比较多,里面包含了有代码段,数据段,函数入口,加载动态库等指令。其中的LC_UUID字段和符号表中的LC_UUID是完全对应的,也就是说,可以通过UUID字段匹配可执行文件和dsym符号表。

可执行文件

符号表文件

2. 可执行文件加载过程

一个iOS应用的加载过程是这样的,首先,由内核加载可执行文件(Mach-O),并从中获得dyld的路径。然后加载dyld,由dyld接管动态库加载,符号绑定等工作,runtime的初始化工作也在这一阶段进行。最后dyld调用main函数,这样便来到了main函数入口。
在这个过程中,操作系统为了安全考虑,使用了ASLR技术。地址空间布局随机化(Address space layout randomization),就是每次应用加载时,使用随机的一个地址空间,这样能有效防止被攻击。VM Address是编译后Image的起始位置,Load Address是在运行时加载到虚拟内存的起始位置,Slide是加载到内存的偏移,这个偏移值是一个随机值,每次运行都不相同,有下面公式:

1
Load Address = VM Address + Slide

由于dsym符号表是编译时生成的地址,crash堆栈的地址是运行时地址,这个时候需要经过转换才能正确的符号化。 crash日志里的符号地址被称为Stack Address,而编译后的符号地址被称为Symbol Address,他们之间的关系如下:

1
Stack Address = Symbol Address + Slide

符号化就是通过Symbol Address到dsym文件中寻找对应符号信息的过程。

3. 获取Binary Images信息

我们在demo的viewDidLoad方法中调用abort方法制造一个crash。仔细观察一下系统采集到的crash日志,报错地址Stack Address位于0x1046eea14,相对Load Address 0x1046e8000偏移了27156。这里的27156并不是ASLR的随机偏移Slide,而是符号相对位置offset(Symbol Address - VM Address):
报错堆栈
再观察crash日志最后,有一栏Binary Images,记录了所有加载image的UUID和加载的Load Address:
Binary Images
根据前文提到的UUID对应关系以及Load Address和Symbol Address的转换关系,只要能获取Binary Images信息,就可以实现符号化。
UUID存放在Mach-O的load command中,对应uuid_command结构体的uuid字段,可以通过遍历所有load command获取。
Slide偏移可以通过image_dyld_get_image_vmaddr_slide方法遍历所有Image获取。
VM Address也存放在load command中,对应segment_command结构体的vmaddr字段,需要注意segment_command存在多种类型以及需要区分32位和64位应用的细微差别。
解析代码如下:

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
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
uint64_t vmbase = 0;
uint64_t vmslide = 0;
uint64_t vmsize = 0;

uint64_t loadAddress = 0;
uint64_t loadEndAddress = 0;
NSString *imageName = @"";
NSString *uuid;

const struct mach_header *header = _dyld_get_image_header(i);
const char *name = _dyld_get_image_name(i);
vmslide = (i);
imageName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
BOOL is64bit = header->magic == MH_MAGIC_64 || header->magic == MH_CIGAM_64;
uintptr_t cursor = (uintptr_t)header + (is64bit ? sizeof(struct mach_header_64) : sizeof(struct mach_header));
struct load_command *loadCommand = NULL;
for (uint32_t i = 0; i < header->ncmds; i++, cursor += loadCommand->cmdsize) {
loadCommand = (struct load_command *)cursor;
if(loadCommand->cmd == LC_SEGMENT) {
const struct segment_command* segmentCommand = (struct segment_command*)loadCommand;
if (strcmp(segmentCommand->segname, SEG_TEXT) == 0) {
vmsize = segmentCommand->vmsize;
vmbase = segmentCommand->vmaddr;
}
} else if(loadCommand->cmd == LC_SEGMENT_64) {
const struct segment_command_64* segmentCommand = (struct segment_command_64*)loadCommand;
if (strcmp(segmentCommand->segname, SEG_TEXT) == 0) {
vmsize = segmentCommand->vmsize;
vmbase = (uintptr_t)(segmentCommand->vmaddr);
}
}
else if (loadCommand->cmd == LC_UUID) {
const struct uuid_command *uuidCommand = (const struct uuid_command *)loadCommand;
NSString *uuidString = [[[NSUUID alloc] initWithUUIDBytes:uuidCommand->uuid] UUIDString];
uuid = [[uuidString stringByReplacingOccurrencesOfString:@"-" withString:@""] lowercaseString];
}
}

loadAddress = vmbase + vmslide;
loadEndAddress = loadAddress + vmsize - 1;
}
// do something...

4. 符号化

通过上述代码,我们可以采集到和系统一样的crash日志。接下来,可以使用dwarfdump和atos进行符号化。

4.1 dwarfdump

拿到crash日志后,我们要先确定dsym文件是否匹配。可以使用dwarfdump –uuid命令查看dsym文件所有架构的UUID:

1
2
3
$ dwarfdump --uuid mytest.app.dSYM 
UUID: B4217D5B-0349-3D9F-9D70-BC7DD60DA121 (armv7) mytest.app.dSYM/Contents/Resources/DWARF/mytest
UUID: A52E3452-C2EF-3291-AE37-9392EDCCE572 (arm64) mytest.app.dSYM/Contents/Resources/DWARF/mytest

可以看到dsym文件的arm64架构中包含的A52E3452-C2EF-3291-AE37-9392EDCCE572和Binary Images中的UUID是相匹配的。
UUID
下面就可以用dwarfdump –lookup命令对报错堆栈符号化,格式如下:

1
dwarfdump --arch [arch type] --lookup [Symbol Address] [dsym file path]

对于报错堆栈的Stack Address 0x1046eea14,需要进行一个转换。已知VM Address为0x100000000,Load Address为0x1046e8000,可以得到Slide为0x46e8000。通过公式Symbol Address = Stack Address - Slider求得Symbol Address为0x100006a14,输入命令:

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
$ dwarfdump --arch arm64 --lookup 0x100006a14 mytest.app.dSYM 
----------------------------------------------------------------------
File: mytest.app.dSYM/Contents/Resources/DWARF/mytest (arm64)
----------------------------------------------------------------------
Looking up address: 0x0000000100006a14 in .debug_info... found!

0x0003ebb7: Compile Unit: length = 0x000000d4 version = 0x0004 abbr_offset = 0x00000000 addr_size = 0x08 (next CU at 0x0003ec8f)

0x0003ebc2: TAG_compile_unit [120] *
AT_producer( "Apple LLVM version 9.1.0 (clang-902.0.39.2)" )
AT_language( DW_LANG_ObjC )
AT_name( "/Users/worthyzhang/Desktop/mytest/mytest/ViewController.m" )
AT_stmt_list( 0x00009151 )
AT_comp_dir( "/Users/worthyzhang/Desktop/mytest" )
AT_APPLE_optimized( true )
AT_APPLE_major_runtime_vers( 0x02 )
AT_low_pc( 0x00000001000069bc )
AT_high_pc( 0x000000a4 )

0x0003ebf9: TAG_subprogram [122] *
AT_low_pc( 0x00000001000069bc )
AT_high_pc( 0x00000070 )
AT_frame_base( reg29 )
AT_object_pointer( {0x0003ec12} )
AT_name( "-[ViewController viewDidLoad]" )
AT_decl_file( "/Users/worthyzhang/Desktop/mytest/mytest/ViewController.m" )
AT_decl_line( 17 )
AT_prototyped( true )
AT_APPLE_optimized( true )
Line table dir : '/Users/worthyzhang/Desktop/mytest/mytest'
Line table file: 'ViewController.m' line 25, column 1 with start address 0x0000000100006a14

Looking up address: 0x0000000100006a14 in .debug_frame... not found.mp

可以定位到报错所在的函数名[ViewController viewDidLoad]以及文件名,行号等信息。

4.2 atos

如果只是简单的获取符号名,可以用atos来符号化,命令格式如下:

1
atos -o [dsym file path] -l [Load Address] -arch [arch type] [Stack Address]

需要注意这里的dsym file path是dsym文件而不是.dSYM结尾的文件夹,输入命令:

1
2
$ atos -o mytest.app.dSYM/Contents/Resources/DWARF/mytest -l 0x1046e8000 --arch arm64 0x1046eea14
-[ViewController viewDidLoad] (in mytest) (ViewController.m:25)

得到结果和dwarfdump是一致的。

参考资料:
Mach-O Executables
Mach-O Programming Topics
漫谈iOS Crash收集框架
iOS崩溃堆栈信息的符号化解析