iOS Crash 流程化4:打造自己的收集、符號化程序 Table of Contents

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時捕獲的線程回溯:


可以看出,使用系統的API可以完美的捕獲到崩潰日誌,而且符號化了,一行代碼 callStackSymbols 就獲取了異常線程的回溯並完成了符號化工作。其實,事情沒有這麼簡單,不妨試試發佈包,是不是也能像在debug和release模式那樣,獲取到符號化的異常線程回溯?

發佈包沒帶符號表

將測試程序打爲發佈包,查看異常線程回溯圖,如下:

發佈包的Crash日誌


圖中紅框是異常線程的關鍵回溯,顯示的是鏡像的名字,沒有被轉化爲有效的代碼符號。爲什麼?

仔細想想,前面提到符號化的前提條件,是得有符號表,那麼我們推測debug和release的APP包含了符號表,而發佈包沒有包含符號表,是不是?在終端中使用nm命令驗證下。

確實是,發佈包沒有符號表,爲什麼?

原來,符號表是一個debug產物,如果使用archive模式打包,那麼符號表會被剪裁掉。不過你也可以在Xcode的編譯選項中配置爲符號表不剪裁。方法是設置Strip Style選項爲Debugging Symbols。下圖是設置發佈包帶符號表的方法:

但是這會讓最後生成的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結構示意圖:


下面依次講解這三部分,他們的數據結構定義在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顯示的結果:


  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顯示的結果:


圖中直觀的顯示出了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看到的結果:


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的文件結構。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章