编译与链接(三)——动态链接

链接分为静态链接和动态链接,前面我们介绍了静态链接,本文将介绍动态链接。动态链接的基本原理与静态链接其实一样,也是把各个目标文件连接到一起生生一个可执行的文件。只是链接时机不同而已。

动态链接的思想与优点

动态链接是什么呢,简单的说,不把各个模块连接在一起,而分成各自独立的模块,在运行之前不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是把链接的过程推迟到了运行时再链接,这就是动态链接的基本思想。

有了静态链接了,为什么还要动态链接呢。因为交之静态链接,动态链接有以下优点:1 共享目标文件在磁盘只存储一份,而不是多份,节省了磁盘空间;2 多个程序运行时,在内存只存在一份共享文件,节省内存空间;3 不同进程的数据和指令访问都集中在同一个共享模块,可以减少物理页面的换入换出,也可以增加CPU的缓存命中率;4 可以使程序升级更加容易,理论上只需要将目标文件覆盖掉;5 程序运行时可以动态的选择加载各种程序模块即插件;6 加强程序的兼容性,程序在不同平台运行时可以动态的链接到由操作系统提供的动态链接库,消除了程序对不同平台之间依赖的差异性。动态链接与静态链接的差异如图1所示。

动态链接描述

当程序被加载时,动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并且将程序中所有的未决议符号绑定到相应的动态连接库中,并进行重定位工作。从这里可以看出,在程序加载时才进行链接,这样会使得性能降低,即增加了运行时间。但是通过统计,与静态链接相比,动态链接的性能损失大约在5%以下。

下面先看一个简单的例子,进行感官上的认识。例如有程序program1和program2,其源文件program1.c和program2.c,这两个程序都将用到lib.c中定义的函数,我们把lib.c编译成共享文件lib.so。program1.c和program2.c各自编译出来的二进制文件program1.o和program2.o分别于lib.so链接,生成程序program1和program2。仅仅从program1的调度看整个编译和链接的过程如图2所示。

仅仅从program1的角度看,lib.so是程序的一部分,链接完成的程序program1与静态链接的没什么不同,但是在操作系统的角度看,内存中只有一份lib.so,program1与program2同时运行时共享使用lib.so。lib.so与program1一样,是被操作性用通用的方法映射到进程的虚拟地址空间。Program1中的共享对象除了lib.so之外,还用到了C语言运行库,和动态链接器。动态链接器与普通共享对象一样映射到进程地址空间,在系统开始运行program1之前,首先把控制权交给动态链接器,由它完成所有动态链接工作之后再把控制权交给program1。表示为:创建进程——>控制权交给动态链接器——>交给program1。

动态链接的注意点

由于需要在运行时才链接,我们会遇到一个问题:共享对象在装载时,如何确定他在进程虚拟地址空间的位置呢?因为可执行文件往往是第一个被加载的文件,可以选择一个固定空闲的地址,所以可执行文件基本可以确定自己在进程虚拟空间中的起始位置。但是共享对象不同,在编译时不能假设自己在进程虚拟地址空间的位置,在装载时地址也是不确定的。所以在装载是需要对指令和数据进行重定位,被称为装载时重定位。装载时重定位不能解决绝对地址引用的问题,对于该问题使用地址无关码技术。

地址无关码简述

地址无关码技术是指,把共享的指令部分分成可修改和只读部分,对于可修改的部分的指令与数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。

共享模块中的地址引用分为如下四种情况:1 模块内部的函数调用跳转等,2 模块内部的数据访问,比如模块中定义的全局变量,静态变量等,3 模块外部的函数调用跳转等,4 模块外部的数据访问,比如其他模块中定义的全局变量。

模块内部的函数调用比较简单,别调用函数与调用者都是处在同一个模块,他们之间的位置是固定的。与前面静态链接相同,是基于相对地址调用或者基于寄存器的相对调用,这种调用指令是不需要重定位的。

模块内部的数据访问也是使用的相对寻址;任何一条指令与它需要访问的模块内部数据之间的位置是固定的,只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。注意数据的相对的寻址往往没有相对于当前指令地址(PC)的寻址方式,需要再加上一个便宜量,详细的过程不再叙述。

模块间数据访问是指要访问其他模块的数据;其他模块的全局变量的地址跟模块装载地址有关,ELF的做法是在数据段里面建立一个指向这些变量的数据——全局便宜表(GOT),当需要访问模块外部数据时,西安查找GOT,然后从GOT中找到变量的目标地址如图3。

当要访问b时,程序会先找到GOT,根据GOT中变量所对应的项找到变量的目标地址。链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT的各个项,以确保每个指针所指向的地址正确。GOT本身是放在数据段,可以在模块装载时被修改,并且每个进程都可以有独立的副本,互不影响。

模块间函数调用、跳转采用的是与上面类似的方法来解决,不同的是GOT中相应的项保存的是目标函数的地址,当模块要调用目标函数时,可以通过GOT中的项进行间接跳转。其实为了优化系统,模块间的函数调用比数据访问麻烦一下。使用到了延迟绑定技术(PLT)。

延迟绑定技术简介。

动态绑定效率比静态绑定低1%—-5%,主要原因是1 动态链接下全局变量和静态变量的访问都要进行复杂的got定位,然后间接寻址;2 对于模块间的调用要先定位GOT然后再进行间接跳转;3 动态链接的工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作,动态链接器寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作。为了对动态绑定的性能进行优化,使用的延迟绑定技术。

延迟绑定的基本思想是当函数第一次被调用到时才进行绑定,如果没有用到则不进行绑定。假设liba.so需要调用libc.so中的bar()函数,当liba.so中第一次调用bark()时,就需要动态链接器中的函数_dl_rumtime_resolve()完成地址绑定工作。该函数需要知道这个地址绑定发生在哪个模块,哪个函数。PLT为了实现延迟绑定,又增加了一层间接跳转。函数调用并不直接通过GOT跳转,而是通过叫做PLT项的结构进行跳转。例如bar()在PLT中的项的地址称为[email protected],其内容如下:[email protected]:   jam*([email protected]);    push n;  push module ID;  jump_dl_runtime_resolve。为了实现延迟绑定,链接器在初始化阶段并没有将bar()的地址填入该项,而是将上面代码中的push n地址放入[email protected]中,第二条指令push n压入栈中,n是bar这个符号引用在重定位表.rel.plt中的下标。接着是将模块ID压入堆栈然后跳转到_dl_runtime_resolve。整个过程描述为:(1)先将所需要决议符号的下标压入堆栈,(2)再将模块ID压入堆栈,(3)最后调用动态链接器的_dl_runtime_resolve函数完成符号解析和重定位工作。一旦bar()这个函数被解析完毕,再次调用[email protected]时,第一条指令就能跳到真正的bar()函数。

ELT将GOT分成两个表叫做.got和.got.plt。其中.got用来保存全局变量引用地址,.got.plt用来保存函数引用的地址。.got.plt的结构如图4所示。

.got.plt还有一个特殊的地方是它的前3项有特殊意义,含义分别如下:

第一项保存的是.dynamic段地址,这个段描述了本模块动态链接相关信息。

第二项保存的是本模块的ID。

第三项保存的是_dl_runtime_resolve()的地址。其中第二项和第三项由动态链接器在装载共享模块时候负责将他们初始化。

到这里我们基本介绍清了动态链接工程,其实动态链接包含很多内容,例如共享模块的全局变量问题,dynamic段的内容,interp段的功能,动态符号表的结构等等,要了解的更详细,请参看相关书籍。