iOS Crash 流程化4:打造自己的收集、符号化程序

Table of Contents

  • iOS Crash 流程化4:打造自己的收集、符号化程序
    • 实现代码
    • 发布包没带符号表
    • Mach-O File Format
      • header
      • load Command
        • LC_SEGMENT
        • LC_SYMTAB
      • 数据部分
      • 小小结
    • 获取构架、镜像加载地址
    • 输出Crash日志
    • 小结

当APP发布到AppStore后,如果发生了Crash,通常情况下我们拿不到崩溃手机,也就是说拿不到Crash日志。这是一个棘手的问题。有人说可以在开发者中心找到用户上传到苹果的日志,但是,不是所有的用户都会在程序Crash后上传Crash日志,所以有必要打造一个属于我们自己的异常收集系统。

下面就讲讲打造的异常收集系统,主要思路:使用NSSetUncaughtExceptionHandler注册异常处理函数,当APP 发生Crash时,回调到异常处理函数,在异常处理函数中收集Crash信息,然后上传到服务器;当需要分析的时候,从服务器取回Crash日志,如果没有符号化,使用atos命令符号化。由于暂时没有服务器,就保存到了沙盒路径的Document目录下,可以使用itunes方便的导出日志。这里提供了一个简单示例代码:UncaughtException,先从代码入手。

实现代码

这里会分别列出关键的代码。下面是 AppDelegate.m 中的代码

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [LJCaughtException setDefaultHandler];
    // Override point for customization after application launch.
    return YES;
}

application:didFinishLaunchingWithOptions:中注册异常处理函数,所有的异常注册和异常处理函数的代码都封装到LJCaughtException.m中,如下:

///先前注册的处理句柄
NSUncaughtExceptionHandler *preHander;

/// 异常处理函数
void UncaughtExceptionHandler(NSException * exception)
{
    [LJCaughtException  processException:exception];
}

@implementation LJCaughtException

+ (void)setDefaultHandler
{
        ///首先保存先前注册的异常处理句柄
    preHander = [LJCaughtException getHandler];
    ///注册异常处理句柄
    NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
}

+ (NSUncaughtExceptionHandler *)getHandler
{
    return NSGetUncaughtExceptionHandler();
}

///异常处理句柄
+ (void)processException:(NSException *)exception
{
    /// 异常的堆栈信息
    NSArray *aryCrashBackTrace = [exception callStackSymbols];
    if (!aryCrashBackTrace)
    {
        return;
    }
    /// 出现异常的原因
    NSString *strCrashReason = [exception reason];

    /// 异常名称
    NSString *strCrashName = [exception name];

    ....
}
... 

@end

上面代码可以分解为三个部分理解:

  1. 定义异常处理函数,异常处理函数的原型为:
typedef void NSUncaughtExceptionHandler(NSException *exception);
  1. 注册异常处理函数:使用NSSetUncaughtExceptionHandler注册异常处理函数,注册的代码为:NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler)

  2. 执行异常处理函数:当异常发生时,自动执行异常处理函数。异常处理函数内部完成收集Crash信息的功能。

下面是在Debug和Release模式下,Crash时捕获的线程回溯:


收集、解析IOS崩溃日式10

可以看出,使用系统的API可以完美的捕获到崩溃日志,而且符号化了,一行代码 callStackSymbols 就获取了异常线程的回溯并完成了符号化工作。其实,事情没有这么简单,不妨试试发布包,是不是也能像在debug和release模式那样,获取到符号化的异常线程回溯?

发布包没带符号表

将测试程序打为发布包,查看异常线程回溯图,如下:

发布包的Crash日志


收集、解析IOS崩溃日式11

图中红框是异常线程的关键回溯,显示的是镜像的名字,没有被转化为有效的代码符号。为什么?

仔细想想,前面提到符号化的前提条件,是得有符号表,那么我们推测debug和release的APP包含了符号表,而发布包没有包含符号表,是不是?在终端中使用nm命令验证下。

收集、解析IOS崩溃日式13

确实是,发布包没有符号表,为什么?

原来,符号表是一个debug产物,如果使用archive模式打包,那么符号表会被剪裁掉。不过你也可以在Xcode的编译选项中配置为符号表不剪裁。方法是设置Strip Style选项为Debugging Symbols。下图是设置发布包带符号表的方法:

image

但是这会让最后生成的IPA变大不少(5%)。用我们项目测试,居然大了约30%,可能是代码太多的原因吧。这个对于严格限制APP大小的人来说,是无法接受的。

天无绝人之路,在使用archive打包时,生成了一个dSYM符号文件,这个文件不发布,在本地保存着。这个文件太有用了,也是我们符号化的唯一选择了。

显然,对于发布到用户手中的发布包,在程序Crash后,不能在用户设备上完成符号化工作,callStackSymbols只能返回带地址的日志信息,需要我们线下符号化,还好苹果提供了一个命令行工具—–atos,完成符号化工作。

若想通过atos工具在符号文件中查找到地址对应的符号,需要代码构架、镜像加载地址这两个参数,查看发布包的Crash日志图片,这两个参数都没有,怎么办?只能祭出OS X ABI Mach-O File Format ReferenceKSCrash 开源框架这两个终极神器。

OS X ABI Mach-O File Format Reference阐述了可执行二进制程序的存储格式,提供原理性的支撑。

KSCrash包含了获取代码构架和镜像加载地址的代码。

依据这两个神器,我们可以顺利的拿到代码构架、镜像加载地址。

Mach-O File Format

Mach-O 是Mach object 的意思,就是OS X系统中对象文件的存储格式,对象文件包括:

  1. kernel extensions
  • command-line tools
  • applications
  • frameworks
  • libraries (shared and static)

详细的可以参考Mach-O Programming Topics

一个Mach-O 文件包括下面三个部分

  1. Header: Specifies the target architecture of the file, such as PPC, PPC64, IA-32, or x86-64.
  2. Load commands: Specify the logical structure of the file and the layout of the file in virtual memory.
  3. Raw segment data: Contains raw data for the segments defined in the load commands.

下面是官网上的一张图形化的Mach-O结构示意图:


image

下面依次讲解这三部分,他们的数据结构定义在mach-o/loader.h中。我们通过三种方式来呈现Mach-O文件结构:

  1. 代码定义
  • 通过命令行工具otool呈现
  • 通过MachOView呈现。

这其中otool是系统自带的对象文件查看工具。MachOView 是网上下载的可视化查看Mach-O结构工具。由于存在两个代码构架,armv7s、ARM64,他们的定义稍微有点区别,仅以ARM64构架为例。

header

header的数据结构的定义如下:

struct mach_header_64 
{
    uint32_t    magic;              ///魔数,标记这个是Mach-O文件
    cpu_type_t    cputype;      ///cup 的类型
    cpu_subtype_t    cpusubtype;
    uint32_t    filetype;    
    uint32_t    ncmds;             /// load commands 个数
    uint32_t    sizeofcmds;  
    uint32_t    flags;        
    uint32_t    reserved;
};

终端中查看header:

otool -hV ~/Desktop/收集、解析IOS崩溃日式/Exception/UncaughtException_archive 

输出如下:

 magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
   MH_MAGIC     ARM         V7  0x00     EXECUTE    23       2432   NOUNDEFS DYLDLINK TWOLEVEL PIE
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64   ARM64        ALL  0x00     EXECUTE    23       2872   NOUNDEFS DYLDLINK TWOLEVEL PIE

MachOView显示的结果:


image
  1. magicMH_MAGIC_64,固定值:0xfeedfacf,标记这是一个Mach-O文件
  • filetype 文件类型是EXECUTE,可执行程序
  • ncmds,load command个数是23

load Command

load Command 种类特别多,大概有60多种,每种command的数据结构是不同的, 不会去一一的说明,只拿LC_SEGMENT、LC_SYMTAB 做个示例。下面列表了部分load command。

#define    LC_SEGMENT    0x1    /* segment of this file to be mapped */
#define    LC_SYMTAB    0x2    /* link-edit stab symbol table info */
#define    LC_SYMSEG    0x3    /* link-edit gdb symbol table info (obsolete) */
#define    LC_THREAD    0x4    /* thread */
#define    LC_UNIXTHREAD    0x5    /* unix thread (includes a stack) */
#define    LC_LOADFVMLIB    0x6    /* load a specified fixed VM shared library */
.....   

LC_SEGMENT

LC_SEGMENT: segment load command indicates that a part of this file is to be mapped into a 64-bit task’s address space.

说白了,就是映射到内存中的所有数据,自然包括代码、数据等等。

segment进一步可以分为

  1. **PAGEZERO: 该类型的segment是可执行程序的第一个segment,代表指针地址NULL。
  • **TEXT: 就是可执行代码,当然是只读了
  • **DATA: 可写的数据segment,应该就是代码中的变量区域
  • **OBJC: Objective-C runtime support library
  • **IMPORT
  • **LINKEDIT: contains raw data used by the dynamic linker, such as symbol, string, and relocation table entries。

每种segment可能包含多种类型的内容,例如**TEXT代码段,可以有代码(**text)、字符串(**cstring) 、常量(**const)、符号(**symbol_stub)、字面量(**literal4、__literal8),所以进一步用二级目录(section)表示。下面是segment、section的数据结构:

struct segment_command_64 
{ 
    /* for 64-bit architectures */
        uint32_t    cmd;        /* LC_SEGMENT_64 */
        uint32_t    cmdsize;    /* includes sizeof section_64 structs */
        char        segname[16];    /* segment name */
        uint64_t    vmaddr;        /* memory address of this segment */
        uint64_t    vmsize;        /* memory size of this segment */
        uint64_t    fileoff;    /* file offset of this segment */
        uint64_t    filesize;    /* amount to map from the file */
        vm_prot_t    maxprot;    /* maximum VM protection */
        vm_prot_t    initprot;    /* initial VM protection */
        uint32_t    nsects;        /* number of sections in segment */
        uint32_t    flags;        /* flags */
};

struct section_64 
{ 
    /* for 64-bit architectures */
    char        sectname[16];    /* name of this section */
    char        segname[16];    /* segment this section goes in */
    uint64_t    addr;        /* memory address of this section */
    uint64_t    size;        /* size in bytes of this section */
    uint32_t    offset;        /* file offset of this section */
    uint32_t    align;        /* section alignment (power of 2) */
    uint32_t    reloff;        /* file offset of relocation entries */
    uint32_t    nreloc;        /* number of relocation entries */
    uint32_t    flags;        /* flags (section type and attributes)*/
    uint32_t    reserved1;    /* reserved (for offset or index) */
    uint32_t    reserved2;    /* reserved (for count or sizeof) */
    uint32_t    reserved3;    /* reserved */
};

终端输入:

otool -lV ~/Desktop/收集、解析IOS崩溃日式/Exception/UncaughtException_archive

输出:

   ........
   cmd LC_SEGMENT_64
 cmdsize 712
 segname __TEXT
  vmaddr 0x0000000100000000
  vmsize 0x0000000000008000
 fileoff 0
filesize 32768
 maxprot r-x
 initprot r-x
  nsects 8
   flags (none)
   .......

MachOView显示的结果:


image

图中直观的显示出了LC_SEGMENT的数据、LC_SEGMENT的二级目录section的数据。

LC_SYMTAB

LC_SYMTAB的数据结构如下:

struct symtab_command {
    uint32_t    cmd;        /* LC_SYMTAB */
    uint32_t    cmdsize;    /* sizeof(struct symtab_command) */
    uint32_t    symoff;        /* symbol table offset */
    uint32_t    nsyms;        /* number of symbol table entries */
    uint32_t    stroff;        /* string table offset */
    uint32_t    strsize;    /* string table size in bytes */
};

终端输出的结果:

Load command 6
     cmd LC_SYMTAB
 cmdsize 24
  symoff 132944
   nsyms 48
  stroff 133916
 strsize 1152

MachOView看到的结果:


image

LC_SYMTAB 指定了符号的个数和相对Mach-O的偏移量。

数据部分

紧跟着 load command 后面的是数据部分,就是各个 load command 对应的具体数据。

小小结

Mach-O文件的格式非常像一篇文章的结构:

  1. Header部分是文章的摘要,总体描述了非常重要部分。
  • Load commands 相当于目录,Mach-O文件所有内容的索引。
  • Raw segment data 正文内容。

Mach-O 文件格式就是一个规范,各个部分都有自己的数据格式,内容繁多,只能多看。
不过之前提到了一个有用的工具—otool,查看Mach-O对象文件的命令行工具。

获取构架、镜像加载地址

上面说了那么多Mach-O文件结构,主要是提供原理支撑,目的是通过对Mach-O文件结构的理解,找到获取构架、镜像加载地址的方法。

构架很好获取,就在Mach-O的文件头中,获取的关键代码如下:

/*
 获取代码的构架
 */
NSString * getCodeArch()
{
    NSString *strSystemArch =nil;

    ///获取应用程序的名称
    NSDictionary *dicInfo =   [[NSBundle mainBundle] infoDictionary];
    if (LJM_Dic_Not_Valid(dicInfo))
    {
        return strSystemArch;
    }
    NSString *strAppName = dicInfo[@"CFBundleName"];
    if (!strAppName)
    {
        return strSystemArch;
    }

    ///获取  cpu 的大小版本号
    uint32_t count = _dyld_image_count();
    cpu_type_t cpuType = -1;
    cpu_type_t cpuSubType =-1;

    for(uint32_t iImg = 0; iImg < count; iImg++)
    {
        const char* szName = _dyld_get_image_name(iImg);
        if (strstr(szName, strAppName.UTF8String) != NULL)
        {
            const struct mach_header* machHeader = _dyld_get_image_header(iImg);
            cpuType = machHeader->cputype;
            cpuSubType = machHeader->cpusubtype;
            break;
        }
    }

    if(cpuType < 0 ||  cpuSubType <0)
    {
        return  strSystemArch;
    }
    ///转化cpu 版本为文字类型
    switch(cpuType)
    {
        case CPU_TYPE_ARM:
        {
            strSystemArch = @"arm";
            switch (cpuSubType)
            {
                case CPU_SUBTYPE_ARM_V6:
                    strSystemArch = @"armv6";
                    break;
                case CPU_SUBTYPE_ARM_V7:
                    strSystemArch = @"armv7";
                    break;
                case CPU_SUBTYPE_ARM_V7F:
                    strSystemArch = @"armv7f";
                    break;
                case CPU_SUBTYPE_ARM_V7K:
                    strSystemArch = @"armv7k";
                    break;
#ifdef CPU_SUBTYPE_ARM_V7S
                case CPU_SUBTYPE_ARM_V7S:
                    strSystemArch = @"armv7s";
                    break;
#endif
            }
            break;
        }
#ifdef CPU_TYPE_ARM64
        case CPU_TYPE_ARM64:
            strSystemArch = @"arm64";
            break;
#endif
        case CPU_TYPE_X86:
            strSystemArch = @"i386";
            break;
        case CPU_TYPE_X86_64:
            strSystemArch = @"x86_64";
            break;
    }
    return strSystemArch;
}

主要思路是:通过 _dyld_image_count 获取到所有的镜像个数,然后根据镜像索引(0…镜像个数-1),依次枚举出镜像的名字,然后,镜像名字使用_dyld_get_image_header函数获取到镜像的header结构体信息,赋值到:mach_header* machHeader 中。最后,通过 machHeader->cputype( CPU的类型)和 machHeader->cpusubtype(CPU的子类型)转化为具体的代码构架。

对于镜像的加载地址,其实就是镜像的header结构体的首地址。详细代码如下:

/*
 获取应用程序的加载地址
 */
NSString * getImageLoadAddress()
{
    NSString *strLoadAddress =nil;

    NSString * strAppName = getAppName();
    if (!strAppName)
    {
        return strLoadAddress;
    }

    ///获取应用程序的load address
    uint32_t count = _dyld_image_count();
    for(uint32_t iImg = 0; iImg < count; iImg++)
    {
        const char* szName = _dyld_get_image_name(iImg);
        if (strstr(szName, strAppName.UTF8String) != NULL)
        {
            const struct mach_header* header = _dyld_get_image_header(iImg);
            strLoadAddress = [NSString stringWithFormat:@"0x%lX",(uintptr_t)header];
            break;
        }
    }
    return strLoadAddress;
}

主要思路就是:利用_dyld_get_image_header获取镜像的header结构体,header结构体是整个Mach-O的起始部分,所以,header结构体的首地址就是镜像的加载地址。

好了,到目前为止,使用atos符号化崩溃日志的三个条件(符号文件、代码构架、镜像加载地址)都有了,那么我们就可以完成异常地址的符号化工作了。所以,到目前为止,我们定制的异常系统基本完成了,收集功能、符号化动能都有了。下面来看看我们的系统输出的内容。

输出Crash日志

本崩溃收集系统的输出格式使用 JSON 格式,输出的信息包括 arch、CrashName、CrashReason、CrashBackTrace、CrashSystemVersion 。有了这些信息,我们完全可以符号化崩溃地址了。

{
  "strCrashArch" : "arm64",         ///代码构架
  "strCrashName" : "NSRangeException",
  "strCrashSystemVersion" : "10.0.2",
  "strCrashReason" : "*** -[__NSArrayI objectAtIndex:]: index 2 beyond bounds [0 .. 1]",
  "aryCrashBackTrace" : [
    {
      "strStackAddress" : "0x000000018ec6c1d8",
      "strImageName" : "CoreFoundation",
      "strImageLoadAddress" : "<redacted>"
    },
    {
      "strStackAddress" : "0x000000018d6a455c",
      "strImageName" : "libobjc.A.dylib",
      "strImageLoadAddress" : "objc_exception_throw"
    },
    {
      "strStackAddress" : "0x000000018eb48584",
      "strImageName" : "CoreFoundation",
      "strImageLoadAddress" : "CFRunLoopRemoveTimer"
    },
    {
      "strStackAddress" : "0x00000001000b48a0",    ///崩溃地址
      "strImageName" : "UncaughtException",
      "strImageLoadAddress" : "0x1000B0000"       ///镜像加载地址
    },
    {
      "strStackAddress" : "0x0000000194aea7b0",
      "strImageName" : "UIKit",
      "strImageLoadAddress" : "<redacted>"
    },
    ........
    ........
    {
      "strStackAddress" : "0x0000000194b1b360",
      "strImageName" : "UIKit",
      "strImageLoadAddress" : "UIApplicationMain"
    },
    {
      "strStackAddress" : "0x00000001000b4df0",
      "strImageName" : "UncaughtException",
      "strImageLoadAddress" : "0x1000B0000"
    },
    {
      "strStackAddress" : "0x000000018db285b8",
      "strImageName" : "libdyld.dylib",
      "strImageLoadAddress" : "<redacted>"
    }
  ]
}

小结

这章,我们使用苹果的API完成了Crash日志收集系统,这个系统输出的日志可以使用atos在线下符号化。同时介绍了Mach-O的文件结构。

推荐阅读更多精彩内容