0%

《程序员的自我修养》Ch7动态链接(下)

7.4 延迟绑定(PLT)

由前可知,GOT相比静态链接,增加了灵活性,但是降低了运行的速度(因为多了运行时重定位的过程)。同时,因为要在真正运行程序前将程序引用到的所有函数都进行动态链接,这个过程很耗时,导致用户在点击运行程序后需要等待的时间变长,影响用户体验(用户就是上帝!)。

同时,考虑到一个事实,那就是:在我们实用程序的时候,并不是所有在程序中被引用的函数都真正被调用了(比如if判断就会导致有些函数不会被调用)。于是考虑,能不能在函数第一次被调用的时候才对函数进行链接,这样也能将原本的时间(一次性链接所有函数)平摊到每次调用函数的时候——一个函数第一次链接所需要的时间很少,使得用户体验得到明显提升。

这种方案称作延迟绑定(Lazy Binding),基本思想就是在函数第一次被用到的时候才进行绑定(符号查找、重定位等),这样可以大幅提高程序的启动速度。

ELF具体使用的是PLT(Procedure Linkage Table)方法,在ELF文件中表现为段名中包含plt的段,其本质就是在指令和GOT之间又增加了一层跳转的过程,在第一次调用的时候,调用一个“绑定函数”来完成函数的绑定过程(填充GOT表中的项),在之后的调用时,直接调用GOT表中的项。每一个函数都在plt中有一个对应的项。

动态链接器绑定过程具体调用的方法是_dl_runtime_resolve(),它需要的两个参数:

  1. 这个函数绑定发生在哪个模块(module)
  2. 需要绑定的是哪个函数(function)

一个例子

我们在liba.so模块中调用了bar()函数,则会调用.plt段中的bar项,具体表示为(bar@plt):

1
2
3
4
5
bar@plt:
jmp *(bar@GOT) ; bar@GOT表示bar在GOT中的项的地址
push n ; n是bar这个符号在.rel.plt段中的下标,用以查询符号名
push moduleID ; moduleID是调用bar的模块的ID
jump _dl_runtime_resolve ; 最后三句是函数调用过程

需要注意的是,第二行的*(bar@GOT)在未初始化时(第一次调用bar之前),指向的是第三行的地址(也就是push n),当执行了_dl_runtime_resolve_之后,*(bar@GOT)的值就被修改为bar真正的地址了。后续再调用bar函数的时候,就不会执行后面三句了,同时bar函数的返回地址也是堆栈中EIP保存的函数调用者的地址了,而不是push n

实际的ELF中,GOT被拆成了.got.got.plt两个段,前者是用来保存全局变量引用的地址,后者是用来保存函数引用的地址。

为了减少.plt中代码的重复,该段把每一项(上面的例子)中最后两句单独提取出来,同时把所需要的值(moduleID_dl_runtime_resolve_)放在了.got.plt段的开头,代码如下:

1
2
3
4
5
6
7
8
9
PLT0:
push *(GOT+4) ; 这里的GOT其实指的是.got.plt的起始地址
push *(GOT+8)
...
...
bar@plt:
jmp *(bar@GOT) ; 这里的GOT同理是.got.plt中的bar项的地址
push n
jump PLT0

.got.plt的内容如图(.plt未画出):

7.5-GOT中PLT的数据结构

图7-5 GOT中PLT的数据结构

PS:.got.plt的第一项是.dynamic段的地址,这个段描述了本模块动态连接的相关信息。后面的小节会讲一下这个段。

PPS:实际的ELF中还有.plt.plt.got这两个段,与上面提到的.got.got.plt很容易弄混,这四个段的区别的作用可以看一个Blog:计算机系统篇之链接(14):.plt、.plt.got、.got 和 .got.plt section 之间的区别 - 代码先锋网 (codeleading.com),感觉写得不错。

7.5 动态链接相关结构

动态链接的简要流程是:1. OS加载可执行文件头,读取Segment的属性,将它们映射到进程的虚拟地址空间中;2. OS将CPU的控制权交给动态链接器(Dynamic Linker),由其完成程序的动态链接过程;3. 完成动态链接工作之后,将控制权交给可执行文件的入口地址。

7.5.1 “.interp”段

这个段的内容是一个字符串,保存的是可执行文件用到的动态链接器的路径。

objdump -s xxx即可查看xxx的不同段的内容,如果xxx是动态链接的可执行程序,那么就能找到.interp段的内容:

1
2
3
Contents of section .interp:
0318 2f6c6962 36342f6c 642d6c69 6e75782d /lib64/ld-linux-
0328 7838362d 36342e73 6f2e3200 x86-64.so.2.

7.5.2 “.dynamic”段

“.dynamic”段是ELF动态链接中最重要的结构了,它保存了动态链接器完成动态链接(中的重定位)所需要的基本信息,包括但不限于:依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。这个段是一个结构体数组,该结构体的定义(定义在elf.h中)为:

1
2
3
4
5
6
7
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word_d_val;
Elf32_Addr_d_ptr;
} d_un;
} Elf32_Dyn;

其中d_tag指代的是一项的类型,d_un是一项的值。这里的类型就包括了上面所说的那些信息,比如d_tagDT_SYMTAB,则d_un的值就是的信息是动态链接符号表的地址。还有其他的类型,请看书P205或搜索。

Linux中有一个ldd命令,可以查看一个可执行程序/共享对象依赖的共享库

7.5.3 动态符号表

动态链接中,一个模块A引用了模块B的函数foobar,那么称foobar为模块A导入函数(Import Function),为模块B导出函数(Export Function)。导入/导出函数可以分别看作函数的引用和定义。

采用了动态连接的可执行文件/库,除了包含前面静态链接中提到过的符号表“.symtab”,还包含了针对动态连接的模块之间的符号导入导出关系的动态符号表(Dynamic Symbol Table),名为.dynstr的段。

readelf -s可以查看程序的.symtab.dynstr

7.5.4 动态链接重定位表

与静态链接中的重定位表类似,动态链接也有相应的重定位表——.rel.dyn.rel.plt,前者是对数据的修正,后者是对函数引用的修正。

即使是PIC编译的共享对象,也需要重定位,只不过代码部分不用重定位,但是数据部分仍然需要(如GOT表)。

7.5.5 动态链接时进程堆栈初始化信息

在动态链接器接手CPU的控制权之后,为了进行动态链接,它需要知道该文件的基本信息,包括:有几个段(Segment)、每个段(Segment)的属性、程序的入口地址等,以装载可执行文件/共享对象。由前文6.4节可知,这些信息来源于文件的执行视图,可通过程序头(Program Header)获取。

进程初始化的堆栈里面就包含了程序头以及可执行文件的信息,这些信息构成了辅助信息数组(Auxiliary Vector),如:可执行文件的文件句柄、程序头的地址、程序头中入口(Entry)的数量和每个的大小、可执行文件的入口地址等等。这个数组的元素的数据结构定义如下:

1
2
3
4
5
6
7
8
typedef struct
{
uint32_t a_type; //该项的类型
union
{
uint32_t a_val; //该项对应的值
}a_un;
}Elf32_auxv_t;

也是键值对的形式(为什么有一个union是历史遗留的原因)。

这个数组具体的位置是在环境变量指针的后面(环境变量指针在程序输入参数的后面,也就是argc以及argv所指向的内容的后面。

这一小节的示例代码(P213)在我的机器上并不能正常运行,需要经过修改,修改的时候对底层的原理有些疑问,等有空了再写一篇Blog详细解释一下

7.6 动态链接的步骤和实现

动态链接主要分为3步:启动动态链接器本身、装载所有依赖的共享对象、重定位和初始化。

7.6.1 动态链接器自举

由于动态链接器本身就是为了给其他可执行文件or共享对象提供动态连接的功能,所以它不能自己动态链接自己,这是一个鸡生蛋、蛋生鸡的问题。虽然动态链接器本身是静态链接的(可以用ldd命令查看),但是其中的全局变量和静态变量仍然需要重定位,可能因为是绝对寻址的,所以需要跟据装载地址调整地址,这个过程叫做动态链接器的自举(Bootstrap),在这个过程中,执行的代码不能用到全局变量和静态变量,也不能调用函数。完成自举之后,就能随意使用了。

7.6.2 装载共享对象

当一个新的共享对象被装载到内存中时,它的符号表会被合并到全局符号表(Global Symbol Table)中(包括动态链接器和可执行文件的符号表),对共享对象的依赖关系可以看作一个图的数据结构,所以装入顺序分为深度优先和广度优先,一般采用的是广度优先的方法。

这里存在一个可能的问题,就是不同的共享对象中的符号可能存在重复的情况,处理的方式是,哪一个符号最先被加载,哪一个就有效,其余的符号都会被解析为第一个被加载的同名符号(C语言出现同名的概率要大一些,C++的命名空间机制namespace大大减少了同名符号的可能性)。这种处理方式表明了符号的优先级

7.6.3 重定位和初始化

这个阶段,动态链接器开始遍历可执行程序以及每个对象的重定位表,对GOT/PLT等结构中需要重定位的地方进行重定位。

然后就是执行不同共享对象的初始化代码(如果有的话),初始化代码位于.init段中。值得注意的是,可执行程序的.init代码并不是由链接器执行的,而是由程序初始化部分代码负责(后面的章节会讲)。

7.6.4 Linux动态链接器实现

Linux动态链接器既是一个共享对象,也是一个可执行程序。

共享库和可执行文件实际上没有什么区别,除了头文件的标志位和扩展名有所不同之外,其他都是一样的。

  • 动态链接器本身是静态链接的,且不依赖于其他共享对象。
  • 动态链接器一般是PIC的,为了代码段能够共享。
  • ld.so的文件中标明的装载地址是0x00000000。这是一个无效地址,在装载的时候OS会为其选择一个合适的装载地址。

7.7 显示运行时链接

以上讲的都是动态链接时动态链接器对共享对象的自动装载,还有一种更灵活的方法是由程序员决定的“手动装载”,就是在代码中显示地调用系统提供的API来加载一个共享对象,这个被加载的共享对象也称作动态装载库(Dynamic Loading Library),只是看待的角度不同。

这种机制可以用来实现插件的效果。当用到某个模块的时候,才将该模块装载到内存中,不需要的时候也可以调用API进行卸载,可以很好的利用有限的内存空间。

书中主要介绍了由动态链接器提供的4个API:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)、关闭动态库(dlclose)。这些API位于dlfcn.h头文件中(Linux下)。Windows也提供了rundll这个程序用来实现动态装载的功能。7.7.5小节还写了一个演示程序,值得看一看。

PS:如果直接在你自己的电脑上运行的话,很大可能会报错,因为书上的代码是十多年前的版本了,还是32位,现在早已经是64位的天下了,且API早已更新了很多版(待我有时间的时候写一版目前可运行的版本。