延迟绑定机制是指将符号的绑定工作推迟到符号第一次被程序调用的时候。为了大家更好地理解延迟绑定的概念以及我们为什么要延迟绑定,本文首先会介绍一些程序链接方面的知识。
相关背景
链接在编译过程中的位置
一般来说,代码的编译过程分为以下几个阶段:
-
预处理
主要包括头文件导入、宏展开等过程。
-
语法以及语义分析
首先对代码进行扫描分析,生成抽象语法树(Abstract Syntax Tree),语法树中还记录了每个节点在源代码中的位置,方便编译器定位问题;之后编译器对其进行更高级别的静态分析,确保程序中没有错误,例如调用对象未实现的方法、类型转换错误、对可能导致内存泄露的代码进行警告等。
-
中间代码生成及优化
将代码进一步转换成中间代码(LLVM IR),对其进行相应的优化处理,并输出汇编代码。
-
汇编
将汇编代码生成相应的目标文件。
-
链接
合并多个目标文件,最后输出一个可执行文件或是库文件。
链接是程序编译过程中的最后一步,也是十分重要的一步,它主要有两方面的工作:(1)符号解析。将符号的引用和符号的定义联系起来;(2)重定位。将符号的定义和具体的地址对应起来,并修改所有对这些符号的引用,使它们指向相应的地址。
静态链接
在很久很久以前,链接是指静态链接,也就是将所需的库文件全部都拷贝一份到程序中,最终形成一个可执行文件。静态链接在技术上是没有什么问题的,但是随着时间的推移,人们也发现了它身上的一些硬伤:
最终生成的可执行文件十分臃肿、巨大。
编译时间大大增加。
浪费存储器资源。相同的代码会被复制到不同的程序中,操作系统运行的程序越多,浪费就越严重。
更新和维护困难。如果你要更新一个库文件,那么你必须要重新修改编译参数,然后将以上费时费力的编译过程重新跑一边,如果其中出了一点偏差,那么不好意思,一切都要重新来过,反复几次后程序不一定能编译出来,但孩子一定可以打酱油了(ಥ_ಥ)
所以为了解决这些问题,勤劳、善良的程序员们又发明了新的技术:动态链接。
动态链接
静态链接问题的根源在于它使程序和静态库的联系过于紧密,解决问题的关键是降低二者间的耦合度,动态链接技术为此应运而生。
与静态链接相比,动态链接将相关符号的绑定工作推迟到程序被加载到内存中执行的时候,这不仅减少了程序的编译时间,而且也使得库文件能够真正的被不同的程序所共享。此处的“共享”有两点含义:一是指库文件在操作系统中只存在一份,而不是像静态链接那样将库文件给每个程序都拷贝一份;二是指在程序运行的过程中,共享库的 text 段的内容可以被不同的进程所共享。
与动态链接技术相对应的库文件叫做动态库文件,在 Windows 上是 dll 文件,在 Linux 上是 so 文件,而在 Mac 上则是 dylib 文件。
我们知道,把对符号的引用修正为符号所对应的地址是链接过程中的关键部分,动态库也是一样,它也要做出相应的修改,但是最尴尬的地方在于动态库只有一份,但是它会被很多进程加载,而且它事先并不知道自己会被加载到哪里。
举个例子:假设动态库加载到进程 A 的地址为 0xAAAAAA,链接结束后动态库相关符号的地址也被修改,如果此后没有进程加载该动态库,那么一切都没有问题。可是直到某一天,进程 B 也要加载此动态库,还硬要把它加载到 0xBBBBBB 上,这样麻烦就来了。动态库中符号的相关地址是基于 0xAAAAAA 的,在进程 B 中是无法工作的,那么要基于 0xBBBBBB 修改吗?这样进程 A 就不能工作了。重新拷贝一份到进程 B 吗?那你还共享个啥啊!!!
还有一个潜在的问题,那就是动态库的 text 段必须是可写的,否则加载的时候就无法修改相关符号进行重定位,这就会使安全性大打折扣。
为了使动态链接成为可能,我们勤劳、善良的程序员又发明了新的技术,即 PIC(Position Independent Code)。
Position Independent Code
PIC 又称为位置无关代码,即相关代码加载到任何位置都可以正常运行,动态库的编译都需要加上 -fPIC -shared 选项。
Use -fPIC or -fpic to generate position independent code. Whether to use -fPIC or -fpic to generate position independent code is target-dependent. The -fPIC choice always works, but may produce larger code than -fpic (mnenomic to remember this is that PIC is in a larger case, so it may produce larger amounts of code). Using -fpic option usually generates smaller and faster code, but will have platform-dependent limitations, such as the number of globally visible symbols or the size of the code. The linker will tell you whether it fits when you create the shared library. When in doubt, I choose -fPIC, because it always works.
那么 PIC 是如何实现的?它又是如何在不修改 text 段的情况下,让同一动态库加载到不同进程的不同位置?使用动态库的程序又需要做哪些工作?
答案其实很简单,就是多一层引用关系。
既然 text 段不让写,那我就写 data 段呗😳。因为 data 段是可写入的,所以就把符号加载后的地址放在 data 段中的相关位置,而当 text 段使用该符号时,就去 data 段中找到相应的位置,从中取出符号的地址,这样就解决了问题。可是这样问题又来了,data 段的地址你总要知道吧,根据加载位置的不同,data 段地址也是不同的,PIC 又是怎样做到位置无关呢?这依赖于以下两个原理:
-
指令和数据间的距离是常量
在程序的虚拟地址空间中,data 段总是被映射到紧随 text 段的地方,而且链接器知晓程序中每个段的大小以及它们的相对位置,所以这就造成了一个重要的事实:text 段中的任何指令和 data 段中的任何数据之间的距离是一个运行时常量。即使某一天编译技术改了,data 段不紧随 text 段了,那也是没有问题的,因为链接器知道每个段的大小及位置,所以是有办法知道指令和数据间的运行时距离的。
-
获取当前指令地址的技巧�
指令和数据间的相对距离知道了,如果能够知道指令的当前地址就可以得到数据的绝对地址,就能够做到位置无关了,那么怎么拿到当前指令的地址呢?汇编语言中没有为此提供方便,但是我们可以利用以下代码来巧妙地获取当前指令的地址:
call get_pc get_pc: pop %ebx
PC 寄存器总是保存下一条指令的地址,而我们对 get_pc 的调用会导致程序将 PC 的值压入栈顶,在上述程序中 PC 的值就是 pop 指令的地址。随后 pop 指令将这个地址弹出到 ebx 寄存器中,最终的结果就是将 PC 的值保存到 ebx 中,这样就达到了目的,拿到了指令的地址。
下面简单介绍下 Linux 操作系统是如何运用 PIC 技术的。
PIC 数据引用
编译器会在 data 段中创建一个全局偏移量表(Global Offset Table, GOT),表中记录了对动态库全局数据的引用。当加载动态库时,动态链接器会修改 GOT 中的条目,使其包含正确的绝对地址。在程序运行时,通过 GOT 条目进行间接的引用。
PIC 函数引用
对于动态库函数的引用,虽然可以按照数据引用的方式进行处理,也就是加载动态库的时候修正每个函数的 GOT 条目,但大多数编译系统不会这么做,因为这非常耗时。根据程序运行的局部性原理,程序会将80%的时间用于执行20%的代码,多数代码并没有被执行,况且函数的调用远比全局数据要多,这会使程序做很多无用功,导致加载时间非常长。因此对于函数引用,编译系统会对其进行延迟绑定,也就是推迟到相关函数第一次被调用的时候再进行绑定,这需要过程链接表(Procedure Linkage Table, PLT)与 GOT 相互配合。
上述流程解释如下:
程序调用 func 函数,随后控制传递到 PLT 表中与 func 相对的条目。
PLT 条目包含3条指令:第一条指令是跳转到 GOT 条目中所记录的 func 地址;第2条指令是准备符号解析所需的相关信息;第3条指令是跳转到 PLT[0] 中,开始绑定符号。
因为是第一次调用函数,所以 GOT 条目中并没有相关函数的地址,此时它记录的是相关 PLT 条目的第二条指令的地址,最终结果是程序跳回到 PLT 条目中,在准备好符号解析的信息后继续执行。
接下来,程序跳转到 PLT[0] 条目。PLT[0] 也包含了一连串的指令:它首先将 GOT[1] 的内容入栈,GOT[1] 中记录的是符号绑定所需的信息;接下来它跳转到 GOT[2] 中记录的地址,GOT[2] 包含的是动态链接器的入口地址;接下来,动态链接器开始绑定符号。
当动态链接器完成符号的绑定后,GOT 相关条目的内容就会被更新为 func 的地址。
当程序再次调用 func 函数时,就无需再次进行符号绑定了,只需要根据 GOT 条目所记录的地址来调用函数即可。
PIC 补充
那么有些同学可能会想,主程序自身的符号也要这样处理吗?答案是否定的。对同一目标模块的符号引用是不需要特殊处理的,结合 PC 以及偏移量即可,但是引用定义在动态库中的符号就不同了,需要上述特殊处理。
如果是动态库使用自身的符号呢?对于 Linux 而言,动态库使用自身的全局数据或是函数,也会生成相应的 GOT 以及 PLT 条目。这是为了避免因 Linux 使用 flat namespace 而带来的全局符号介入的问题。当动态链接器将动态库加载到程序中时,会将它们的符号放在全局符号表中(Global Symbol Table),这肯定会导致符号冲突,而 Linux 的动态链接器会这样处理:当一个符号被放入全局符号表时,如果和它同名的符号已经存在,那么后加入的符号会被忽略。
所以假设动态库中有 a、b 两个函数,b 函数内部调用了 a 函数,且 a 函数又被其他模块的重名函数覆盖掉。那么如果采用相对地址调用,就需要对其进行重定位,就需要修改动态库的 text 段,所以编译器只能为 a 函数生成 PLT 条目,避免动态库的代码段受到影响。
那么定义在动态库中的全局数据呢?假设可执行文件中有如下代码:
extern int c;
int d() {
c++;
}
编译器在编译的时候并不知道 c 是在同一目标模块的其他文件中还是在动态库中,所以会为其在可执行程序中创建一个副本,假如程序所链接的动态库也定义了 int c,就会出现问题。解决的办法就是将所有对该符号的引用都指向可执行程序的那个副本,在动态库中,就是为其生成 GOT 条目:如果在可执行程序中有副本,就将 GOT 条目指向该副本,否则就指向动态库内部的符号。
当然你也可以将全局变量或是函数加上 static 关键字来解决问题,但是这样做会妨碍动态库内部使用该符号,通常的做法是将相应符号的 visibility 设置为 hidden,不让其成为导出符号,只允许它在动态库内部使用,从而解决问题。具体的做法可以参考此文章。
但是在 Mac 平台上,事情却不太一样。在 macOS 10.1 之后,默认使用 two-level namespace,也就是说引用符号的同时还要指出包含该符号的库的名称,这有以下好处:
提高符号解析效率。链接器明确知道该去哪个库中搜索符号,而不是像 flat namespace 那样去搜索所有的库。
避免符号冲突。
因为 two-level namespace 的存在,即便动态库使用了自身的全局数据或是函数,在 Mac 平台上编译后也是采用相对地址调用,不会生成 GOT 或是 PLT 条目。程序在静态链接的时候,也必须指明所有引用符号所对应的库文件,编译过程结束后,引用符号所对应的库信息会被记录在符号表中。如果是 macOS 10.3 之后,你也可以使用 -undefined dynamic_lookup 选项,将相关工作交给动态链接器去做。
当然有些特殊情况需要使用 flat namespce,这时你需要手动加上 -flat_namespace 编译选项,如果存在未被解析的引用符号,那么你还要加上 -undefined suppress 选项。更多信息请参考此篇文档。
iOS 系统中的延迟绑定
咳咳,说了这么一大推,终于进入本文的主题了,那就是讲解在 iOS 系统上是如何进行延迟绑定的。虽然前面介绍过 Linux 上的延迟绑定,然而在 iOS 系统中,延迟绑定的过程略有不同。
在 Xcode 中新建一个 iOS 工程,写下代码来探究 NSlog 是如何被绑定的:
在 iOS 系统中,当程序调用动态库的函数时,它实际上是执行__TEXT
段的 __stubs
节的代码,下图红笔圈出的部分便是用来调用 NSLog 函数的 stub(你也可以将其理解成 Linux 中的 PLT),它的地址是 0x100006bc0。
外部函数的地址放在 __DATA
段的__la_symbol_ptr
中,而__stub
的作用便是找到相应的 __la_symbol_ptr
,并跳转到它所包含的地址。此处指向 NSLog 函数的 __la_symbol_ptr
的地址是 0x100008010,它所记录的地址为 0x100006c50。
当我们第一次使用 NSLog 时,__la_symbol_ptr
尚未记录 NSLog 的地址,而是指向 __TEXT
段的__stub_helper
节中的相关内容(0x100006c50):
在 __stub_helper
节中,它将绑定过程所需的参数放到 w16 中,之后跳转到 0x100006c38 处,也就是 __stub_helper
节的首部,然后调用 dyld_stub_binder(动态链接器的入口) 进行符号绑定,最后会将 NSLog 的地址放到 __la_symbol_ptr
处。
整个过程和 Linux 大体一致,但是细致观察后发现 w16 实际上是存放一个 long 值,这个 long 究竟代表什么呢,为什么动态链接器可以利用它来绑定符号?实际上它是相对于 __LINKEDIT
段中 Lazy Binding Info 的偏移量:
动态链接器根据这个偏移量便可以从 Lazy Binding Info 中找到绑定信息: 到 Foundation 库中寻找 NSLog。我们可以通过调试来验证以上内容:
运行程序到 NSLog(@"123");
处,我们可以看到程序实际上是跳转到 0x1000a6bc0 处的 __stub
代码,反汇编如下:
但是等等,我们之前在 MachOView 中观察到程序此时应该跳转到 0x100006bc0 处,但是为什么在这里却是 0x1000a6bc0 呢? 这是因为地址空间布局随机化的影响,通过计算 0x1000a6bc0 - 0x100006bc0
可以得到程序的偏移量为 0xa0000,我们继续往下看。
由于 0x1000a6bc0 处的汇编代码是 nop,所以程序不做任何动作,继续下一条指令的执行,也就是 ldr x16, #0x144c
,它是将当前指令的地址 0x1000a6bc4 与立即数 0x144c 相加并将结果 0x1000a8010 放到 x16 中。结合上面计算得到的程序的偏移量以及 MachOView 查看的结果,0x1000a8010 正是存放 NSLog 函数地址的指针,而下一条指令 br x16
则是跳转到指针记录的地址,即 0x1000a6c50。那它会是我们要找的 NSLog 吗?因为是第一次调用,所以肯定不是 NSLog,但是凭着严谨的态度我们还是要验证一下,反汇编如下:
和我们用 MachOView 看到的代码一模一样!程序将偏移量放到 w16 中(此处的偏移量是0,也就是 Lazy Binding Info 的头条),然后执行解析例程!而当程序执行 NSLog(@"123");
后,再次调用 NSLog(@"456");
时,__la_symbol_ptr
记录的地址早已经变成 NSLog 的地址 0x0000000183d12598:
对 0x0000000183d12598 反汇编如下:
就是我们要找的 NSLog !!!
其他
上面介绍了 iOS 延迟绑定的机制,因为它涉及到程序链接方方面面的知识,所以花了很大的篇幅来介绍相关背景,避免直接抛出结论导致大家一头雾水。iOS 上用的动态链接器是 dyld,它的原理是复杂而又多变的,想要了解的同学可以去读它的源码。