程序员的自我修养——动态链接
动态链接
动态链接确实有很多的优势,比静态链接要灵活的多,但它也是牺牲一部分性能为代价的, ELF 程序在静态链接下要比动态库稍微快一些,大约为 1%~5%。动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问要通过 GOT 表定位,然后间接寻址,对于模块间的调用也需要 GOT 表,然后进行间接跳转,如此一来,程序的运行速度必定会减慢。另一个原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器需要进行一次链接工作,寻找并装载所需要的共享对象,然后符号查找地址重定位等工作,势必会减慢程序的启动速度。我们将在后面看到如何进行优化。
延迟绑定(PLT)
延迟绑定的实现
在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量将会导致模块间的耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间来用于解决模块之间的函数引用的符号查找以及重定位,这也是我们上面提到的减慢动态链接性能的第二个原因。
为了优化,ELF 采用了一种叫做延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找,重定位等),如果没有用到就不进行绑定。所以当程序开始执行的时候,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器负责绑定。
ELF 使用 PLT(Produce Linkage Table)的方法来实现。当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过 GOT 中相应的项进行间接跳转。PLT 为了实现延迟绑定,在这个过程中加入了一层间接跳转。调用函数并不直接通过 GOT 跳转,而是通过一个叫做 PLT 项的结构来进行跳转。每个外部函数在 PLT 中都有一个相应的项,比如 bar() 函数在 PLT 中的项的地址我们称之为 bar@plt
1 | bar@plt |
第一条指令是一条通过 GOT 间接跳转的指令,bar@GOT 表示 GOT 中保存 bar() 这个函数相应的项。如果链接器在初始化阶段已经初始化该项,并且将地址填充上去了,那么我们就可以直接跳转过去了。但实际上为了延迟绑定,链接器并没有在初始化的时候就将 bar() 的地址填入到该项中,而是将上面代码中第二条指令push n
的地址填入到 bar@GOT 中,这个步骤不需要查找任何符号,所以代价很低。很明显,第一条指令的效果就是跳转到第二条指令,将一个数字 n 压入堆栈中,这个数字是 bar 这个符号引用在重定位表.rel.plt
中的下标,接着将模块 ID 压入栈中,然后跳转到函数地址绑定函数。
先将所需要的决议符号的下标压入堆栈,再将模块 ID 压入堆栈,然后调用动态链接器的 _dl_runtime_resolve() 函数来完成符号解析和重定位工作。
一旦解析完成后,当我们再次调用 bar@plt 时,第一条 jump 指令就能够跳转到真正的 bar() 函数中。
这个只是基本的原理,PLT 真正的实现要比它的结构复杂一些。ELF 将 GOT 表拆分成2个来保存,一个是 .got
另一个是 .got.plt
。其中 got 表用来保存全局变量引用的地址, gotplt 表用于保存函数引用的地址,此外它的前三项也是有特殊的含义:
- 第一项保存的是
.dynamic
段的地址,这个段描述了本模块动态链接相关的信息 - 第二项保存的是本模块的 ID
- 第三项保存的是 _dl_runtime_resolve 的地址
其中第二项和第三项有动态链接器在装载共享模块的时候负责将它们初始化,其余的项对应于外部函数的调用。
1 | ;实际的 PLT 基本结构 |
动态链接相关结构
ELF 的动态链接的方式比 PE 稍微简单一点。
在动态链接的情况下,可执行文件的装载与静态链接情况基本一样,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的 Program Header 中读取每个 Segment 的虚拟地址,文件地址和属性,并把他们映射到进程虚拟空间的相应位置,这些步骤和静态链接的情况基本一致,此时如果是静态链接,操作系统就会把控制权交给可执行文件的入口地址,然后程序执行,但在动态链接的情况下,操作系统还不能在装载完的时候就移交可执行权限,因为我们知道,可执行文件依赖很多共享对象,这时候文件里的对外部符号的引用还处于无效地址的状态,这时操作系统需要启动动态链接器(Dynamic Linker)
在 Linux 下,动态链接器 ld.so 实际上是一个共享对象,操作系统通过映射的方式将它加载到进程地址空间中。操作系统在加载完动态链接之后,就将控制权交给动态链接器的入口地址。然后动态链接器开始自身初始化,根据当前环境参数对可执行文件进行动态链接工作。当所有的动态链接工作完成后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
.interp 段
系统中哪个才是动态链接器呢?它的地址不由系统配置指定,也不是由环境参数决定,而是由 ELF 可执行文件决定。在动态链接的 ELF 可执行文件中有 .interp 负责存储,它的里面保存一个字符串格式的可执行文件动态链接器的路径,但往往操作系统中这个路径是一个软链接,它指向的文件才是真正的动态链接器,可以使用 readelf -l xxx | grep interpreter
来查看
$ readelf -l a.out | grep interpreter
[Requesting program interpreter: /lib/ld-linux.so.2]
.dynamic 段
动态链接 ELF 中最重要的结构应该就是 .dynamic 段,这个段里保存了动态链接所需要的基本信息,比如依赖哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,共享对象初始化代码的地址等。
1 | // .dynamic 的结构 |
Elf32_Dyn 的结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同的含义。这里列举几个比较常见的类型值(这些值都是定义在 elf.h 里面的宏)
d_tag类型 | d_un的含义 |
---|---|
DT_SYMTAB | 动态链接符号表的地址,d_ptr表示.dynsym的地址 |
DT_STRAB | 动态链接字符串表地址,d_ptr表示.dynstr的地址 |
DT_STRSZ | 动态链接字符串表地址,d_val表示大小 |
DT_HASH | 动态链接器哈希表地址,d_ptr表示.hash的地址 |
DT_SONAME | 本共享对象SO-NAME |
DT_RPATH | 动态链接共享对象搜索路径 |
DT_INIT | 初始化代码地址 |
DT_FINT | 结束代码地址 |
DT_NEED | 依赖的共享对象,d_ptr表示所依赖的共享对象文件名 |
DT_REL,DT_RELA | 动态链接重定位表地址 |
DT_RELENT,DT_RELAENT | 动态重读位表入口数量 |
使用 readelf -d Lib.so
来查看
动态符号表
为了完成动态链接需要解决模块之间的符号导入和导出关系,ELF 有专门一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段通常叫做 .dynsym
,与 .symtab
不同的是,前者只保存了动态链接相关的符号,而对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有 .dynsym
和 .symtab
两个表,.symtab
往往保存了所有的符号。
符号表为了保存字符串,会有动态符号字符串表(Dynamic String Table),即 .dynstr
。由于动态链接下,我们需要在程序运行的时候查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(.hash),我们可以使用 readelf -sD xxx.so
来查看 ELF 文件的动态符号表和它的哈希表。
动态链接重定位表
动态链接中,导入符号的地址需要在运行时才能确认,所以需要在运行时进行导入符号的修正(重定位),动态链接的可执行文件使用的方法是PIC(地址无关代码)方法,但这并不能改变它需要重定位的本质。对于一个动态链接来说,如果一个共享对象不是以PIC模式编译的,那么它需要在装载的时候被重定位。但如果一个共享对象是PIC模式编译的,那么它依然需要在装载时进行重定位。
对于实行PIC技术的可执行文件或者共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但数据段依旧包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,变成了GOT,GOT表是数据段的一部分,除它之外,数据段还可能包含绝对地址引用。
动态链接重定位相关结构
在静态链接的文件中,.rel.text
是代码段的重定位表而 .rel.data
是数据段的重定位表,在动态链接的文件中有.rel.dyn
和 .rel.plt
,相当于上面的两个。.rel.dyn
实际上是对数据引用的修正,它所修正的位置位于 .got
以及数据段,而 .rel.plt
实际上是对于函数引用的修正,它修正的位置位于.got.plt
使用 readelf -r
和 readelf -S
来查看一个动态链接的文件的重定位表:
1 | $ readelf -r Lib.so |
我们关注R_X86_64_JUMP_SLO
R_X86_64_GLOB_DAT
R_X86_64_RELATIVE
,虽然和书上的不同,但这也仅仅是处理器不同。JUMP_SLO
和 GLOB_DAT
的修正方式都是将正确的地址填入对应的位置中。而比较麻烦的是RELATIVE
类型的重定位入口。
这种类型实际上是基址重置(Rebasing),共享数据无法做到地址无关(它可能会包含对绝对地址的引用),而对于绝对地址的引用,我们必须在装载时就进行重定位。如:
1 | static int a; |
在编译的时候,共享对象的地址从0开始,我们假设该静态变量 a 相对于起始地址0的偏移量为 B,p的值为 B。一旦共享对象被装载到地址 A,那么实际 a 的地址为 A+B,那么 p 的值需要加上一个装载的地址 A,才是正确的。 RELATIVE
类型的重定位入口就是专门用来重定位指针变量 p 这种类型的。
动态链接时进程堆栈初始化信息
站在动态链接的角度看,当操作系统把控制权交给它的时候,它将开始做链接工作。那么至少它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段(Segment),每个段的属性,程序的入口地址(因为动态链接器到时候需要把控制权交给可执行文件)等。这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。进程初始化的时候,堆栈里面保存了关于进程执行环境和命令行参数等信息。除此之外,堆栈还保存了动态链接器所需要的一些辅助信息数组(Auxiliary Vector),其格式也是结构数组,被定义在 elf.h
中
1 | typedef struct{ |
这个数组位于进程堆栈中环境变量指针的后面,假如操作系统传给了动态链接器的辅助信息有4个,分别是:
- AT_PHDR,值为0x08048034,表示程序表头位于0x08048034。
- AT_PHENT,值为20,表示程序表头中每个项的大小为20字节。
- AT_PHNUM,值为7,程序表头共有7项。
- AT_ENTRY,值为0x08048320,程序的入口地址为0x08048320
联合前面的进程堆栈的初始化,我们可以得到比较全面的堆栈的初始化信息了:
- 0xBF801FD8 ~ 0xBF802000 记录了环境变量等信息:
- HOME=/home/usr
- PATH=/usr/bin
- 执行的命令是 prog 123
参考
《程序员的自我修养》
程序员的自我修养——动态链接