在应用程序开发过程中,最棘手的问题莫过于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文件分为三部分。 第一部分是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: 根据前文提到的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是相匹配的。 下面就可以用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崩溃堆栈信息的符号化解析