一个iOS程序员的自我修养(三)Mach-O文件静态连接

上文分析了 Mach-O 文件的总体结构,那么 Mach-O 文件是怎么来的呢?其中一个重要的过程就是静态连接,连接器将全部输入的 “.o” 文件打包输出可执行文件,能够简单理解这个可执行文件就是 Mach-O 文件,由于本篇主要分析静态连接,因此暂且理解为静态连接后生成了最终的可执行文件。git

假设咱们只有两个模块,“a.c” 和 “b.c”,它们的代码定义以下:程序员

/* a.c */
extern int shared;
int main() {
    int a = 100;
    swap(&a, &shared);
}

/* b.c */
int shared = 1;
void swap( int* a, int* b) {
    *a ^= *b ^= *a ^= *b;
}
复制代码

以 x86 架构为例,首先使用 clang 命令将 “a.c” 和 “b.c” 分别编译成目标文件 “a.o” 和 “b.o”:github

clang -fmodules -c a.c b.c -o a.o b.o
复制代码

通过编译后,咱们就获得了 a.o b.o 这两个目标文件。从代码能够看到,b.c 中共定义了两个全局符号,“shared” 和 “swap”,a.c 里面定义了一个全局符号 “main”,a.c 里面引用到了 b.c 里面的 “shared” 和 “swap”,接下来要作的就是把 a.o 和 b.o 两个目标文件连接成 “ab” 可执行文件。数组

函数和变量被统称为符号。markdown

空间与地址分配

连接器会先扫描全部输入的目标文件,获取它们各个段的长度、属性和位置,将全部的符号表收集起来统一放到一个全局符号表。这一步连接器将全部目标文件进行段合并,计算出合并后的长度和位置,创建映射关系。架构

实际上目标文件与可执行文件 Mach-O 结构一致。函数

符号解析与重定位

在分析符号解析与重定位以前先看看 a.o 里面是怎么使用两个外部符号 “shared” 和 “swap” 的: 利用 MachOView 工具能够看到 a.o 的代码段反汇编结果: 工具

最左边的那列是每条在虚拟内存中的偏移量,每一行表明了一条指令。红框标出的就是两个引用了 “shared” 和 “swap” 的位置,其中 shared 使用 mov 指令,这条指令占用了 3 个字节,swap 调用使用的是 call 指令,其中的 0x488B35 和 0xE8 操做码都是近址相对位移调用指令,后面的四个字节就是被调用函数相对于调用指令的下一条指令的偏移量。实际上 0x1E3 和 0x1F5 存放的只是 “shared” 和 “swap” 的临时假地址,由于编译器在编译的时候并不知道它们的真正地址。编译器将这两条指令的地址暂时用 0x00000000 代替着。连接器在空间与地址分配后就能够肯定全部符号的虚拟地址了,以后对每一个须要重定位的指令进行修正。下面将 a.o b.o 连接成可执行文件 ab:oop

clang a.o b.o -o ab
复制代码

再对 ab 进行反汇编看一下连接后的代码段和 a.o 对比一下变化: post

通过修整后,“shared” 和 “swap” 的地址分别为 0x000000A1 和 0x0000000F (小端模式),以 swap 为例,这个 call 指令是一条近址相对位移调用指令,它后面跟的是相对于下一条指令 xor 的偏移量,也就是 0xF71 + 0x0F 求和结果正好是 0xF80,0xF80 恰好是 swap 函数的地址。

重定位表

那么连接器怎么知道哪些指令须要被调整呢?事实上在目标文件中有个重定位表,专门保存与重定位相关的符号,它被定义在了目标文件的 Relocations 段中。a.o 的 Relocations 段定义以下:

每一个要被重定位的地方叫作一个重定位入口,能够看到 a.o 里面在 __TEXT,__text 段有两个重定位入口,对照前面 a.o 的反汇编分析,这里的 0x1D 和 0xB 正好是代码段中的 call 指令和 mov 指令的地址部分。

重定位表能够理解为是一个装有重定位入口的数组,重定位入口的结构体被定义在了mach-o 的 reloc.h 中,它的结构以下:

struct relocation_info {
   int32_t	r_address;	/* offset in the section to what is being
				   relocated */
   uint32_t     r_symbolnum:24,	/* symbol index if r_extern == 1 or section
				   ordinal if r_extern == 0 */
		r_pcrel:1, 	/* was relocated pc relative already */
		r_length:2,	/* 0=byte, 1=word, 2=long, 3=quad */
		r_extern:1,	/* does not include value of sym referenced */
		r_type:4;	/* if not 0, machine specific relocation type */
};
复制代码

两个重要的字段 r_addressr_symbolnum,r_address 对应该符号在该段中的偏移,经过 r_address 就能够找到要重定位的位置,r_symbolnum 对应该符号在符号表中的下标,经过 r_symbolnum 就能够找到该符号在符号表中的位置。

符号解析

一般观念里,之因此要连接是由于目标文件中用到的符号被定义在了其余目标文件中,因此要将他们连接起来,例如咱们直接连接 “a.o” 连接器发现 shared 和 swap 两个符号未被定义,没有办法完成连接工做:

Undefined symbols for architecture x86_64:
  "_shared", referenced from:
      _main in a.o
  "_swap", referenced from:
      _main in a.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
复制代码

这也是编写程序遇到最多的错误之一,就是连接时符号未定义。从程序员的角度来看,符号解析占据了连接过程的主要内容。

其实重定位过程也伴随着符号解析过程,每一个目标文件均可能定义一些符号或者引用到定义在其余目标文件中的符号,例如 a.o 引用到了 b.o 的 “shared” 和 “swap”。符号都被定义在了符号表数组中,它的结构体定义在 mach-o 下的 loader.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) */
};
复制代码
  • n_strx:字符串表中的下标。
  • n_sect:第几个 section。
  • n_value:符号地址。

好比 a.o 的符号表:

能够看到除了 main 函数定义在了代码段以外,其余符号都是 N_UNDF 类型,即 “undefined” 类型,实际上这种未定义的符号都可以在重定位表中找到。在连接器扫描完全部的输入文件后,这些未定义的符号都应该可以在全局符号表中找到,不然连接器就会报符号未定义错误。可经过下图对比出连接先后,符号表中“shared”和“swap”符号的变化:

目标文件 a.o 中未定义的类型在连接成可执行文件 ab 后,未定义的部分就都变得有值了。

重定位

重定位时,重定位表中的每一个重定位入口的 r_address 都是对一个符号的引用,当连接器对引用的某个符号进行重定位时,它就要肯定这个目标符号的地址,这时候就会经过重定位入口的下标 r_symbolnum 去全局符号表中查找这个符号,找到后再将这个符号的地址按照必定的规则(例如相对位移调用指令的方式),回填入调用该符号的位置,这样一个重定位过程就结束了。

静态插桩 hook objc_msgSend 分析

所谓静态插桩就是在静态连接期间实现 objc_msgSend 方法替换。具体实现方案就是在主工程用汇编的方式实现 hook_msgSend 函数,再将静态库中字符串表中的 objc_msgSend 替换为 hook_msgSend,例如替换某个 Pod 库中的 objc_msgSend,用来监控 OC 方法调用。

字符串表

每一个目标文件或者说静态库里面都有专门用来为符号表服务的字符串表,存储着好比段名、变量名、函数名等。由于字符串的长度是不定的,因此没有像符号那样的结构体来表示它。全部的字符串都被集中起来存放到一个表中,而后使用字符串在表中的偏移来引用字符串,符号表正是经过这个偏移 n_strx 值来索引到它的符号名称。

咱们仍是以 ab 可执行文件的 main 函数为例,经过 MachOView 分析下符号表经过索引查找对应符号名的过程:

红框内的值就是符号表中的 n_strx,符号表中 main 符号在字符串表中的偏移为 0x16,换算成十进制是 22。而后在看一下字符串表中的结构: 红框内的 16 进制翻译成 ASCII 正好是 _main 字符串,在字符串表中的偏移也正好是 22 位,这也对应上了在符号表中的偏移。

由于 main 函数并不是定义在外部,因此它的 value 是有值的,若是是一个外部函数,例如 objc_msgSend 这个 value 会是 0,由于 objc_msgSend 是属于 runtime 的库函数,这是一个动态库,动态库中函数地址的肯定是在动态连接的时候进行绑定的,关于动态连接后面章节会讲到。在生成可执行文件 Mach-O 时 objc_msgSend 的真实地址是未知的,在静态连接的过程不会对这个符号进行重定位。若是在主工程和静态库连接前,将 objc_msgSend 修改成 hook_msgSend 字符串,连接后符号表中的 value 就变成了 hook_msgSend 函数的地址。

由于静态库自己就是一组目标文件的集合,静态库与库之间在连接的过程与目标文件之间的连接并没有二异。经过上面的分析咱们能够知道,当目标文件也就是 .o 文件引用了外部符号后,这些外部符号在全局符号表中的状态都是 N_UNDF 类型,而且同时会在重定位表中增长这个符号的重定位入口。 在空间与地址分配后,符号表中的未知符号在虚拟地址中的偏移也就随之肯定了。在这以后会遍历全部的重定位入口,对须要重定位的位置进行修正,也就是将调用 objc_msgSend 指令的地方都修正为对 hook_msgSend 的调用。

关于具体代码实现能够参考这个开源工具: KKMagicHook

参考

《程序员的自我修养》

juejin.cn/post/684490…

github.com/maniackk/KK…

相关文章
相关标签/搜索