iOS Crash 流程化3:Crash 产生和符号化的原理

  • iOS Crash 流程化3:Crash 产生和符号化的原理
    • 异常类型
      • Mach异常
      • Unix信号
    • 异常的产生
    • 线程回溯
      • 符号化回溯线程
        • 符号在二进制中的偏移量
        • atos
    • 符号化内幕
      • 小小结
    • 线程的状态寄存器
    • Binary Images
    • 小结

iOS 的异常类型(Exception Type)由两部分构成:Mach异常、Unix信号异常。

异常类型

Mach异常

苹果系统有一个微内核,叫做XNU,它的源码可以在opensource上下载到。Mach是XNU的核心,因而,Mach异常就指Mach内核异常。Mach包含三部分内容:thread,task,host。后续的章节中很多地方都会用到Mach。不妨移步到Mach IPC Interface,了解下Mach暴露给用户的API。

Mach暴露给了用户部分API,允许用户和内核交互。用户态的开发者可以通过Mach API设置thread、task、host的异常端口,来捕获Mach异常,抓取Crash事件。

Mach异常包括:

#define EXC_BAD_ACCESS        1    /* Could not access memory */
    /* Code contains kern_return_t describing error. */
    /* Subcode contains bad memory address. */

#define EXC_BAD_INSTRUCTION    2    /* Instruction failed */
    /* Illegal or undefined instruction or operand */

#define EXC_ARITHMETIC        3    /* Arithmetic exception */
    /* Exact nature of exception is in code field */

#define EXC_EMULATION        4    /* Emulation instruction */
    /* Emulation support instruction encountered */
    /* Details in code and subcode fields    */

#define EXC_SOFTWARE        5    /* Software generated exception */
    /* Exact exception is in code field. */
    /* Codes 0 - 0xFFFF reserved to hardware */
    /* Codes 0x10000 - 0x1FFFF reserved for OS emulation (Unix) */

#define EXC_BREAKPOINT        6    /* Trace, breakpoint, etc. */
    /* Details in code field. */

#define EXC_SYSCALL        7    /* System calls. */

#define EXC_MACH_SYSCALL    8    /* Mach system calls. */

#define EXC_RPC_ALERT        9    /* RPC alert */

#define EXC_CRASH        10    /* Abnormal process exit */

#define EXC_RESOURCE        11    /* Hit resource consumption limit */

Unix信号

信号是通知进程已发生某种情况的软中断技术。例如:某个进程执行了除法操作,其除数为0,则将名为SIGFPE(浮点异常)的信号发送给该进程。

异常的产生

那么,怎么会有两种异常信息呢?

念茜的漫谈iOS Crash收集框架阐述了两者的关系,这里再重复下。

苹果系统是基于Unix系统的,苹果的大牛们为了兼容Unix信号,将Mach异常转化为Unix信号,并投射到异常的线程,这样做的目的是:对于不懂Mach异常的人,也可以使用Unix信号捕获异常。所以,Crash日志有两种异常信息。

Mach和Unix关系图:


image

所有Mach异常都在host层被ux_exception转换为相应的Unix信号,并通过threadsignal将信号投递到出错的线程。

捕获Mach异常或者Unix信号都可以抓到crash事件,这两种方式哪个更好呢?

优选Mach异常,因为Mach异常的处理会先于Unix信号处理,如果Mach异常的handler让程序exit了,那么Unix信号就永远不会到达这个进程了。

所以,Crash日志中的EXC_BAD_ACCESS 是Mach异常信息,SIGSEGV是Unix信号异常信息。

小贴士:
因为硬件产生的信号(通过CPU陷阱)被Mach层捕获,然后才转换为对应的Unix信号;苹果为了统一机制,于是操作系统和用户产生的信号(通过调用kill和pthread_kill)也首先沉下来被转换为Mach异常,再转换为Unix信号。

线程回溯

符号化回溯线程

线程的回溯是APP Crash瞬间,程序中所有线程的逆向调用堆栈。线程回溯对我们修复Crash非常有用,根据线程回溯,可以分析、定位程序崩溃的原因。

下面将崩溃的代码、未符号化崩溃日志、符号化崩溃日志贴出来,做个对比性的理解。

@implementation ViewController

- (IBAction)onCrash:(__unused id)sender {
    char* ptr = (char*)-1;
    *ptr = 10;  ///这里程序崩溃了 
}

@end

未符号化的崩溃日志

image

符号化的崩溃日志

image

符号在二进制中的偏移量

未符号化的崩溃日志中红色文字展示了几个名词:镜像文件、加载地址、堆栈地址。以及没有展示出来的一个名词:符号在二进制中的偏移量。他们的含义分别为:

  • 镜像文件:是可执行二进制文件和二进制文件依赖的动态库的总称。
  • 堆栈地址:是代码在内存中执行的内存地址。
  • 镜像的加载地址:程序执行时,内核会将包含程序代码的镜像加载到内存中,镜像在内存中的基地址就是加载地址。程序每次启动时,镜像的加载地址是随机的。所以,同一代码在不同的设备中执行时,堆栈地址是不一样的。
  • 符号在二进制中的偏移量:按照字面意思理解吧。它以通过下面的公式得到:
符号在二进制中的偏移量 = 堆栈地址 - 镜像的加载地址  

符号在二进制中的偏移量非常有用,我们就是根据它,从符号文件中查找出地址对应的代码符号。这里的符号文件指的是:带有符号表的可执行二进制文件、dSYM文件,这两种文件在后续章节中都统称为符号文件。

那么怎么将未符号化的崩溃日志符号化呢?

atos

苹果自带的atos命令行工具可以查找地址对应的符号,在终端中输入:

/usr/bin/atos -o [符号文件] -arch arm64 -l 0x100030000 0x000000010003522c 

输出结果如下:

-[ViewController onCrash:] (in Simple-Example) (ViewController.m:10)

是不是很简单的就将地址转换为符号?是的,只需将符号文件(-o指定)、代码构架(-arch指定)、加载地址(-l指定)、堆栈地址传入atos命令,就能解析出符号。

atos命令解析出了堆栈地址为0x000000010003522c、加载地址为0x100030000对应的符号。符号为[ViewController onCrash:],也验证了崩溃发生在onCrash函数中,也验证了崩溃日志中的地址是可以符号化的。

符号化原理是什么?怎么就通过地址找到了Crash代码的符号,这就涉及符号化内幕。

符号化内幕

符号化的内幕就是:==在符号文件中,通过偏移量查找符号==。下面,一步步的来分析,首先计算Crash地址在符号文件中的偏移量,为000000010000522c。

符号在二进制中的偏移量 = 堆栈地址 - 镜像的加载地址 = 0x000000010003522c -  0x100030000 = 000000010000522c

在符号文件中直接找地址000000010000522c,应该是找不到,在后续你可以理解。我们使用逆向方法,根据符号-[ViewController onCrash:],找对应的地址,比较是不是000000010000522c,如果是,就充分说明了,通过偏移量是可以查找到内存地址对应的符号的。在终端中输入下面的命令:

nm [符号文件] | grep "ViewController onCrash:"

输出如下

00008320 t -[ViewController onCrash:]
0000000100005224 t -[ViewController onCrash:]

输出的第一行是armv7s构架的符号,第二行是arm64构架的符号,Crash日志显示的代码构架是arm64,使用第二行,符号-[ViewController onCrash:]对应的偏移量是0000000100005224,而不是 000000010000522c,这个公式是在stack overflow上找到的,就相差8!后来写日志组织测试用例的时候,忽然明白了为什么差那一点点。

原来,我们通过nm命令查找出的符号地址对,是函数入口地址和对应的函数调用的符号对,仅仅是函数调用的符号,没有函数内部代码的符号,而程序是崩溃到函数内部,崩溃到*ptr = 10这句话,内部代码的地址怎么可能和入口地址一样呢!相差一点点!

下面根据偏移量000000010000522c和代码推算函数的入口地址吧,看看是什么。崩溃代码*ptr = 10前面只有一个语句—定义初始化指针char* ptr = (char*)-1,在64位系统上指针的地址占8个字节,000000010000522c - 8= 0000000100005224,果然是0000000100005224。

这个不就是函数的入口地址嘛。原来那一点点的原因在这里。那么偏移量0000000100005224 对应的符号正是-[ViewController onCrash:]

上面通过nm 命令查找符号可能不直观,可以通过可视化工具MachOView查看。验证下吧,选择 Debug Symbols(ARM64_ALL)->Symbol Table->Symbols,然后在右上角的搜索框中输入符号:-[ViewController onCrash:],结果如下:

image

通过这个工具可以直观的查看到符号和地址的对应关系。

小小结

终于把符号化和符号化原理阐述完了简单回顾下:

  1. 可以通过系统的atos符号化崩溃日志的单个符号
  2. 符号化内部原理就是:根据符号在二进制中的偏移量,在符号文件中查找对应的符号。其中:==符号在二进制中的偏移量 = 堆栈地址 - 镜像的加载地址==。

线程的状态寄存器

Thread 0 crashed with ARM Thread State (64-bit):
    x0: 0x000000010050b460   x1: 0x0000000100102cea   x2: 0x00000001004339d0   x3: 0x00000001740f8f00
    x4: 0x00000001740f8f00   x5: 0x00000001740f8f00   x6: 0x0000000000000001   x7: 0x0000000000000000
    x8: 0xffffffffffffffff   x9: 0x000000000000000a  x10: 0x00000001b3ad0018  x11: 0x00c1580100c15880
   x12: 0x0000000000c15800  x13: 0x0000000000c15900  x14: 0x0000000000c158c0  x15: 0x0000000000c15801
   x16: 0x0000000000000000  x17: 0x00000001000c1224  x18: 0x0000000000000000  x19: 0x00000001740f8f00
   x20: 0x00000001004339d0  x21: 0x0000000100102cea  x22: 0x000000010050b460  x23: 0x0000000170240bd0
   x24: 0x000000017400db90  x25: 0x0000000000000001  x26: 0x0000000000000000  x27: 0x00000001b2822000
   x28: 0x0000000000000040   fp: 0x000000016fd41ab0   lr: 0x0000000194aea7b0
    sp: 0x000000016fd41a90   pc: 0x00000001000c122c cpsr: 0x60000000

这是APP crash的时候,ARM64 构架CPU的32个寄存器的值, 其中 fp 帧指针、sp 堆栈指针,lr 是返回地址指针,这三个都比较有用,用来逐级回溯线程调用栈。

Binary Images

image

镜像文件就是上面讲的可执行程序依赖的所有动态库

镜像文件中包括镜像的加载地址,和线程回溯中的镜像加载地址指的是一个地址。加载地址后面有个UUID,符号文件中也有个UUID,只有这两个地址一致,才能解析出地址对应的符号。符号文件中的UUID可以通过终端中输入下面的命令得到:

dwarfdump —u [符号文件]

输出如下:

UUID: C8E0E6E4-F761-3A19-B231-A31C1BB9037A (armv7) 
UUID: 39BBB8F4-CCB0-3193-8491-C007931CA05E (arm64) 

第二行的arm64构架的UUID居然和图8中的红色矩形框中UUID必须一致,这才表示代码对应的符号能在这个符号文件中找到,如果不一致,就没法解析出地址对应的符号。不论是Xcode,还是symbolicatecrash,都解析不了。

也可以通过MachOView查看符号文件的UUID,结果如下:

image

小结

这里阐述了日志的产生原因和符号化崩溃日志的原理。同时提及了几个有用的工具:

  1. file 文件类型显示工具(The file-type displaying tool,位于/usr/bin/file);
  2. atos (将数字地址转换为镜像或可执行程序中的符号工具,convert numeric addresses to symbols of binary images or processes,位于/usr/bin/atos);
  3. nm(符号表展示工具,The symbol table display tool,位于 /usr/bin/nm);
  4. 可视化查看Mach-O工具,MachOView

推荐阅读更多精彩内容

  • [这是第14篇] 序: iOS Crash问题是iOS开发中难以忽视的存在,本文就捕获iOS Crash、Cras...
    南华coder阅读 9,452评论 21 114
  • 本文就捕获iOS Crash、Crash日志组成、Crash日志符号化、异常信息解读、常见的Crash五部分介绍。...
    xukuangbo_阅读 1,229评论 0 0
  • Ref:iOS Crash 捕获及堆栈符号化思路剖析 iOS Crash 流程化:概览崩溃捕获Mach 异常捕获U...
    Vinc阅读 802评论 0 0
  • 《泥步修行》这本书是我第一次通过正版购买的方式获取的余老著作,算是对余老迟到的敬意吧。余老师的书,我读过了不少,深...
    放纵天涯阅读 771评论 0 0
  • 已默认读者了解本篇自言自语的context,且对于module有所了解,对于module的相关扩展说明将穿插在内容...
    蒋启钲阅读 911评论 0 4
  • 题记 首先,写此文的目的并不是冠冕堂皇地以一个圣人的角度,站在道德的制高点去批判什么人,什么现象,而是分享自己的一...
    Helle_阅读 628评论 1 5
  • 和美腻的叶老师聊天,我说我觉得自己可能有精神分裂。一方面我始终不明白人为什么要过得那么拧巴呢,不开心就散啊,为什么...
    小希24阅读 76评论 0 0
  • 每个人的心底,住了一个小小的小孩,他的名字叫烦恼。烦恼的心里想着什么,你就会烦恼什么。我的心里也有,在我的心里...
    段文昊阅读 191评论 0 0