动态链接
为什么要动态链接
静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先限制程序的规模也随之扩大。但是慢慢地静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使人们不得不寻找一种更好的方式来组织程序的模块。
内存和磁盘空间
静态链接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统情况下,静态链接极大地浪费了内存空间,想象一下每个程序内部除了都保留着 printf()函数、sancf()函数、strlen()函数等这样的公用库函数,还有数量相当可观的的其他库函数及它们所需要的结构。
程序开发和发布
另一个问题时静态链接对于程序的更新、部署和发布也会带来很多麻烦。比如程序 Program1 所使用的 Lib.O 是由一个第三方厂商提供的,当该厂商更新了 Lib.o 的时候,那么 Program1 厂商就要拿到最新版的 Lib.o,然后将其与 Program1.o 链接后,将新的 Program1 整个发布给用户。这样做的缺点很明显,即一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。如果程序使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载。
动态链接
要解决空间浪费和更新困难着两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目标文件进行连接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。
以 Program1 和 Program2 为例,假设我们保留 Program1.o、Program2.o 和 Lib.o 三个目标文件。当我们要运行 Program1 这个程序时,系统首先加载 Program1.o,当系统发现 Program1.o 中用到了 Lib.o,即 Program1.o 依赖于 Lib.o,那么系统接着加载 Lib.o,如果 Program1.o 或 Lib.o 还依赖于其他目标文件,系统会按照这种方法将它们全部加载至内存。所有需要的目标文件加载完毕之后,如果依赖关系满足,即所有依赖的目标文件都存在于磁盘,系统开始链接工作。这个链接工作的原理与静态链接非常相似,包括符号解析、地址重定位等,我们在前面已经很详细地介绍过了。完成这些步骤之后,系统开始把控制权交给 Program1.o 的程序入口处,程序开始运行。这时如果我们需要运行 Program2,那么系统只需要加载 Program2.o,而不需要重新加载 Lib.o,因为内存中已经存在了一份 Lib.o 的副本,系统要做的只是将 Program2.o 和 Lib.o 链接起来。
很明显,上面的这种做法解决了共享的目标文件多个副本浪费磁盘和内存空间的问题,可以看到,磁盘和内存中只存在一份 Lib.o,而不是两份。另外在内存中共享一个目标文件模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加 CPU 缓存的命中率,因为不同进程的数据和指令访问都集中在了同一个共享模块上。
上面的做法也使得程序升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接起来,程序就完成了升级的目标。
地址无关代码
固定装载地址的困扰
通过了解了动态链接的概念,我们也遇到了一个问题,那就是:共享对象在被装载时,如何确定它在进程中的虚拟地址空间中的位置呢?
我们回顾我们之前在静态链接所提到的,程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件的时候,就要假设模块被装载的目标地址。为了实现动态链接,我们首先会遇到的问题就是共享对象地址的冲突问题。
很明显,在动态链接的情况下,如果不同的模块目标装载地址都一样是不行的。而对于单个程序来讲,我们可以手工指定各个模块的地址,比如把 0x1000 到 0x2000 分配给模块 A,把地址 0x2000 到 0x3000 分配给模块 B。但是,如果某个模块被多个程序使用,甚至多个模块被多个程序使用,那么管理这些模块的地址将是一个无比繁琐的事情。比如一个很简单的情况,一个人制作了一个程序,该程序需要用到模块 B,但是不需要用到模块 A,所以他以为地址 0x1000 到 0x2000 是空闲的,于是分配给另外一个模块 C。这样 C 和原先的模块 A 的目标地址就冲突了,任何人以后将不能同时在一个程序里使用模块 A 和 C。想象一个有着成千上万个并且由不同公司和个人开发的共享对象系统中,采用这种手工分配的方式几乎是不可行的。不幸的是,早期的确有一些系统采用了这样的做法,这种做法叫静态共享库。静态共享库的做法就是将程序的各种模块统一交给操作系统来管理,操作系统在某个个特定的地址划分处一些地址块,为那些已知的模块预留足够的空间。
静态共享库的目标地址导致了很多问题,除了上面提到的地址冲突的问题,静态共享库的升级也很快成了问题,因为升级后的共享库必须保持共享库中全局函数的变量和地址的不变,如果应用程序在链接时已经绑定了这些地址,一旦更改,就必须重新链接应用程序,否则会引起应用程序的崩溃。即使升级静态共享库后保持原来的函数和变量地址不变,只是增加了一些全局函数和变量,也会受到限制,因为静态共享库被分配到的虚拟地址空间有限,不能增长太多,否则可能会超出被分配的空间。种种限制和弊端导致了静态共享库的方式在现在的支持动态链接的系统中已经很少见,而彻底被动态链接所取代。
为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如 Linux 下一般都是 0x08040000,Window 下一般是 0x0040000。
装载时重定位
为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时在完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。假设函数 foobar 相对于代码段的起始地址时 0x100,当模块被装载到 0x10000000 时,我们假设代码段位于模块的最开始,即代码段的装载地址也是0x10000000,那么我们就可以确定 foobar 的地址为 0x1000100.这时侯,系统遍历模块中的重定位表,把所有对 foobar 的地址引用都重定位至 0x10000100。
事实上,类似的方法在很早以前就存在。早在没有虚拟存储概念的情况下,程序是直接被装载进物理内存的。当同时有多个程序运行的时候,操作系统根据当时内存空闲的情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。系统在装载程序的时候就需要对程序的指令和数据中对绝对地址的引用进行重定位。这种重定位比前面提到过的静态链接中的重定位要简单得多,因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置不会改变。比如一个程序在编译时假设被装载的目标地址为 0x1000,但是装载时操作系统发现 0x1000 这个地址已经被别的程序所使用了,从 0x4000 开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载至 0x4000,程序指令或数据中的所有绝对引用只要都加上 0x3000 的偏移量就可以了。
我们前面在静态链接中提到过重定位,那时的重定位叫做链接时重定位,而现在这种情况经常被称为装载时重定位。
这种情况与我们碰到的问题很相似,都是程序模块在编译时目标地址不确定而需要在装载时将模块重定位。但是装载时重定位的方法并不适合用来解决上面共享对象中所存在的问题。可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令重定位后对于每个进程来讲时不同的。当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
地址无关代码
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址引用的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码的技术。
对于现代的机器来说,产生地址无关代码并不麻烦。我们先来分析模块中各种类型的地址引用方式。这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的方式又可以分为指令引用和数据访问,这样我们就能分为四种情况:
- 第一种是模块内部的函数引用、跳转等。
- 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
- 第三种是模块外部的函数调用、跳转等。
- 第四种是模块外部的数据访问,比如其他模块定义的全局变量。
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1; //type 2
b = 2; //type 4
}
void foo()
{
bar(); //type 1
ext(); //type 3
}
类型一 模块内部调用或跳转
第一种类型应该是最简单的,那就是模块内部调用。因为被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。比如上面例子中 foo 对 bar 的调用可能产生如下代码:
8048344 <bar>:
8048344: 55 push %ebp
8048345: 89 e5 mov %esp,%ebp
8048347: 5d pop %ebp
8048348: c3 ret
8048349 <foo>:
...
8048357: e8 e8 ff ff ff call 8048344 <bar>
804835c: b8 00 00 00 00 mov $0x0,%eax
···
foo 中对 bar 的调用的那条指令实际上是一条相对地址调用指令。这条指令中后 4 个字节是目的地址相对于当前指令的下一条指令的偏移,即为 0xFFFFFFE8。0xFFFFFFE8 是 -24 的补码形式,即 bar 的地址为 0x804835c + (-24) = 0x8048344。那么只要 bar 和 foo 的相对位置不变,这条指令是地址无关的。即无论模块被装载到哪个位置,这条指令都是有效的。
类型二 模块内部数据访问
接着来看第二种类型,模块数据访问。很明显,指令中不能包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任意一条指令与它所需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。现在的体系结构中,数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,所以 ELF 用了一个很巧妙的办法来得到当前的 PC 值,然后再加上一个偏移量就可以达到访问相应变量的目的了。
类型三 模块间数据访问
模块间得数据访问比模块内部稍微麻烦一点,因为模块间得数据访问目标地址要等到装载时才决定,比如上面例子中的变量 b,它被定义在其他模块中,并且该地址在装载时才能确定。我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。 ELF 的做法是在数据段里面建立一个指向这些变量的指针数据,也被称为全局偏移表(GOT),当代码需要引用该变量时,可以通过 GOT 中相对应的项间接引用。
类型四 模块间调用、跳转
可以采用类型三的方法来解决,不同的是,此时 GOT 中相对应的项保存的是目标函数的地址。
延迟绑定
ELF 采用了一种叫做延迟绑定的做法,也就是当函数第一次被用到时才进行绑定,如果没有用到则不进行绑定。
ELF 使用 PLT 的方法来实现,这种方法使用了一些很精巧的指令序列来完成。
在开始介绍 PLT 之前,我们先从动态链接器的角度想一下:假设 liba.so 需要调用 libc.so 中的 bar() 函数,那么当 liba.so 中第一次调用 bar() 时,这时候用就需要调用动态链接器中的某个函数来完成地址绑定工作,我们假设这个函数叫做 lookup(),那么 lookup() 需要知道哪些必要的信息才能完成这个函数地址绑定的工作呢?我想答案可以很明显,lookup () 至少需要知道这个地址绑定发生在哪个模块,哪个函数?那么我们可以假设 lookup 的原型为 lookup,这两个参数的值在我们这个例子中分别为 liba.so 和 bar()。我们这里的 lookup() 函数真正的名字叫 _dl_runtime_resolve()。
当我们调用某个外部模块的函数时,如果按照通常做法应该是通过 GOT 中相应的项进行间接跳转, PLT 为了实现延迟绑定,在这个过程中间又增加了一层跳转。调用函数并不直接通过 GOT 跳转,而是通过一个叫作 PLT 项的结构来进行跳转。每个外部函数在 PLT 中都有一个相应的项,比如 bar() 函数中的项的地址我们称之为 bar@plt。让我们来看看 bar@plt 的实现:
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
bar@plt 的第一条指令时通过 GOT 间接跳转指令。bar@GOT 表示 GOT 中保存 bar() 这个函数的项。如果链接器在初始化以及初始化该项,并且将 bar() 的地址填入该项目,那么这个跳转指令的结果就是我们所期望的,跳转到 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() 函数中,bar() 函数返回的时候会根据堆栈里面保存的 EIP 直接返回到调用者,而不会再继续执行 bar@plt 中第二条指令开始的那段代码,那段代码只会在符号未被解析时执行一次。
上面我们描述的是 PLT 的基本原理,PLT 真正的实现要比它的结构稍微复杂一些。ELF 将 GOT 拆分成两个表叫做 “.got” 和 “.got.plt”。其中 “.got” 用来保存全局变量引用的地址,“.got.plt” 用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了 “.got.plt” 中。另外 “.got.plt” 还有一个热塑的地方是它的前三项是有特殊含义的:
- 第一项保存的是 “.dynamic” 段的地址,这个段描述了本模块动态链接相关的信息。
- 第二项保存的是本模块的 ID。
- 第三项保存的是 _dl_runtime_resolve() 的地址。
其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。“.got.plt” 的其余项分别对应每个外部函数的引用。PLT 的结构也是与我们示例中的 PLT 稍有不同,为了减少代码的重复,ELF 把上面例子中最后两条指令放到 PLT 中的第一项。
PLT0:
push *(GOT + 4)
jump *(GOT + 8)
···
bar@plt:
jmp *(bar@GOT)
push n
jump PLT0
内容来源
《程序员的自我修养》