一个iOS程序员的自我修养(五)Mach-O文件动态链接

2,522 阅读8分钟

为什么要动态链接

有了静态链接,为什么还需要动态链接?

在静态链接的情况下,比如有两个程序 Program1 和Program2,并且他们还共用一个 Lib.o 外部模块,所以在输出的可执行文件 Program1 和 Program2 中有两个副本,当同时运行 Program1 Program2 时,Lib.o 同时在内存中和磁盘中都有两份副本,当内存中存在大量的像 Lib.o 这样的目标文件时,极大的浪费了内存空间。另一个问题是静态链接对程序的更新、部署、发布也会带来很多问题,比如 Lib.o 是一个第三方厂商提供的,当三方厂商更新 Lib.o 的时候,Program1 就要重新拿到 Lib.o 重新链接在发布给用户,这样导致 Lib.o 有任何一个小的改动都要用户重新下载整个程序。

要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块互相分割开来,形成独立的文件,而不是再将他们静态链接在一起,简单的讲,就是不对那些组成程序的目标文件进行链接,等到运行时再进行链接,这就是动态链接的基本思想。

简单的动态链接例子

// ViewController.m
#import "ViewController.h"
#import "TestDyld.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [TestDyld testPrint];
    swap(10, 20);
    NSString *aString = bString;
    NSString *enterBG = UIApplicationDidEnterBackgroundNotification;
}
@end

// TestDyld.m
#import "TestDyld.h"
NSString *bString = @"test";
@implementation TestDyld
+ (void)testPrint {
    NSLog(@"测试");
}
void swap(int a, int b) {
    a+b;
}
@end

以上代码引用了 UIKit Foundation 等多个动态库,然后通过 xcrun 命令将 maim.m、ViewController.m 和 TestDyld.m 合并成名为 testdyld 的 Mach-O 文件,通过 MachOView 对其进行反汇编探索一下动态链接的原理。

如上图所示,LC_LOAD_DYLIB 加载命令专门用来在动态链接的时候加载程序主模块依赖的动态库。LC_LOAD_DYLIB 命令的参数描述了 dylib 的基本信息:

struct dylib {
    union lc_str  name;             // dylib 的 path
    uint32_t timestamp;             // dylib 构建的时间戳
    uint32_t current_version;       // dylib 的版本
    uint32_t compatibility_version; // dylib 的兼容版本
};

在动态链接的过程中,操作系统会通过 LC_LOAD_DYLINKER 加载命令加载 dyld 动态链接器,通过 dyld 再去加载其他的 dylib 库,然后在对各种库进行绑定和重定位工作,比如说图中的 Foundation 和 UIKit。

地址无关代码(PIC)

Foundation 或者 UIKit 等动态库在被 dyld 装载时,如何确定它们在进程虚拟地址空间中的位置?

在 iOS 上所有的应用程序几乎都用到了 Foundation 和 UIKit,在静态链接部分我们知道程序的指令和数据中可能会包含一些绝对地址的引用,那么就要确定动态库被装载的地址,如果不同的库被装载到了同一个地址就会产生目标地址冲突。为了解决这个装载地址固定问题,首先想到的就是能够在装载时再对动态库进行重定位,但是装载时重定位也并不适合用来解决这个问题,动态库在被装载映射至虚拟空间后,指令部分是要在多个进程之间共享的,而不像静态库一样每个程序都有一份副本。由于重定位需要修改库内的指令跳转目标地址,这就导致共享库指令部分没有办法被多个进程共享,这就失去了动态链接节省内存的大优势。

为了让共享的指令部分在装载时不需要因为地址的改变而改变,就需要把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保证不变,而数据部分可以在每个进程中拥有一个副本,这就是地址无关代码技术。

模块中的各种类型的地址引用方式有如下四种情况:

  1. 模块内部的函数调用、跳转。
  2. 模块内部的数据访问,比如模块内部定义的全局变量、静态变量。
  3. 模块外部的函数调用、跳转。
  4. 模块外部的数据访问,比如其他模块中定义的全局变量。

模块内部指令跳转

由于同一模块内的相对位置是固定的,所以模块内跳转、函数调用可以是相对地址调用。指令跳转在 x86 使用的是 call 指令,指令码是 E8,在静态链接中提到过这是近址相对位移调用指令,这种指令是不需要重定位的。在 arm64 架构上寻址方式有些区别,arm64 跳转使用的是 bl 指令,指令码是 94 或者 97,97 表示向前跳转 94 表示向后跳转,它的偏移计算公式为:(目标地址 - 指令地址)/ 4。通过 MachOView 探究下 swap 跳转的寻址过程: 通过查找符号表可以知道 swap 的目标地址正是 0x10007EC4。

模块内部数据访问

模块内部的数据访问也可以不包含绝对地址的引用,但是相对于模块内部跳转的 bl 指令复杂些,arm 通过 adrp+add 的组合来得到数据的地址。如下图所示是访问 bString 字符串的汇编指令:

在了解 adrp 指令之前,首先要了解 adr 指令。

  1. adr 指令: 小范围的地址读取指令。adr 指令将基于 PC 相对偏移的地址值读取到寄存器中。将有符号的 21 位的偏移,加上 PC, 结果写入到通用寄存器,可用来计算 +/- 1MB 范围的任意字节的有效地址。

  2. adrp 指令: 以页为单位的大范围的地址读取指令。符号扩展一个 21 位的 offset(immhi+immlo), 向左移动 12 位,PC 的值的低 12 位清零,然后把这两者相加,结果写入到 x8 寄存器,用来得到一块含有 bString 的 4KB 对齐内存区域的 base 地址(也就是说 bString 所在的地址,一定落在这个 4KB 的内存区域里), 可用来寻址 +/- 4GB 的范围(2^33次幂)。

通俗来讲,adrp 指令就是先进行 PC+imm(偏移值)然后找到 bString 所在的一个 4KB 的页,然后取得 bString 的基址,再通过 add 指令加上偏移去寻址。

adrp+add 对 bString 的寻址过程如下:

0xB0000008 = 1011 0000 0000 0000 0000 0000 0000 1000
immlo = 01
immhi = 0000 0000 0000 0000 000
imm = immlo + immhi = 0x01
imm << 12 = 0x1000
PC = 0x10007EAC
PC 低 12 位清零 = 0x10007000
0x10007000 + 0x1000 = 0x10008000 aString 所在的基地址。
通过 add 指令补全在基地址中的偏移:
目标地址:0x10008000 + 0x2A0 = 0x100082A0

可以看到 0x100082A0 正是数据段中存放的数据。

模块间数据访问

模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定。比如上面例子的 UIApplicationDidEnterBackgroundNotification 被定义在了 UIKit 中,并且该地址在装载时才能确定。我们前面提到要使得代码地址无关,基本思想就是把地址相关的部分放到数据段里面。Mach-O 里面有一个全局偏移表(Global Offset Table,GOT),当代码需要引用该全局常量时,可以通过 GOT 间接引用,基本机制如下图:

当指令中需要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找到变量的目标地址。如下图所示:

可以看到访问常量 UIApplicationDidEnterBackgroundNotification 的地址是 0x10008000,具体寻址方式和模块内部寻址方式相同,这个地址位于 GOT 中红框标注的位置,而这个符号的真实地址还是0x0,需要在装载的时候才能确定。GOT 段相对于当前指令的偏移是在编译期就可以确定了的,GOT 中每个地址对应哪个变量或常量是由编译期决定的,比如第一个地址对应着 UIApplicationDidEnterBackgroundNotification 常量。GOT 段本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响,这样模块间的数据访问也变得与地址无关了。

模块间调用、跳转

模块间的调用和跳转也是采用通过 GOT 间接跳转的方法解决,基本和模块间数据访问一致,只不过 GOT 中相应的项保存的是目标函数的地址。

总结 PIC 实现如下:

延迟绑定(PLT)

背景

动态链接相比于静态链接是以牺牲了一部分性能为代价的。主要原因是动态链接下对于全局和静态的数据访问和模块之间的调用都要进行复杂的 GOT 定位,然后间接寻址,如此一来程序的运行速度必定会很慢。还有一个原因是动态链接器要对所有的动态库进行符号地址查找和重定位等工作,这样势必会减慢程序的启动速度。

延迟绑定的实现

基本思想就是当函数第一次用到时才进行绑定(符号查找和重定位过程)。调用外部模块函数通常做法是通过 GOT 中相应的项进行间接跳转,在 x86 架构中,PLT 为了实现延迟绑定在这个过程中又增加了一层间接跳转。例如在某个动态库中有一个 bar() 函数,这个函数在 PLT 中的地址称之为 bar@plt,在 Linux 下的 ELF 可执行文件实现如下:

bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve

bar@GOT 表示 GOT 段中 bar() 相应的项,如果该项有值,也就是说被绑定过了,那么直接跳转到 bar() 的位置。实际上为了延迟绑定链接器并没有将 bar() 的地址填入该项,然后就会进入 push n 将一个数字 n 压入堆栈中,这个数字对应着在重定位表中的下标。再然后将模块 ID 压入堆栈,再跳转到 _dl_runtime_resolve。实际上这就是找到 bar() 在哪个模块叫什么方法然后调用 _dl_runtime_resolve(iOS 上调用的 dyld_stub_binder) 进行重定位的过程,在下一次调用就可以直接跳转了。

Mach-O 动态链接过程分析

在动态链接过程中还有个重要的加载命令 LC_DYSYMTAB,动态符号表,它是符号表的子集,里面只包含动态链接相关的符号。本质上是 index 数组,即每个条目的内容是一个 index 值,该 index 值(从 0 开始)指向到符号表中对应的符号。

除了 LC_DYSYMTAB 这个 Segment 外,Mach-O 中还有两个重要的 Section __got__stubs,Mach-O 的代码段对 dylib 外部符号的引用地址,要么指向到 got,要么指向到 stubs,前者主要存储的是全局变量或常量,后者储存的是对函数的引用。常量或变量在模块间的引用相对较少,引用过多会产生一定的耦合,而函数在模块间的调用非常频繁,所以这两种符号的绑定方式又分为 Non-Lazy 和 Lazy 两种,前者在动态链接的过程中进行符号的重定位与绑定,而后者是在第一次被使用时进行绑定。

got

当镜像文件被加载时,dyld 动态链接器会对 got 段中每个条目所对应的符号进行重定位,将其真正的地址填入。那么 dyld 是如何找到 got 中的符号在符号表中的位置的呢?每个 segment 由LC_SEGMENT 命令定义,该命令后的参数描述了 segment 包含的 section 信息,对应结构体是section_64:

struct section_64 { /* for 64-bit architectures */
	char		sectname[16];	/* name of this section */
	char		segname[16];	/* segment this section goes in */
	...
	uint32_t	reserved1;	/* reserved (for offset or index) */
	uint32_t	reserved2;	/* reserved (for count or sizeof) */
	uint32_t	reserved3;	/* reserved */
};

对于 got、stubs,reserved1 描述了该 list 中条目在 dynamic symbol table 中的起始 index,第二条 index+1...index+n,再根据 ynamic symbol table 返回的下标去全局符号表中查找该符号,这个过程的伪代码如下:

__got[0]->symbol = symbolTable[indirectSymbolTable[__got.sectionHeader.reserved1]]
// -> __got.sectionHeader.reserved1 == 2
// -> indirectSymbolTable[2] == 2
// -> symbolTable[2] = Symbol(_kHelloPrefix)
// -> __got[0]->symbol = Symbol(_kHelloPrefix)

同理
__got[1]->symbol = symbolTable[indirectSymbolTable[__got.sectionHeader.reserved1 + 1]]
// -> __got.sectionHeader.reserved1 + 1 == 3
// -> indirectSymbolTable[3] == 4
// -> symbolTable[2] = Symbol(dyld_stub_binder)
// -> __got[0]->symbol = Symbol(dyld_stub_binder)

下面用 MachOView 反汇编看下 UIKit 中 UIApplicationDidEnterBackgroundNotification 符号的重定位过程:

stubs、la_symbol_ptr、stub_helper

Mach-O 中代码段对函数的引用最终都会指向 stubs 段,如下图是代码段对 NSLog 的调用指令,0x100007f08 正好在 stubs 段:

使用 otool 命令对 stubs 段进行反汇编如下:

0x100008010 位于 __la_symbol_ptr 段,不止 NSLog,所有引用外部的函数最后都会跳转到 __la_symbol_ptr 段的相应项上,下面再看看 __la_symbol_ptr 里面是什么。

__la_symbol_ptr 中的所有项都指向了 __stub_helper 中的一段汇编指令,这段汇编代码最终都会跳转到第六行的 br 指令,也就是目标地址 0x100008008,通过上图可以看出这个地址存储的是 section(__DATA __got) 里面的 dyld_stub_binder 函数。转了一大圈,实际上所有引用的外部函数最终都会跳转到 dyld_stub_binder,这是一个寻找外部函数地址的函数,Lazy binding symbol 的绑定工作正是由 dyld_stub_binder 触发,必须提前绑定好,所以它和常量和全局变量放在了一个段。最终将 NSLog 指令的真实地址回填到 __la_symbol_ptr 段。整个过程总结如下:

首次访问 NSLog 时:

  1. NSLog 对应的 __la_symbol_ptr 条目内容指向到 __stub_helper。
  2. __stub_helper 里的代码逻辑,通过各种辗转最终调用 dyld_stub_binder 函数。
  3. dyld_stub_binder 函数通过调用 dyld 内部的函数找到 NSLog 符号的真实地址。
  4. dyld_stub_binder 将地址写入 __la_symbol_ptr 对应函数中。
  5. dyld_stub_binder 跳转到 NSLog 符号的真实地址。
  6. 之后再次访问 NSLog 时,跳转到 __la_symbol_ptr 段后直接跳转符号的真实地址。

引用

  1. 《程序员的自我修养》
  2. blog.csdn.net/liao392781/…
  3. www.jianshu.com/p/9e4ccd3cb…