第7章 动态链接

文章总结

为什么需要动态链接

静态链接存在的问题:浪费内存和磁盘空间、模块更新困难

内存和磁盘空间

Program1和Program2分别包含Program1.o和Program2.o两个模块,并且它们还共用Lib.o这个模块。在静态连接的情况下,因为Program1和Program2都用到了Lib.o这个模块,所以它们同时在链接输出的可执行文件Program1和Program2有两个副本。当我们同时运行Program1和Program2时,Lib.o在磁盘中和内存中都有两份副本。当系统中存在大量的类似于Lib.o的被多个程序共享的目标文件时,其中很大一部分空间就被浪费了。在静态链接中,c语言静态库是很典型的浪费空间的例子,还有其他数以千计的库如果都需要静态链接,那么空间浪费无法想象。

程序开发和更新

一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。比如一个程序有20个模块,每个模块1MB,那么每次更新任何一个模块,用户就得重新获取这个20MB的程序。如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一且程序任何位置的一个小改动,都会导致整个程序重新下载。

动态链接

基本思想:不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。把链接这个过程推迟到了运行时再进行。

在内存中共享一个目标文件模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上

上面的动态链接方案也可以使程序的升级变得更加容易,当要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。

动态链接的方式使得开发过程中各个模块更加独立,耦合度更小,便于不同的开发者和开发组织之间独立进行开发和测试。

程序可扩展性和兼容性

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件(Plug-in)。

比如某个公司开发完成了某个产品,它按照一定的规则制定好程序的接口,其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展。 ———— 例如初始接口实现为空函数,然后实际使用时,用第三方开发的动态库进行替换,从而实现接口实现内容的替换。

动态链接可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,**这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间依赖的差异性。**比如操作系统A和操作系统B对于printf()的实现机制不同,只要操作系统A和操作系统B都能提供一个动态链接库包含printf(), 并且这个printf()使用相同的接口,那么程序只需要有一个版本,就可以在两个操作系统上运行,动态地选择相应的printf()的实现版本。

动态链接例子

回到动态链接的机制上来,当程序模块Programl.c被编译成为Program1.o时,编译器还不知道foobar()函数的地址。当链接器将Program1.o链接成可执行文件时,这时链接器必须确定Program1.o中所引用的foobar()函数的性质。如果foobar()是一个定义与其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位:如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行

链接器如何知道foobar的引用是一个静态符号还是一个动态符号?

这实际上就是我们要用到Lib.so的原因。Lib.so中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar是一个定义在Lib.so的动态符号。这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用。

动态链接程序运行时地址空间分布

查看进程的虚拟地址空间分布:

Lib.so与Program1都是被操作系统用同样的方法去映射至进程的虚拟地址空间,只是它们占据的虚拟地址和长度不同。Program1除了使用Lib.so以外,它还用到了动态链接形式的C语言运行库libc-2.6.1.so,另外还有一个Linux下的动态链接器ld-2.6.so。

动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行Program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给Program1,然后开始执行。

共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

地址无关代码

装载时重定位

为了能够使共享对象在任意地址装载,首先能想到的方法就是静态链接中的重定位。基本思路:在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。

动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。

地址无关代码

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。

目的:希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。

把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,得到了如图的4种情况。

  • 第一种是模块内部的函数调用、跳转等。
    • 相对偏移调用指令
  • 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
    • 基于当前PC值 + 偏移量
  • 第三种是模块外部的函数调用、跳转等。
    • 在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。(编译时,可以确定GOT相对当前指令的偏移)
  • 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。
    • GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。

GOT用于模块外部的内容访问。

各种地址引用方式:

-fpic 和 -fPIC

使用GCC产生地址无关代码很简单,只需要使用“-fPIC”参数即可。实际上GCC还提供了另外一个类似的参数叫做“-fpic”,即“PIC”3个字母小写,这两个参数从功能上来讲完个一样,都是指示GCC产生地址无关代码。唯一的区别是,“-fPIC”产生的代码要大,而“-fpic”产生的代码相对较小,而且较快。由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,“-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fPIC”则没有这样的限制。

延迟绑定

动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址:对于模块间的调用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成(生成可执行文件只是依赖符号表信息),即程序开始执行时,动态链接器都要进行一次链接工作,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度。这是影响动态链接性能的两个主要问题。

延迟绑定实现

ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。

**ELF使用PLT(Procedure Linkageble Table)的方法来实现。**先从动态链接器的角度设想:假设liba.so需要调用libc.so中的bar()函数,那么当liba.so中第一次调用bar()时,就需要调用动态链接器中的某个函数来完成地址绑定工作,假设这个函数叫做lookup(),则至少需要知道这个地址绑定发生在哪个模块,哪个函数?假设lookup的原型为lookup(module, function),这两个参数的值在我们这个例子中分别为liba.so和bar()。在Glibc中,我们这里的lookup()函数真正的名字叫_dl_runtime_resolve()。

bar()函数在PLT中的项的地址称为bar@plt:

bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
1
2
3
4
5

lookup(module,function)这个函数的调用:先将所需要决议符号的下标(bar符号引用在重定位表“.rel.plt”中的下标)压入堆栈,再将模块ID压入堆栈,然后调用动态链接器的_dl_runtime_resolve函数完成符号解析和重定位工作。

ELF将GOT拆分成了两个表叫做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址。

动态库链接相关结构

在Linux下,动态链接库的路径都是“/lib/ld-linux.so,且属于Glibc的一部分属于系统库级别,它的版本号往往跟系统中的Glibc库版本号一致。

动态符号表

在静态链接中,有一个专门的段叫做符号表“.symtab”(Symbol Table),里面保存了所有关于该目标文件的符号的定义和引用。对应的ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段的段名通常叫做“.dynsym”(Dynamic Symbol)。与“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。

很多时候动态链接的模块同时拥有“.dynsym”和“.symtab”两个表,“.symtab”中往往保存了所有符号,包括“.dynsym”中的符号。

动态链接符号表的结构与静态链接的符号表几乎一样,可以简单地将导入函数看作是对其他目标文件中函数的引用:把导出函数看作是在本目标文件定义的函数就可以了。

动态链接重定位表

共享对象需要重定位的要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就是说有导入的符号时,那么它的代码或数据中就会有对于导入符号的引用。在编译时这些导入符号的地址未知,在静态链接中,这些未知的地址引用在最终链接时被修正。但是在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。

PIC模式的共享对象也需要重定位:虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用

动态链接的步骤和实现

先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。

  • 动态链接器自举
  • 装载共享对象
    • 共享对象全局符号介入:一个共享对象里面的全局符号被另外一个共享对象的同名全局符号覆盖。当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
  • 重定位和初始化

显示运行时链接

在Linux中,从文件本身的格式上来看,动态库实际上跟一般的共享对象没有区别。主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose),程序可以通过这几个 API对动态库进行操作。对应头文件和库为 <dlfcn.h> ,/lib/libdl.so.2。

不同的函数有不同的参数和返回值类型,即有不同的函数签名。