Linux中的动态链接

静态由于在链接的时候将所有的代码都合并在一起,每一个可执行文件都有一分静态链接库的副本,这样在多个进程进行运行的时候会造成内存浪费。还有一个缺点就是,当程序需要依赖其他模块时,由于静态链接已经把代码直接写死在可执行文件里面,这样当有一个模块更新时,需要重新链接一遍,导致没必要的麻烦。再说,很难实现程序的动态加载模块。目前有很多的软件可以根据文件格式来加载必要的模块,使用静态链接的话,很难实现该功能。

要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块互相分割开来,形成独立的文件,而不再将他们静态的链接在一起。简单的讲,就是不对那些组成程序的目标文件进行链接,等到程序需要运行时才进行链接,也即是说,把链接的整个过程推迟到运行才进行,这就是动态链接的基本思想。

假如我们保留Program1.o、Program2.o和Lib.o三个目标文件,但我们需要运行Program1这个程序时,系统首先加载Program1.o,当系统发现Program1.o中需要用到Lib.o时,那么系统会按照这种方法将他们全部加载至内存。所有需要的目标文件加载后,如果依赖关系满足,即所有依赖的目标文件都存在于磁盘,系统开始进行链接工作。这个链接工作原理和静态链接非常相似,包括符号解析、地址重定位等。完成这些工作后,系统开始把控制权交给Program1.o的程序入口处,程序开始运行。这是如果我们需要运行Program2.o,那么系统只需要加载Program2.0,而不需要再一次加载Lib.o,因为内存中已经存在一份Lib.o副本了。只需要将Program2.o和Lib.o链接起来。

在使用动态链接的时候,当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库上,并进行重定位工作。

装载时从定位

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

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

Linux和GCC支持这种装载重定位方法,在编译源文件生成共享对象时,使用了-shared -fPIC参数,如果只是使用-shared,那么输出来的共享对象就是使用装载时重定位的方法。

地址无关代码

为了实现程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中需要修改的那些部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每一个进程中拥有一个副本。这种方案就是目前被称为地址无关代码的技术。
为了实现地址无关技术,ELF在数据段里面建立了一个全局偏移表(Global Offset Table)GOT,用来存储指向需要重定位的全局变量的实际地址。当指令需要访问变量时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每一个变量都对应一个四个字节的地址,链接器在装载模块的时候会查找每一个变量所在的地址,然后填充GOT的各个项。由于GOT本身是放在数据段,所以他可以在模块装载时被修改,并且每一个进程都可以独立拥有一个副本。

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC方法来产生可执行文件的代码段部分,以便于不同进程能够共享代码段,节省内存。所以我们可以看到动态链接的可执行文件中存在”.got”段。

延迟绑定

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

在动态链接下,模块包含了大量的函数引用,所以程序在开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用符号查找以及重定位,不过在一个程序运行过程中,可能很多函数在程序执行完成时,都不会被用到,如果一开始就把所有函数都链接好,实际上就是一种浪费。所以ELF采用了一种叫做延迟绑定的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位)。

动态链接有关的结构

.interp 段

.interp段的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件说需要动态链接器的路径。在Linux下,可执行文件所需要的动态链接器的路径几乎都是一样的”/lib/ld-linux.so.2”。该文件通常是一个软连接,一般指向”/lib/ld-2.6.1.so”文件,该文件才是真正的动态链接器。在Linux中,操作系统在对可执行文件进行加载的时候,他回去寻找装载该可执行文件所需要相应的动态链接器,即”.interp”段所记录的路径的共享对象。

.dynamic 段

动态链接ELF中最重要的结构应该是”.dynamic”段,这个段里保存了动态链接器所需要的基本信息,比如依赖于那些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等信息。

动态符号表

为了完成动态链接,最关键的还是所依赖的符号和相关文件信息。在静态链接中,有一个专门的段叫做”.symtab”,里面保存了所有关于该目标文件的符号的定义和引用。动态链接的符号表实际上跟静态来凝结十分相似。为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表的段来保存这些信息。与”.symtab”不同的是,”.dynamic”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候,动态链接的模块同时拥有”.dynsym”和”.symtab”两个表,”symtab”中往往保存了所有符号,包括”.dynsym”中的符号。

动态链接重定位表

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

在静态链接中,目标文件的重定位表为”.rel.text”和”.rel.data”,在动态链接文件中,也有类似的重定位表分别叫做”.rel.dyn”和”.rel.plt”,分别相当于重定位数据段以及重定位代码段。”.rel.dyn”实际上是对数据引用的修正,他所修正的位置位于”.got”以及数据段,而”.rel.plt”是对函数引用的修正,他所修正的位置位于”.got.plt”。

ELF将got拆分成两个表叫做”.got”和”.got.plt”,”.got”用来保存全局变量引用的地址,”.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到”.got.plt”中。

装载共享对象

动态链接器完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,称为“全局符号表”。然后链接器开始寻找可执行文件所依赖的共享对象,在”.dynamic”段中,有保存改可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中,然后连接器开始从集合里面取一个所需的共享对象名字,找到相应的文件后,打开该文件,读取相应的ELF文件头和”.dynamic”段,然后将他的代码段和数据段映射到进程空间中。如果给ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放在装载集合中。如此循环知道所有依赖的共享对象都被装载进来为止。(一般采用广度优先装载)

当一个新的共享对象被装载进来的时候,他的符号表会被合并到全局符号表中,所以当所有共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。

重定位和初始化

在所有共享对象都装载完成后,链接器开始重新遍历可执行文件和每一个共享对象的重定位表,将他们的GOT/PLT中的每一个需要重定位的位置进行修改。因为此时动态链接器已经拥有了进程的全局符号表。所以修改过程比较容易实现。

重定位完成后,如果某一个共享对象有”.init”段,那么动态链接器会执行该段中的代码,用以实现共享对象特有的初始化过程。比如常见的。共享对象中的C++全局/静态对象的构造就需要通过”.init”来初始化。相应的,共享对象中还有可能有”.finit”段,当进程退出时,会执行该段的内容,用以实现类似的C++全局对象析构之类的操作。

如果进程的可执行文件也有”.init”段和”.finit”段,那么动态链接器不会执行他,因为可执行文件的这两个段由程序初始化部分代码负责执行。

当完成重定位和初始化工作后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这个时候动态链接器将进程的控制权转交给程序的入口并且开始执行。