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
上面代码可以分解为三个部分理解:
- 定义异常处理函数,异常处理函数的原型为:
typedef void NSUncaughtExceptionHandler(NSException *exception);
注册异常处理函数:使用
NSSetUncaughtExceptionHandler
注册异常处理函数,注册的代码为:NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler)
执行异常处理函数:当异常发生时,自动执行异常处理函数。异常处理函数内部完成收集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 Reference和KSCrash 开源框架这两个终极神器。
OS X ABI Mach-O File Format Reference阐述了可执行二进制程序的存储格式,提供原理性的支撑。
KSCrash包含了获取代码构架和镜像加载地址的代码。
依据这两个神器,我们可以顺利的拿到代码构架、镜像加载地址。
Mach-O File Format
Mach-O 是Mach object 的意思,就是OS X系统中对象文件的存储格式,对象文件包括:
- kernel extensions
- command-line tools
- applications
- frameworks
- libraries (shared and static)
详细的可以参考Mach-O Programming Topics
一个Mach-O 文件包括下面三个部分
- Header: Specifies the target architecture of the file, such as PPC, PPC64, IA-32, or x86-64.
- Load commands: Specify the logical structure of the file and the layout of the file in virtual memory.
- Raw segment data: Contains raw data for the segments defined in the load commands.
下面是官网上的一张图形化的Mach-O结构示意图:
下面依次讲解这三部分,他们的数据结构定义在mach-o/loader.h中。我们通过三种方式来呈现Mach-O文件结构:
- 代码定义
- 通过命令行工具
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显示的结果:
-
magic
是MH_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进一步可以分为
-
**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文件的格式非常像一篇文章的结构:
- 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的文件结构。