iOS开发之常用技术点SDKiOS高阶

iOS实现Crash捕获与堆栈符号化

2018-08-30  本文已影响47人  Worthy

在应用程序开发过程中,最棘手的问题莫过于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:

void RegisterExceptionHandler() {
    if(NSGetUncaughtExceptionHandler() != MyExceptionHandler) {
        OldHandler = NSGetUncaughtExceptionHandler();
    }
    NSSetUncaughtExceptionHandler(&MyExceptionHandler);
}

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

void MyExceptionHandler(NSException *exception) {

   // do something...

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

2. Signal信号捕获

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

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

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

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:

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关键代码如下:

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头文件中找到相关定义:

/*
 * 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 结构体:

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是加载到内存的偏移,这个偏移值是一个随机值,每次运行都不相同,有下面公式:

Load Address = VM Address + Slide

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

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位应用的细微差别。
解析代码如下:

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:

$ 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命令对报错堆栈符号化,格式如下:

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,输入命令:

$ 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.

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

4.2 atos

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

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

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

$ 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崩溃堆栈信息的符号化解析

上一篇下一篇

猜你喜欢

热点阅读