程序的动态链接(3):延迟绑定
前言
动态链接将链接工作由编译时推迟到了运行时,在每次程序运行时,动态链接器都要寻找并加载依赖的动态库,然后进行符号查找和重定位工作,这导致动态链接的程序在加载时会带来一些额外的开销。为了提升程序的加载速度,编译系统使用了一种称为延迟绑定(Lazy Binding)的技术。
延迟绑定实现
使用延迟绑定是基于这样一个前提:在动态链接下,程序加载的模块中包含了大量的函数调用,因此动态链接器会耗费很多的时间用于解决模块之间的函数引用的符号查找以及重定位,而实际上只有很少的一部分符号会被立刻访问。延迟绑定通过将函数地址的绑定推迟到第一次调用这个函数时,从而避免动态链接器在加载时处理大量函数引用的重定位。延迟绑定的实现使用了两个特殊的数据结构:全局偏移表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)。
全局偏移表
全局偏移表在ELF文件中以独立的节区存在,共包含两类,对应的节区名为.got
和.got.plt
,其中,.got
存放所有对于外部变量引用的地址;.got.plt
保存所有对于外部函数引用的地址,对于延迟绑定主要使用.got.plt
表。.got.plt
表的基本结构如下图所示:
其中,.got.plt
的前三项存放着特殊的地址引用:
- GOT[0]:保存
.dynamic
段的地址,动态链接器利用该地址提取动态链接相关的信息; - GOT[1]:保存本模块的ID;
- GOT[2]:存放了指向动态链接器
_dl_runtime_resolve
函数的地址,该函数用来解析共享库函数的实际符号地址。
过程链接表
为了实现延迟绑定,当调用外部模块的函数时,程序并不会直接通过GOT跳转,而是通过存储在PLT表中的特定表项进行跳转。对于所有的外部函数,在PLT表中都会有一个相应的项,其中每个表项都保存了16字节的代码,用于调用一个具体的函数。过程链接表的通用结构如下:
过程链接表中除了包含编译器为调用的外部函数单独创建的PLT表项外,还有一个特殊的表项,对应于PLT[0],它用于跳转到动态链接器,进行实际的符号解析和重定位工作:
延迟绑定过程
考虑经典Hello world
的实现:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}
默认情况下,编译器通过动态链接的方式来使用C标准库,因此printf函数实际上就是一个存在于外部动态库的实现,通过观察printf的执行,即可以了解到在程序中延迟绑定的运作过程。我们将上述代码编译后进行反汇编:
main函数对printf的调用流程如下:
- main函数不会直接调用printf函数,而是调用puts@plt。注意,这里编译器会优化对printf的调用为对库函数puts的调用;
- puts@plt的第一条指令通过GOT[3]进行跳转。由于每个GOT表项初始化时都指向对应PLT条目的第二条指令,因此这个间接跳转会将控制转移到puts@plt的第二条指令继续执行;
- puts@plt的第二条指令会将puts的ID压入栈中之后,然后跳转到PLT[0]中的指令;
- .plt中指令继续压入全局偏移表表中第二个表项所存放的地址,即本模块ID,最后跳转到动态链接器的入口**_dl_runtime_resolve**,执行符号解析;
- 完成符号解析后,_dl_runtime_resolve会将解析出来的puts函数的地址,填入GOT[3]中,到达这一步后,对puts函数的符号绑定工作就完成了。
参考
- 《程序员的自我修养——编译、装载与库》
- 《深入理解计算机系统》