介绍
Mach-O 的全称是 Mach Object File Format。可以是可执行文件,目标代码或共享库,动态库。Mach 内核的操作系统比如 macOS,iPadOS 和 iOS 都是用的 Mach-O。Mach-O 包含程序的核心逻辑,以及入口点主要功能。
通过学习 Mach-O,可以了解应用程序是如何加载到系统的,如何执行的。还能了解符号查找,函数调用堆栈符号化等。更重要的是能够了解如何设计数据结构,这对于日后开发生涯的收益是长期的。了解这些对于了解编译和逆向工程都会有帮助,你还会了解到动态链接器的内部工作原理以及字节码格式的信息,Leb128字节流,Mach 导出时 Trie 二进制 image 压缩。
对于 Mach-O,你一定不陌生,但是对于它内部逻辑你一定会好奇,比如它是怎么构建出来的,组织方式如何,怎么加载的,如何工作,谁让它工作的,怎样导入和导出符号的。
接下来我们先看看怎么构建一个 Mach-O 文件的吧。
构建
构建 Mach-O 文件,主要需要用到编译器和静态链接器,编译器可以将编写的高级语言代码转成中间目标文件,然后用静态链接器把中间目标文件组合成 Mach-O。
编译器驱动程序使用的是 clang,有编译、组装和链接的能力,调用 Xcode Tools 里的其他工具来实现源码到 Mach-O 文件生成。其他工具包括将汇编代码创建为中间目标文件的 as 汇编程序,组合中间目标文件成 Mach-O 文件的静态链接器 ld,还有创建静态库或共享库的 libtool。
构建成 Mach-O 包括中间对象文件(MH_OBJECT)、可执行二进制(MH_EXECUTE)、VM 共享库文件(MH_FVMLIB)、Crash 产生的 Core 文件(MH_CORE)、preload(MH_PRELOAD)、动态共享库(MH_DYLIB)、动态链接器(MH_DYLINKER)、静态链接文件(MH_DYLIB_STUB)、符号文件和调试信息(MH_DSYM)这几种类型。其中框架会包含Mach-O和图片、文档、接口等相关资源。
写个 main.c 文件代码:
|
|
通过 clang 构建成 Mach-O 文件 a.out。
|
|
如果有多个文件,先将多个文件生成中间目标文件,后缀是.o,使用 clang 的选项 -c。每个目标文件都是模块。使用静态链接器可以把多个模块组合成一个动态共享库。通过 ld 可以完成这个操作。使用 libtool 的选项 -static 可以构建静态库。
组合成动态库可以使用 clang 的 -dynamiclib 选项,命令如下:
|
|
静态链接就是把各个模块组合成一个整体,生成新的 Mach-O,链接的内容就是把各个模块间相互的引用能够正确的链接好,原理就是把一些指令对其他符号的地址引用进行修正。过程包含地址和空间分配,符号解析和围绕符号进行的重定位。核心是重定位,X86-64寻址方式是 RIP-relative 寻址,就是基于 RIP 来计算目标地址,通过 jumpq 跳转目标地址,就是当前指令下一条指令地址来加偏移量。
构建完 Mach-O。那你一定好奇 Mach-O 里面都有什么呢?分析 Mach-O 的工具有分析体系结构的 lipo,显式文件类型的 file,列 Data 内容的 otool,分析 image 每个逻辑信息符号的 pagestuff,符号表显示的 nm。
组成
Mach-O 会将数据流分组,每组都会有自己的意义,主要分三大部分,分别是 Mach Header、Load Command、Data。
Header
Mach Header 里会有 Mach-O 的 CPU 信息,以及 Load Command 的信息。可以使用 otool 查看内容:
|
|
结果如下:
|
|
通过 _dyld_get_image_header 函数可以获取 mach_header 结构体。GCDFetchFeed/SMCallStack.m at master · ming1016/GCDFetchFeed · GitHub 里这段代码里有判断 Mach Header 结构体魔数的函数 smCmdFirstPointerFromMachHeader,代码如下:
|
|
还有 Fat Header,里面会包含多个架构的 Header。
LLVM 中生成 Mach Header 的代码如下:
|
|
Load Command
Load Command 包含 Mach-O 里命令类型信息,名称和二进制文件的位置。
使用 otool 命令可以查看详细:
|
|
遍历 Mach Header 里的 ncmds 可以取到所有 Load Command。代码如下:
|
|
loadcommand 里的 cmd 是以 LC 开头定义的宏,可以参看 loader.h 里的定义,有50多个,主要的是:
- LC_SEGMENT_64(_PAGEZERO)
- LC_SEGMENT_64(_TEXT)
- LC_SEGMENT_64(_DATA)
- LC_SEGMENT_64(_LINKEDIT)
- LC_DYLD_INFO_ONLY
- LC_SYMTAB
- LC_DYSYMTAB
- LC_LOAD_DYLINKER
- LC_UUID
- LC_BUILD_VERSION
- LC_SOURCE_VERSION
- LC_MAIN
- LC_LOAD_DYLIB(libSystem.B.dylib)
- LC_FUNCTION_STARTS
- LC_DATA_IN_CODE
每个 command 的结构都是独立的,前两个字段 cmd 和 cmdsize 是一样的。
根据 Load Command 可以得到 Segment 的偏移量。
生成 Load Command 的代码如下:
Data
Data 由 Segment 的数据组成,是 Mach-O 占比最多的部分,有代码有数据,比如符号表。Data 共三个 Segment,TEXT、DATA、LINKEDIT。其中 TEXT 和 DATA 对应一个或多个 Section,LINKEDIT 没有 Section,需要配合 LC_SYMTAB 来解析 symbol table 和 string table。这些里面是 Mach-O 的主要数据。
生成 __LINKEDIT 的代码如下:
|
|
通过生成 LINKEDIT 的代码可以看出 LINKEDIT 里包含 dyld 所需各种数据,比如符号表、间接符号表、rebase 操作码、绑定操作码、导出符号、函数启动信息、数据表、代码签名等。
__DATA 包含 lazy 和 non lazy 符号指针,还会包含静态数据和全局变量等。可重定位的 Mach-O 文件还会有一个重定位的区域用来存储重定位信息,如果哪个 section 有重定位字节,就会有一个 relocation table 对应。
生成 relocation 的代码如下:
|
|
使用 size 命令可以看到内容的分布,使用前面生成的 a.out 来看:
|
|
结果如下:
|
|
其中__TEXT Segment 的内容有:
- Section64(TEXT,text)
- Section64(TEXT,stubs)
- Section64(TEXT,stub_helper)
- Section64(TEXT,cstring)
- Section64(TEXT,unwind_info)
__DATA Segment 的内容有:
- Section64(DATA,nl_symbol_ptr)
- Section64(DATA,la_symbol_ptr)
__LINKEDIT 的内容是:
- Dynamic Loader Info
- Function Starts
- Symbol Table
- Data in Code Entries
- Dynamic Symbol Table
- String Table
如果是 Objective-C 代码生成的 Mach-O 会多出很多和 Objective-C 相关的 Section ,我拿已阅项目生成的 Mach-O 来看。
|
|
结果如下:
|
|
可以看到 __objc 前缀的都是为了支持 Objective-C 语言新增加的。
那么 Swift 语言代码构建的 Mach-O 是怎样的呢?
使用我做启动优化时用 Swift 写的工具 MethodTraceAnalyze 看下内容有什么。结果如下:
|
|
可以看到 DATA Segment 部分还是有 objc 前缀的 Section,TEXT Segment 里已经都是 swift5 为前缀的 Section 了。
使用 otool 可以查看某个 Section 内容。比如查看 TEXT Segment 的 text Section 的内容,使用如下命令:
|
|
使用 otool 可以直接看 Mach-O 汇编内容 :
|
|
结果如下:
|
|
构建中查看代码生成汇编可以使用 clang 以下选项:
|
|
生成汇编如下:
|
|
可以发现两者汇编逻辑是一样的。点符号开头的都是汇编指令,比如.section 就是告知会执行哪个 segment,.p2align 指令明确后面代码对齐方式,这里是16(2^4) 字节对齐,0x90 补齐。在 TEXT Segment 的 text Section 里会创建一个调用帧堆栈,进行函数调用,callq printf 函数前会用到 L.str(%rip),L.str 标签会指向字符串,leaq 会把字符串的指针加载到 rdi 寄存器。最后会销毁调用帧堆栈,进行 retq 返回。
主要 Section:
- __nl_symbol_ptr:包含 non-lazy 符号指针,mach-o/loader.h 里有详细说明。服务 dyld_stub_binder 处理的符号。
- la_symbol_ptr:stubs 第一个 jump 目标地址。动态库的符号指针地址。
- got:二进制文件的全局偏移表 GOT,也包含 S_NON_LAZY_SYMBOL_POINTERS 标记的 non-lazy 符号指针。服务于 TEXT Segment 里的符号。可以将got 看作一个表,里面每项都是一个地址值。got 的每项在加载期间都会被 dyld 重写,所以会在 DATA Segment 中。got 用来存放 non-lazy 符号最终地址,为 dyld 所用。dylib 外部符号对于全局变量和常量引用地址会指到 __got。
- __lazy_symbol:包含 lazy 符号,首次使用时绑定。
- stubs:跳转表,重定向到 lazy 和 non-lazy 符号的 section。被标记为 S_SYMBOL_STUBS。TEXT Segment 里代码和 dylib 外部符号的引用地址对函数符号的引用都指向了 stubs。其中每项都是 jmp 代码间接寻址,可跳到 la_symbol_ptr Section 中。
- stub_helper:lazy 动态绑定符号的辅助函数。可跳到 nl_symbol_ptr Section 中。
- __text:机器码,也是实际代码,包含所有功能。
- __cstring:常量。只读 C 字符串。
- __const:初始化过的常量。
- _objc:Objective-C 语言 runtime 的支持。
- __data:初始化过的变量。
- __bss:未初始化的静态变量。
- __unwind_info:生成异常处理信息。
- __eh_frame:DWARF2 unwind 可执行文件代码信息,用于调试。
- string table:以空值终止的字符串序列。
- symbol table:通过 LC_SYMTAB 命令找到 symbol table,其包含所有用到的符号信息。结构体 nlist_64描述了符号的基本信息。nlist_64 结构体中 n_type 字段是一个8位复合字段,其中bit[0:1]表示是外部符号,bit[5:8]表调试符号,bit[4:5]表示私有 external 符号,bit[1:4]是符号类型,有 N_UNDF 未定义、N_ABS 绝对地址、N_SECT 本地符号、N_PBUD 预绑定符号、N_INDR 同名符号几种类型。
- indirect symbol table:每项都是一个 index 值,指向 symbol table 中的项。由 LC_DYSYMTAB 定义,和nl_symbol_ptr 和 lazy_symbol 一起为 stubs 和 got 等 Section 服务。
生成 Section 的代码如下:
|
|
其中 symble table 生成的代码如下:
|
|
获取 Segment 信息的代码如下:
|
|
获取对应符号的方法代码如下:
|
|
加载运行
程序要和其他库还有模块一起运行,需要在运行时对这些库和模块的符号引用进行解析,运行时,你应用程序使用的模块符号都在共享名称空间。macOS 使用的是两级名称空间来确保不同模块符号名不会冲突,同时增强向前兼容。
选择要加载的 Mach-O 后,系统内核会先确定该文件是否是 Mach-O 文件。
文件的第一个字节是魔数,通过魔数可以推断是不是 Mach-O,mach-o/loader.h 里定义了四个魔数标识。
|
以上四个魔数标识是 Mach-O 文件。
然后内核系统会用 fork 函数创建一个进程,然后通过 execve 函数开始程序加载过程,execve 有多个种类,比如 execl、execv 等,只是在参数和环境变量上有不同,最终都会到内核的 execve 函数。
接着会检查 Mach-O header,加载 dyld 和程序到 Load Command 指定的地址空间。执行动态链接器。动态链接器通过 dyld_stub_binder 调用,这个函数的参数不直接指定要绑定的符号,而是通过给 dyld_stub_binder 偏移量到 dyld 解释的特殊字节码 Segment 中。dyld_stub_binder 函数的代码在这里:dyld_stub_binder.s。dyld 分为 rebase、binding、lazy binding、导出几个部分。dyld 可以 hook,使用 DYLD_INSERT_LIBRARIES,类似 ld 的 LD_PRELOAD 还有 DYLD_LIBRARY_PATH。
text 里需要被 lazy binding 的符号引用,访问时回到 stub 中,目标地址在 la_symbol_ptr,对应 la_symbol_ptr 的内容会指向 stub_helper,其中逻辑会调到 dyld_stub_binder 函数,这个函数会通过 dyld 找到符号的真实地址,最后 dyld_stub_binder 会把得到的地址写入 la_symbol_ptr 里后,会跳转到符号的真实地址。由于地址已经在 la_symbol_ptr 里了,所以再访问符号时会通过 stub 的 jum 指令直接跳转到真实地址。
通过 dyld 加载主程序链接到的所有依赖库,执行符号绑定也就是non lazy binding。绑定解析其他模块的功能和数据的引用过程,也叫导入符号。
导入导出符号
执行绑定时,链接程序会用实际定义的地址替换程序的每个导入引用。通过构建时的选项设置,dyld 可以即时绑定,也叫延迟绑定,首次使用引用时的绑定,在使用符号前不会将程序的引用绑定到共享库的符号。使用 -bind_at_load 可以加载时绑定,动态链接程序在加载程序时立即绑定所有导入的引用,如果没有设置这个选项,默认按即时绑定来。设置 -prebind,程序引用的共享库都会在指定的地址预先绑定。
根据 Code Fragment Manager 设计的弱引用允许程序有选择的绑定到指定的共享库,如果 dyld 找不到弱引用的定义,会设置为 NULL,然后可以继续加载程序。代码上可以写判断,如果引用为空进行相应的处理。
过程链接表 PLT,会在运行时确定函数地址。callq 指令在 dyld_stub 调用 PLT 条目,符号 stub 位于 TEXT Segment 的 stubs Section 中。每个 Mach-O 符号 stub 都是一个 jumpq 指令,它会调用 dyld 找到符号,然后执行。
Mach-O 的导入和导出都会存在 __LINKEDIT 里。使用 FSA 接受 Leb128 参数,也就是绑定操作码。LEB 会把整数值编码成可变长度的字节序列,最后一个字节才设置最高有效位。
当 FSA 循环或递归时,会用0xF0对其进行掩码获得操作码,所有导入绑定操作码都会对应有宏名称和对应的功能。比如 0xb0 对应宏是 BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED,功能是将记录放到导入堆栈中,然后把当前记录的地址偏移量设为 seg_offset = seg_offset + (scale * sizeofptr) + sizeofptr ,其中 scale 是立即数中包含的值,sizeofptr 是指针对应平台的大小。
Mach-O 导出符号是 trie 的数据结构,trie 节点最多有一个终端字符串信息,如果没有终端信息,就以0x00字节标记。有的化,就用 Leb128 代替该节点的终端字符串信息大小。节点导出信息后,类型信息类型使用0x3对标志进行位掩码获得。0x00表示常规符号,0x01表示线程本地符号,0x02标识绝对符号,0x4表示弱引用符号,0x8表示重新导出,0x10是 stub,具有 Leb128的 stub 偏移量。大部分符号都是常规符号,会将 Mach-O 的偏移量给符号。
生成 trie 数据结构的代码如下:
|
|
Trie 也叫数字树或前缀树,是一种搜索树。查找复杂度 O(m),m 是字符串的长度。和散列表相比,散列最差复杂度是 O(N),一般都是 O(1),用 O(m)时间评估 hash。散列缺点是会分配一大块内存,内容越多所占内存越大。Trie 不仅查找快,插入和删除都很快,适合存储预测性文本或自动完成词典。为了进一步优化所占空间,可以将 Trie 这种树形的确定性有限自动机压缩成确定性非循环有限状态自动体(DAFSA),其空间小,做法是会压缩相同分支。对于更大内容,还可以做更进一步的优化,比如使用字母缩减的实现技术,把原来的字符串重新解释为较长的字符串;使用单链式列表,节点设计为由符号、子节点、下一个节点来表示;将字母表数组存储为代表 ASCII 字母表的256位的位图。
对于动态库,有几个易于理解的公共符号比导出所有符号更易于使用,让公共符号集少,私有符号集丰富,维护起来更加方便。更新时也不会影响较早版本。导出最少数量的符号,还能够优化动态加载程序到进程的时间,动态库导出符号越少,dyld 加载就越快。
静态存储类是表明不想导出符号的最简单的方法。将可见性属性放置在实现文件中的符号定义里,设置符号可见性也能够更精确的控制哪些符号是公共符号还是私有符号。在编译选项 -fvisbility 可以指定未指定可见性符号的可见性。使用 -weak_library 选项会告诉编译器将库里所有导出符号都设为弱链接符号。使用 nm 的 -gm 选项可以查看 Mach-O 导出的符号:
|
|
结果如下:
|
|
另外可以通过导出的符号文件,列出要导出的符号来控制导出符号数量,其他符号都会被隐藏。导出符号文件 list 如下:
|
|
使用 -exported_symbols_list 选项编译就可以仅导出文件中指定的符号:
|
|
符号绑定范围
符号可能存在与多个作用域级别。未定义的外部符号是在当前文件之外的文件中,如下:
|
|
私有定义符号,其他模块不可见
|
|
私有外部符号可以使用 private_extern关键字:
|
|
指定一个函数为弱引用,可以使用 weak_import 属性:
|
|
在符号声明中添加 weak 属性来指定将符号设置为合并的弱引用:
|
|
入口点
符号绑定结果放到 LC_DYSYMTAB 指定的 section,解析后的地址会放到 DATA segment 的 nl_symbol_ptr 和 got 里。dyld 使用 Load Command 指定 Mach-O 中的数据以各种方式链接依赖项。Mach-O 的 Segment 按照 Load Command 中指定映射到内存中。 初始化后,会调用 LC_MAIN 指定的入口点,这个点是 TEXT Segment 的 text Section 的开始。使用 stubs 将 la_symbol_ptr 指向 stub_helpers,dyld_stub_binder 执行解析,然后更新 __la_symbol_ptr 的地址。
Mach-O 和链接器之间是通过 assembly trampoline 进行的桥接,Mach-O 接口的 ABI 和 ELF 相同,但策略不同。macOS 在调用 dyld 前后都会保存和恢复 SSE 寄存器。
动态库构造函数和析构函数
动态库加载可能需要执行特殊的初始化或者需要做些准备工作,这里可以使用初始化函数也就是构造函数。结束的时候可以加析构函数。
举个例子,先定义一个 header.c,在里面加上构造函数和析构函数:
|
|
将 header.c 构建成一个动态库 header.dylib。
|
|
将 header.dylib 和 main.c 构建成一个中间目标文件 main.o。
|
|
运行看结果
|
|
可以看到,动态库的构造函数 prepare 和析构函数 end 都执行了。