动态链接
一、动态链接与静态链接的对比
静态链接浪费内存和磁盘空间(因为静态链接在可执行文件中可能包含了公有库和其他库的内容,因此浪费磁盘空间,当系统中存在多个程序实例时,每个实例都有静态库的副本,因此浪费内存空间);
静态链接带来程序开发和发布的麻烦(当程序中有多个静态链接库存在时,只要其中一个模块更新,整个程序就需要重新链接和重新发布,尤其是要通过网络来更新程序时,如果采用静态链接,只要程序有一个小改动,就会导致整个程序都要重新下载);
动态链接的基本思想是将程序的模块相互分割开来,形成独立的文件,等到程序要运行时才进行链接。
动态链接可以使所有程序在内存中共享同一个目标文件,这样做不仅仅节省内存,还可以减少物理页面的换入换出,也可以增减CPU缓存的命中率。
动态链接可以使程序中的某个模块更新时,仅仅通过覆盖就的目标文件,无须将所有程序模块重新链接即可完成更新。
动态链接可以使程序的各个模块更加独立,耦合性更小,便于不同的开发者和开发组织之间独立进行开发和测试。
动态链接可以在运行时动态地选择加载各种程序模块,这个优点可以用来制作程序的插件(Plug-in),可以实现程序功能的扩展。
动态链接可以加强程序的兼容性,一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,从而消除了程序对不同平台之间的依赖的差异性(理论上可以,实际上还存在不少问题)。
动态链接也有自身的缺点,比如缺少一种有效的共享库版本管理机制,当程序所依赖的某个模块更新后,由于新的模块与旧模块之间接口不兼容,导致了原有的程序(系统中安装的其他依赖旧版本库的程序)无法运行,被称为“DLL Hell”。此外,动态链接会导致程序性能的损失(程序每次被装载时都要进行重新链接),通过一些优化技术(比如 延迟绑定)可以使得动态链接的性能损失尽可能地减少,据估算,动态链接与静态链接相比,性能损失大约在5%以下。
二、动态链接基本知识
目前主流的操作系统几乎都支持动态链接这种方式,在Linux系统中,ELF动态链接文件被称为动态共享对象()DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以“.so”为扩展名的一些文件; 在Window系统中,动态链接文件被称为动态链接库(Dynamical Linking Library ),它们通常就是我们平时很常见的以“.dll”为扩展名的文件。
三、地址无关代码
1、固定装载地址的困扰
共享对象在编译时不能假设自己在进程虚拟地址空间中的位置,因为共享模块是多个可执行文件共享的,装载的地址是不能固定的,如果固定的话,可能硬气共享对象之间的地址冲突问题。
2、装载时重定位
静态链接时的重定位叫做链接时重定位(Link Time Relocation),而动态链接的重定位称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又叫做基址重置(Rebasing)。
动态链接的装载时重定位要考虑的问题有:(1)指令部分怎样在多个进程之间共享(简单地通过地址偏移来重定位,需要修改指令,不同的进程偏移值可能不同,所以指令就会不同,因此不能实现指令共享),(2)数据部分对于不同进程来要有多个副本。
3、地址无关代码
地址无关代码(PIC, Position-independent Code)技术,可以用来解决共享对象指令中对绝对地址的重定位问题。该技术的基本想法是把指令中需要被修改的部分分离出来,跟数据部分放在一起,指令的其余部分保持不变,这样指令部分可以在多个进程之间共享,数据部分每个进程一个副本。
下面分析具体的实现过程:
为了产生地址无关代码,首先划分共享对象模块中各种类型的地址引用方式。共享对象模块中的地址引用方式按是否跨模块可以分成两类:模块内部引用和模块外部引用;按照不同的引用方式可以划分为:指令引用和数据访问。
因此可以将地址引用方式划分为四类:
模块内部的函数调用、跳转;
模块内部的数据访问,比如模块中的全局变量、静态变量;
模块外部的函数调用、跳转;
模块外部的数据访问,比如其他模块中定义的全局变量;
第一种,模块内部的函数调用、跳转,因为被调用和调用者都处于同一个模块中,它们之间的相对位置固定,因此都是使用相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令不需要重定位。这种情况在实际中存在一个“共享对象全局符号介入(Global Symbol interpposition)”问题。
第二种,模块内部数据访问,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的。因此当前指令加上固定的偏移量就可以访问模块内部数据了,因此这种模块内部的数据访问,通过相对寻址就可以实现,不需要重定位。
第三种,模块间的数据方位,由于模块间的数据访问的地址确定跟模块装载地址有关,是动态共享对象模块中的“地址有关部分”,应该被放在数据段中。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,被称为全局偏移表(Global Offset Table, GOT),当代码需要引用这些数据时,可以通过GOT中相对应的项间接引用。注意:由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
第四种,模块间的调用、跳转,使用上面类似的GOT方法,不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。
* 使用GCC产生地址无关代码,可以使用“-fPIC”参数。
* 判断一个DSO是否是PIC方式的:例子:使用 readelf -d foo.so | grep TEXTREL ,如果该命令有任何输出,那么foo.so就不是PIC的,否则是PIC的,因为PIC的DSO是不会包含任何代码段重定位表的, TEXTREL表示代码段重定位表地址。
* 地址无关代码技术除了可以用在共享对象上面,它也可以用在可执行文件上,一个以地址无关方式编译的可执行文件被称为“地址无关可执行文件(PIE, Position-Independent Executable)”,GCC产生PIE的参数为“-fPIE”或“-fpie”。
4、共享模块中的全局变量问题
共享模块中的全局变量在编译时不能确定是模块内的其他目标文件中定义的,还是其他共享模块中定义的,因此若按照静态链接那样处理,可能会在可执行文件和共享对象中分别存在该全局变量的副本,实际中,解决这个问题的办法是:将所有使用这个变量的指令都指向位于可执行文件中的那个副本。ELF共享库在编译时,默认都把定义在模块内部的全局变量当做定义在其他模块的全局变量,统一放在可执行文件中,通过GOT来实现变量的访问。
当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例,如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本; 如果该全局变量在程序主模块中没有副本,那么GOT中相应地址就是指向模块内部的该变量副本。
5、数据段地址无关性
数据段中也会存在绝对地址引用,但是对于数据段来说,它在每个进程中都有一个独立的副本,所以并不担心被其他进程改变。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了“R_386_RELATIVE”类型的重定位入口。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。
四、延迟绑定(PLT)
动态链接比静态链接慢的主要原因是:(1)、动态链接下对于全局和静态的数据访问、对于模块间的调用都要进行复杂的GOT定位,然后间接寻址或间接跳转;(2)、动态链接的链接工作在运行时完成,符号查找地址重定位等动作势必会减慢程序的启动速度。
在动态链接下,程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位,但是,在一个程序运行过程中,可能很多函数在程序执行完成时都不会被用到,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫做“延迟绑定(Lazy Binding)”的做法,基本的思想是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。这样做可以大大加快程序的启动速度。特别有利于一些有大量函数引用和大量模块的程序。
ELF使用PLT( Procedure Linkage Table)的方法来实现延迟绑定。当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转,PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫做PLT项的结果来进行跳转。
下面分析一下PLT的实现过程:
假设我们要调用其他模块的的一个bar()函数,该外部函数在PLT中有一个相应的项,该项的地址叫做:bar@plt,该地址处存放了一段很精巧的指令序列,如下:
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
第一条指令是一条通过GOT间接跳转的指令。bar@GOT表示GOT中保存bar()这个函数相应的项。如果不使用延迟绑定技术,链接器会在初始化阶段已经初始化该项,并且将bar()的地址填入该项,那么这个跳转指令会跳转到bar()的地址,实现函数正确调用。若使用延迟绑定,链接器在初始化阶段并没有将bar()的地址填入到该项,而是将上面代码中第二条指令“push n”的地址填入bar@GOT中,这个步骤不需要查找任何符号,所以代价很低。这样第一条指令的效果是跳转到第二条指令,相当于没有执行任何操作,第二条指令将一个数字n压入堆栈中,这个数据是bar这个符号引用在重定位表".rel.plt"中的下标。接着又是一条push指令将模块的ID压入到堆栈,然后跳转到_dl_runtime_resolve函数地址,这个函数的执行过程是:先将所需要决议符号的下标压入堆栈,再将模块ID压入堆栈,然后调用动态连接器的_dl_runtime_resolve()函数来完成符号解析和重定位工作。_dl_runtime_resolve()在进行一系列工作以后将bar()的真正地址填入到bar@GOT中。
一旦bar()这个函数被解析完毕,当我们再次调用bar@plt时,第一条jmp指令就能够跳转到真正的bar()函数中。
实际上ELF会将GOT拆分成两个表:".got"和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址。“.got.plt”的结构是:
“dynamic”段地址,该段描述了本模块动态链接相关的信息;
本模块的ID;
_dl_runtime_resolve()的地址;
每个外部函数的引用。
PLT在ELF文件中以独立的段存放,段名通常叫做“.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的“Segment”被装载如内存。
五、动态链接相关结构
无论静态链接还是动态链接,可执行文件在装载时,首先是操作系统读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟地址空间的相应位置。但是静态链接与动态链接不同的是,在静态链接下,操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行,在动态链接下,由于可执行文件依赖于很多共享对象,这时候可执行文件里对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来,所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker),将它加载到进程地址空间中,然后操作系统将控制权交给动态链接器的入口地址。当动态链接器获得控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作,在所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
“.interp”段
在动态链接的ELF可执行文件中,有一个专门的段叫做“.interp”段(interp是interpretrt(解释器)的缩写)。该段里面保存的是一个字符串,字符串内容是可执行文件所需要的动态链接器的路径。
查看一个可执行文件所需要的动态链接器的路径,在linux下使用这个命令: $ readelf -1 a.out | grep interpreter
".dynamic"段
动态链接ELF中最重要的结构应该是“.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。内容是一个结构的数组,该结构是:
typedef struct {
Elf32_SWord d_tag;
union{
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un ;
} Elf32_Dyn;
".dynsym"(动态符号表(Dynamic Symbol Table))
我们知道动态链接中有导入函数(Import Function)和导出函数(Export Function).为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表的段用来保存这些信息,段名通常叫做“.dynsym”。与符号表“.symtab”不同,".dynsym"只保存了与动态链接相关的符号,对于那些模块内部的符号则不保存,很多模块同时拥有“.dynsym”和“.symtab”两个表,“.symtab”表中往往保存了所有符号,包括“.dynsym”中的符号。
此外,动态符号表还需要一些辅助的表。比如动态符号字符串表“.dynstr”(Dynamic String Table)(用来保存符号名的字符串的表);符号哈希表(“.hash”)(用来辅助符号的查找)
动态链接重定位表
共享对象需要重定位的主要原因是导入符号的存在。
无论共享对象的可执行文件是否使用PIC技术,都需要重定位。因为即使是使用了PIC技术的可执行文件或共享对象,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,注:代码段中绝对地址相关的部分被分离出来,变成了GOT,而GOT实际上是数据段的一部分。
动态链接的重定位与静态链接的重定位类似,唯一的区别是目标文件的重定位在静态链接时完成,共享对象的重定位是在装载时完成的。动态链接的重定位表分别叫做“.rel.dyn”和“.rel.plt”,分别相当于目标文件中的“.rel.text”和“.rel.data”。“.rel.dyn”实际上是对数据引用的修正,它所修正的位置位于“.got”以及数据段;而“.rel.plt”是对函数引用的修正,它所修正的位置位于“.got.plt”。
静态链接中遇到过两种类型的重定位入口 R_386_32 和 R_386_PC32,动态链接会用到几种新的重定位入口类型:R_386_RELATIVE、R_386_GLOB_DAT和 R_386_JUMP_SLOT。
***动态链接时进程堆栈初始化信息
站在动态链接器的角度看,当操作系统把控制权交给它的时候,它将开始做链接工作,它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段、每个段的属性、程序的入口地址等,这些信息由操作系统传递给动态链接器,保存在进程的堆栈里面。我们已知,进程初始化时,堆栈里面保存了关于进程执行环境和命令行参数等信息,,事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组(Auxiliary Vector)。
辅助信息的格式是一个结构数组:
typedef struct
{
uint32_t a_type;
union
{
uint32_t a_val;
} a_un;
} Elf32_auxv_t;
六、动态链接的步骤和实现
动态链接的步骤基本上分为3步:(1)、启动动态链接器、(2)、装载所需要的共享对象、(3)、重定位和初始化
第一步:动态链接器的自举
动态链接器也是一个共享对象,它的特殊性在于:它不能依赖于其他共享对象;其次动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成,第一个条件可以在编写动态链接器时保证不适用任何系统库、运行库;第二个条件要求动态链接器在启动时有一段精巧的代码来完成本身的全局和静态变量重定位工作而不能用到全局变量和静态变量,这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。
动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的是“.dynamic”段的偏移地址,由此找到了动态链接器本身的“.dynamic”段。通过“.dynamic”中的信息。自举代码便可以获得动态链接器本身的重定位表和符号表等。从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。
注意:自居代码中除了不可以使用全局变量和静态变量之外,甚至不能调用函数即动态链接器本身的函数也不能调用。
第二步:装载共享对象
完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,这个符号表被称为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,将这些共享对象的名字放入到一个装载集合中,然后链接器开始从集合里取出一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止。
全局符号介入问题及解决方法
动态链接器按照各个模块之间的依赖关系,按照广度优先的顺序,对它们进行装载并且将它们的符号并入到全局符号表时,如果前后装载的两个不同模块都定义了同一个全局符号,那么它们在全局符号表中该如何存?这就是一个全局符号介入的问题。
Linux下动态链接器时这样解决“全局符号介入问题”的:它定义了一个规则,当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。因此,当程序使用大量共享对象时应该非常小心符号的重名问题,如果两个符号重名有执行不同的功能,那么程序运行时可能会将所有该符号的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。
第三步:重定位和初始化
完成上面步骤后,动态链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正,重定位完成之后,如果某个共享对象有“.init”段,那么动态链接器会执行“.init”段中的代码,用以实现共享对象特有的初始化过程(比如,共享对象中C++的全局/静态对象的构造就需要通过“.init”来初始化)。相应地,共享对象中还可能有“.finit”段,当进程退出时会执行“.finit”段中的代码。
注意:可执行文件中也有“.init”段和“.finit”段,不过动态链接器不会执行它,因为它们是由程序初始化代码负责执行的。
当重定位和初始化之后,所有的准备工作就宣告结束,所需要的共享对象也都已经装载并且链接完成,动态链接器将进程的控制权转交给程序的入口并且开始执行程序。