Crash 符号化 3. Mach-O 与 atos

继续符号化这个话题,之前谈到了 Xcode 通过调用 symbolicatecrash 命令进行符号化,而这个命令中除了查找符号表以外,另外一件非常重要的事情就是调用 atos。所以,继续学习 atos

环境信息

Xcode 9.2


atos(Address to symbol)是将地址转为符号的命令,源码可以参考 facebook 开源的 C 语言版本 atosl,虽然 facebook 表示这个库已经停止更新,任何风险自行承担,但是并不妨碍我们学习源码。

整个解析过程就是对符号表的读取,所以首先要知道应该怎么看符号表。符号表为 Mach-O 文件,可以借助 otool 和可视化工具 MachOView,当然,也可以选择 hopper。这里我用的 MachOView

Mach-O

Mach-O(Mach Object)是一种文件格式,macOS、iOS 等系统上的可执行文件、 libraries 和 object code 均使用这种格式。

首先需要了解的就是它的布局方式(Mach-O file layout)。借用网上流传得很广泛的一张图来解释一下:

Mach-O 主要分为三部分:Header、Load commands、Data。读取过程中,每一个部分都标明了各自的大小,读取 Data 部分时,也有对应的 offset 值,所以非常方便。

Fat Header

如果直接用编辑器打开 Mach-O 打开,可以看到一串地址,通过 <mach-o/fat.h> 中对 fat header 的定义,可以知道前几位的含义:

struct fat_header {
uint32_t magic; /* FAT_MAGIC or FAT_MAGIC_64 */
uint32_t nfat_arch; /* number of structs that follow */
};

前 4 个字节为 magic,紧接着的 4 个字节表示 fat 包含的架构数(之前这里写错了,感谢 @Jun_陈军 指正):

magic 的定义也可以在 fat.h 中看到,它对应着两种 fat_arch 结构,通过读取 magic 就可以知道对应的 fat_arch 布局方式。

除了直接看定义以外,也可以利用之前提到的工具来进行查看:

otool

otool 是一个展示 object file 的工具,通过传入不同的参数,来读取不同的片段。

# 可以利用 otool -h 来读取 header 部分
otool -h ~/Desktop/test_dsym/TestSymbol

MachOView

之后都会使用 MachOView 这个 app,比较直观。用 MachOView 打开符号表的 Mach-O 文件,可以看到已经解析完成的 fat header:

需要关心的是 offset 字段,可以直接通过偏移量,找到对应架构的的地址。

可以看到当前符号表包含两种架构,直接来看 ARM64 吧。偏移 756448 字节,就是该架构的起始地址。

mach header 的结构在 <mach-o/loader.h> 这个头文件中,之后读取 load commands 的结构,也都定义在这个文件。同样,这个 header 也分为 32 和 64 两种内存布局:

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

Header 和 Load Commands 之间的地址连续,所以 offset + sizeof(mach_header) 就可以读取到第一个 Load Command。

每个 Load Command 包含 cmdcmdsize,即类型与大小:

struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};

头文件中定义了不同的 cmd 类型,可以将它看成枚举。atosl 中的代码大致是:

switch (load_command.cmd) {
case LC_UUID:
ret = parse_uuid(obj, load_command.cmdsize);
break;
case LC_SEGMENT:
ret = parse_segment(obj, load_command.cmdsize);
break;
case LC_SEGMENT_64:
ret = parse_segment_64(obj, load_command.cmdsize);
break;
case LC_SYMTAB:
ret = parse_symtab(obj, load_command.cmdsize);
break;
...
}

这里我们需要关心三个地方: LC_UUIDLC_SYMTABLC_SEGMENT(__TEXT)

LC_UUID

每个符号表的每个架构都有不同的 UUID,如果 UUID 不匹配,是无法正确符号化的。获取 UUID 的方式很多,一一介绍一下:

通过 otool -l 显示整个 Load Commands,然后找到对应的 UUID:

otool -l ~/Desktop/test_dsym/Test | grep uuid

更好的方式是使用之前提到过的 dwarfdump

dwarfdump --uuid ~/Desktop/test_dsym/Test

当然,最直观的还是 MachOView 🤡:

结构体定义:

struct uuid_command {
uint32_t cmd; /* LC_UUID */
uint32_t cmdsize; /* sizeof(struct uuid_command) */
uint8_t uuid[16]; /* the 128-bit uuid */
};

LC_SYMTAB

继续往下读取,就可以读到 LC_SYMTAB,其中需要关注的是 Symbol Table OffsetString Table Offset 字段,它指明了符号表和代码信息的偏移量。

这里的偏移量是从 mach header 开始,4096 也就是 0x1000,加上 mach header 起始的 0x00C8E00,即 0xC9E00,这也就是符号表的起始地址:

而这里,就是存储的方法名地址、对应虚拟地址等信息了。这部分定义在 <mach-o/nlist.h> 中:

struct nlist_64 {
union {
uint32_t n_strx; /* index into the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};

符号化的过程,就是找到通过 value 找到 n_strx,再通过之前读取的 String Table Offset 找到方法名的过程。

LC_SEGMENT(__TEXT)

由于 ASLR 的原因,在程序启动时,会在指定的进程空间上加上一个偏移量(slide),导致我们看到的 stack address(crash 日志中的地址)和 symbol address(符号对应的地址)无法对应。

Address space layout randomization (ASLR) is a memory-protection process for operating systems (OSes) that guards against buffer-overflow attacks by randomizing the location where system executables are loaded into memory.

Emmm…大致意思就是:随机内存布局(ASLR)是操作系统为防止缓冲区溢出攻击而存在的内存保护机制。该机制通过在程序载入内存时,将地址进行随机偏移来实现。

这个偏移量是每次程序启动时给出的随机值,运行时可以通过 _dyld_get_image_vmaddr_slide 函数获得。但是在符号表中并不能体现。

LC_SEGMENT(__TEXT)VM Address 为未偏移的虚拟地址:

加上偏移的虚拟地址,可以在 crash log 中的 Binary Images 找到:

Binary Images 中的地址,为二进制加载的起止位置。所以可以通过计算获得偏移量:

slide = load address - vm address

偏移量 = 0x1000b4000 - 4294967296 = 0xb4000

符号化

那么,下一步,进行符号化。

来看下以上三个地址(因为 Demo 太简单了,所以调用栈很短,最后一个还是 main,为了更好的介绍,就不符号化 main 了,直接看上面两个地址。这两个地址是我用系统符号化之后,得到的两个属于 Test 的 crash 地址)。

0x1000ba9c80x1000ba964 都是经过偏移的地址,所以首先减去偏移量:

symbol address = stack address - slide

对应符号的地址_1 = 0x1000ba9c8 - 0xb4000 = 0x1000069C8
对应符号的地址_2 = 0x1000ba964 - 0xb4000 = 0x100006964

到这里,可以通过调用 dwarfdump 命令查看地址对应的方法名:

dwarfdump --arch arm64 --lookup 0x1000069C8 ~/Desktop/test_dsym/Test

可以在输出中看到对应的文件、方法名等信息:

当然,通过 MachOView 也可以查看:

当前地址为 0x1000069C8,也就对应着 -[ViewController method1] 方法。

优化

具体 atos 的实现不得而知,估计也躲不过这个套路。针对 symbolicatecrashatos 的调用,列出了一些优化点,因为没有具体实施,所以不清楚优化效果如何。

  • 符号表加载:每次都从硬盘中加载符号表,在符号多的情况下开销还是挺大的。可以针对常用符号进行缓存,比如 CoreFoundationlibdispatch 之类的。
  • 符号查找:不太清楚 atos 的查找方式,但是根据目前网上的代码看,很多代码都是拿到 symbol address 后,开始遍历 Symbol Table 进行查找。但其实 Symbol Table 部分地址连续,可以直接上二分,效率能提升不少(这部分是组内小伙伴优化后得出的结论,但是是对比的网上代码,并不是 atos 实现)。

参考