链接、装载与库(六) 动态链接

1. 为什么要动态链接

静态链接诸多缺点,比如浪费内存和磁盘空间、模块更新困难等

内存和磁盘空间:静态链接的方式对于计算机内存和磁盘的空间浪费非常严重,特别是在多进程操作系统情况下。

程序开发和发布:空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载。

动态链接:要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的问题,而不再将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。动态链接解决了共享的目标文件多个副本浪费磁盘和内存空间的问题。另外在内存中共享一个目标文件模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。动态链接的方式使得开发过程中各个模块更加独立,耦合度更小,便于不同的开发者和开发组织之间独立进行开发和测试。

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

动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间依赖的差异性。

动态链接的基本实现:动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。动态链接涉及运行时的链接及多个文件的装载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。目前主流的操作系统几乎都支持动态链接这种方式,在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO, Dynamic Shared Objects),简称共享对象,它们一般都是以”.so”为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是很常见的以”.dll”为扩展名的文件

2.地址无关码

装载时重定位:为了能够使共享对象在任意地址装载,我们首先想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)。但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数”-shared”和”-fPIC”,如果只使用”-shared”,那么输出的共享对象就是使用装载时重定位的方法

地址无关代码:装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为**地址无关代码(PIC, Position-independent Code)**的技术。

对于现代的机器来说,产生地址无关的代码并不麻烦。我们先来分析模块中各种类型的地址引用方式:这里把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为:指令引用和数据访问,这样就得到了四种情况:

第一种是模块内部的函数调用、跳转等:被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。

第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量:指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。

第三种是模块外部的数据访问,比如其它模块中定义的全局变量:模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量b,它被定义在其它模块中,并且该地址在装载时才能确定。要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其它模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。GOT如何做到指令的地址无关性?从第二中类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译时确定GOT相对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址,当然GOT中每个地址对应于哪个变量是由编译器决定的。

第四种是模块外部的函数调用、跳转等: 也可以采用上面类型三的方法来解决,与上面的类型有所不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转

使用GCC产生地址无关代码很简单,我们只需要使用”-fPIC”参数接口。实际上GCC还提供了另外一个类似的参数叫做”-fpic”,即”PIC”3个字母小写,这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,”-fPIC”产生的代码要大,而”-fpic”产生的代码相对较小,而且较快

那么我们为什么不使用”-fpic”而要使用”-fPIC”呢?
原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,”-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而”-fPIC”则没有这样的限制。所以为了方便起见,绝大部分情况下,我们都使用”-fPIC”参数来产生地址无关代码。

共享模块的全局变量问题:ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其它模块的全局变量,也就是说当作前面的类型三,通过GOT来实现变量的访问。

数据段地址无关性:对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了”R_386_RELATIVE”类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不使用地址无关代码。但是,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程

3.延迟绑定(PLT)

动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。据统计ELF程序在静态链接下要比动态库稍微快点,当然这取决于程序本身的特性及运行环境等。动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都需要进行一次链接工作,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度。这是影响动态链接性能的两个主要问题。

延迟绑定实现:在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位。不过可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种延迟绑定(Lazy Bingding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。ELF使用PLT(Procedure Linkage Table)的方法来实现延迟绑定,这种方法使用了一些很精巧的指令序列来完成。当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫做PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项。

ELF将GOT拆分成了两个表叫做”.got”和”.got.plt”。其中”.got”用来保存全局变量引用的地址,”.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了”.got.plt”中。另外”.got.plt”还有一个特殊的地方是它的前三项是有特殊意义的,分别含义如下:第一项保存的是”.dynamic”段的地址,这个段描述了本模块动态链接相关的信息;第二项保存的是本模块的ID;第三项保存的是_dl_runtime_resolve()的地址。其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。”.got.plt”的其余项分别对应每个外部函数的引用。PLT在ELF文件中以独立的段存放,段名通常叫做”.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的”Segment”被装载入内存。

4. 动态链接的步骤和实现

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

动态链接器自举:动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成;它也可以依赖于其它共享对象,其中的被依赖的共享对象由动态链接器负责链接和装载。动态链接器的特殊性:首先是,动态链接器本身不可以依赖于其它任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于第一个条件我们可以人为地控制,在编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的即是”.dynamic”段的偏移地址,由此找到了动态链接器本身的”.dynamic”段。通过”.dynamic”中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。

装载共享对象:完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,”.dynamic”段中,有一种类型的入口是DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和”.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其它共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看做一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其它的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号

符号的优先级一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象的全局符号介入(Global Symbol Interpose)。关于全局符号介入这个问题,实际上Linux下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应该非常小心符号的重名问题,如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。

重定位和初始化:当所有依赖的共享对象被装载进来以后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个须要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程也显得比较容易,跟前面提到的地址重定位的原理基本相同。重定位完成之后,如果某个共享对象有”.init”段,那么动态链接器会执行”.init”段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的C++的全局/静态对象的构造就需要通过”.init”来初始化。相应地,共享对象中还可能有”.finit”段,当进程退出时会执行”.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作。如果进行的可执行文件也有”.init”段,那么动态链接器不会执行它,因为可执行文件中的”.init”段和”.finit”段由程序初始化部分代码负责执行。当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器就如释重负,将进程的控制权转交给程序的入口并且开始执行

Linux动态链接器实现:内核在装载完ELF可执行文件以后就返回到用户空间,将控制权交给程序的入口。对于不同链接形式的ELF可执行文件,这个程序的入口是有区别的。对于静态链接的可执行文件来说,程序的入口就是ELF文件头里面的e_entry指定的入口;对于动态链接的可执行文件来说,如果这时候把控制权交给e_entry指定的入口地址,那么肯定是不行的,因为可执行文件所依赖的共享库还没有被装载,也没有进行动态链接。所以对于动态链接的可执行文件,内核会分析它的动态链接器地址(在”.interp”段),将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。

在Linux下,可执行文件所需要的动态链接器的路径几乎都是”/lib64/ld-linux-x86-64.so.2”,其它的unix操作系统可能会有不同的路径。在Linux的系统中,/lib64/ld-linux-x86-64.so.2通常是一个软链接。动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序,可以直接在命令行下面运行。共享库和可执行文件实际上没什么区别,除了文件头的标志位和扩展名有所不同之外,其它都是一样的。Windows系统中的EXE和DLL也是类似的区别,DLL也可以被当作程序来运行,Windows提供了一个叫做rundll32.exe的工具可以把一个DLL当作可执行文件运行。

5. 显式运行时链接

支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。如果动态链接器可以在运行时将共享模块装载进内存并且可以进行重定位等操作,那么这种运行时加载在理论上也是很容易实现的。而且一般的共享对象不需要进行任何修改就可以进行运行时加载,这种共享对象往往被叫做动态装载库(Dynamic Loading Library),其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。这种运行时加载使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等功能。当程序需要用到某个插件或者驱动的时候,才将相应的模块加载进来,而不需要从一开始就将它们全部加载进来,从而减少了程序启动时间和内存使用。并且程序可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等,这对于很多需要长期运行的程序来说是很大的优势。

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

猜你喜欢

转载自blog.csdn.net/weixin_43202635/article/details/112204186