动态链接
=》上篇《=
延迟绑定 (PLT)
动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT, 然后再进行间接跳转,另外一个原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作。我们将在这一节介绍优化动态链接性能的一些方法。
- 延迟绑定实现
在动态链接下,程序模块之间包含了大量的函数引用,会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位。
如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫做延迟绑定的做法,基本的思想就是当函数第一次被用到时才进行绑定
ELF 使用PLT(Procedure Linkage Table) 的方法来实现
当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT。 PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项,比如bar()函数在PLT中的项的地址我们称之为 bar@plt。
bareplt:
jmp *(baraGOT)
push n
push moduleID
jump _dl_runtime_resolve
- 第一条指令是一条通过GOT间接跳转的指令,跳转到 bar(), 实现函数正确调用。
但是为了实现延迟绑定,链接器在初始化阶段并没有将 bar()的地址填入到该项,而是将上面代码中第二条指令 “push n”的地址填入到bar@GOT中, - 第二条指令将一个数字n 压入堆栈中,这个数字是bar这个符号引用在重定位表“rel.plt” 中的下标。
- 接着又是一条push指令将模块的ID 压入到堆栈,然后跳转到 _dl_runtime_resolve。 这实际上就是在实现我们前面提到的 lookup(module,function)这个函数的调用:先将所需要决议符号的下标压入堆栈, 再将模块ID压入堆栈,然后调用动态链接器的_dl_runtime_resolve()函数来完成符号解析和重定位工作。_dl_runtime_resolve()在进行一系列工作以后将bar()的真正地址填入到bar@GOT中。
一旦bar()这个函数被解析完毕,当我们再次调用bar@plt 时,第一条jmp指令就能够跳 转到真正的bar()函数中
上面我们描述的是 PLT 的基本原理,PLT真正的实现要比它的结构稍微复杂一些。ELF将GOT拆分成了两个表叫做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址,另外“.got.plt”还有一个特殊的地方是它的前三项 是有特殊意义的,分别含义如下
● 第一项保存的是“.dynamic” 段的地址,这个段描述了本模块动态链接相关的信息,我 们在后面还会介绍“.dynamic”段。
● 第二项保存的是本模块的 ID。
● 第三项保存的是 _dl_runtime_resolve的地址。
动态链接相关结构
动态链接情况下,可执行文件的装载与静态链接情况基本 一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个 “Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟 空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。
但是在动态链接情况下,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,因为我们知道可执行文件依赖于很多共享对象。这时候,可执行文件里对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来。 所以在映射完可执行文件之后,操作系统会先启动一个动态链接器
在Linux 下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式 将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址;当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文 件的入口地址,程序开始正式执行。
- “.interp”段
系统中哪个才是动态链接器呢,它的位置由谁决定?
是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专 门的段叫做“interp”段,“interp”的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。操作系统在对可执行文件的进行加载的时候,它会去寻找装载该可执行文件所需要相应的动态链 接器,即“.interp”段指定的路径的共享对象。
Linux 下,可执行文件所需要的动态链接器的路径几乎都是 “/lib/ld-linux.S0.2”, 其他的*nix 操作系统可能会有不同的路径。在 Linux的系统中,/lib/ld-linux.so.2通常是一个软链接, 比如在我的机器上,它指向/ib/ld-2.6.1.so,这个才是真正的动态链接器。
我们也可以用这个命令来查看一个可执行文件所需要的动态链接器的路径
readelf -l a.out | grep interpreter
- “.dynamic”段
动态链接ELF中最重要的结构应该是“.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的 位置、共享对象初始化代码的地址等。
- 动态符号表
为了完成动态链接,最关键的还是所依赖的符号和相关文件的信息。
为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表的段用来保存这些信息,这个段的段名通常叫做“.dynsym” 。与“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有 “.dynsym” 和“ .symtab”两个表,“.symtab” 中往往保存了所有符号,包括“ .dynsym” 中 的符号。
与“ .symtab”类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表“ .strtab”, 在这里就是动态符号字符串表“.dynstr”; 由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表 (“.hash”) 。
- 动态链接重定位表
在动态链接中,导入符号的地址在运行时才确定,所 以需要在运行时将这些导入符号的引用修正,即需要重定位。
动态链接的可执行文件使用的是PIC 方法,但这不能改变它需要重定位的本质。PIC 模式的共享对象也需要重定位。
在前面“静态链接”中分析过的目标文件的重定位十分类似, 唯一有区别的是目标文件的重定位是在静态链接时完成的,而共享对象的重定位是在装载时 完成的。在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如 “.rel.text”表示是代码段的重定位表,“.rel.data”是数据段的重定位表。
动态链接的文件中,也有类似的重定位表分别叫做“.rel.dyn”和“.rel.plt”, 它们分别相当于“ .rel.text”和“.rel.data"。"rel.dyn”实际上是对数据引用的修正,它所修正的位置 位于“.got”以及数据段;而“.rel.plt”是对函数引用的修正,它所修正的位置位于“got.plt”。
当动态链接器需要进行重定位时,它先查找“printf”的地址,“printf”位于libc-2.6.1.s0。 假设链接器在全局符号表里面找到“printf”的地址为0x08801234, 那么链接器就会将这个 地址填入到“got.plt”中的偏移为0x000015d8 的位置中去,从而实现了地址的重定位
- 动态链接时进程堆栈初始化信息
站在动态链接器的角度看,当操作系统把控制权交给它的时候,它将开始做链接工作, 那么至少它需要知道关于可执行文件和本进程的一些信息。
这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。我们在前面提到过,进程初始化的时候,堆栈里面保存了关于进程执行环境和命令行参数等信 息。事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组
动态链接的步骤和实现
动态链接的步 骤基本上分为3步:
- 先是启动动态链接器本身
- 然后装载所有需要的共享对象
- 最后是重定位和初始化
- 动态链接器自举
我们知道动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。
动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。
这种具有一定限制条件的启动代码往往被称为自举,动态链接器入口地址即是自举代码的入口
- 装载共享对象
完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表。然后链接器开始寻找可执行 文件所依赖的共享对象。读取相应的ELF 文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进 程空间中。如果这个 ELF 共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名 字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止。
- 全局符号介入与地址无关代码
当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表, 将它们的GOT/PLT中的每个需要重定位的位置进行修正。
重定位完成之后,如果某个共享对象有 “init”段,那么动态链接器会执行 “init”段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的C++的全局/静态对象的构造就需要通过“.init”来初始化。
当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器就如释重负,将进程的控制权转交给程序的入 口并且开始执行。
显式运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接, 有时候也叫做运行时加载。 也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。这种运行时加载在理论上也是很容易实现的。而且一般的共享对象不需要进行任何修改就可以进行运行 时装载,这种共享对象往往被叫做动态装载库 其实本质上它 跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。