因为静态链接有缺点:
1、静态链接浪费计算机内存和磁盘空间。因为同一个库的目标文件会在不同的模块中留有多个相同的副本。
2、只要某一模块有更改,那么所有目标文件就必须再静态链接一次。
动态链接的基本思想是把程序各模块彼此分隔开来形成独立的文件,并把链接的过程推迟到运行时再进行。
动态链接解决了缺点1,因为它实现了目标文件的共享,与此同时,这样做还减少了物理页面的换进换出,增加了缓存的命中率。
它也解决了缺点2,因为个目标文件彼此独立,如果有更改只需要换掉那个发生更改的目标文件即可。同时,它加强了模块间的独立性,是各模块可以用不同的编程语言实现,这个非常好!另外,开发、测试和维护也变得容易了。
插件是动态链接可扩展性优点的体现。
程序可以动态地加载合适的运行库以适应不同的运行环境,这是动态链接兼容性的体现。
当然它也有缺点,比如说早期Windows中出现过的DLL
Hell。
动态链接必须要有动态链接文件或者也可以叫做动态链接库,程序和动态链接文件之间是通过动态链接器链接在一起的。
动态链接把链接过程从装载前推迟到装载时,这比静态链接慢,性能会下降,但是这点性能的下降可以换来空间的节省和程序灵活性的提升,所以它是值得的。另外,动态链接过程也是可以优化的。
在动态链接过程中,符号的引用被标记为动态,暂时不进行重定位,这个过程留待装载时进行。
由静态链接产生的可执行文件只有一个文件需要映射到虚拟地址空间中去,该文件就是可执行文件本身。而动态链接却不一样,它除了可执行文件本身外,还包括由动态链接库生成的共享目标文件。还有动态链接器也会被映射到虚拟地址空间中。
共享对象的最终装载地址在编译时是不确定的,装载器会根据虚拟地址空间的实际情况在装载时给共享对象分配一块合适的空间。
共享对象在装载时如何确定其在虚拟内存中的地址?
它不能在编译时确定自己在进程虚拟地址空间中的位置。
就是说动态链接文件可以被装在到虚拟内存空间中的任意位置上去,操作系统会根据实际的内存情况来确定共享目标文件的装载位置,这个就叫做装载时重定位。
虽说共享目标文件在虚拟内存中只有一份,但是由于各进程是彼此独立的,它们各自的操作也是彼此独立的,所有它们对共享目标文件中可修改数据部分的动作可能不一致,所以实际上共享目标文件在各进程中都有副本。
上面提到的副本是把动态链接文件的指令和数据部分都复制了一下,仔细想想这个完全没有必要。因为指令是只读的,数据是可读可写的,所以指令完全没有必要也复制,当然这里说的指令是指只读的,其他的都算作数据好了。
所以要想做到只让数据被复制,只需要把指令和数据分离开来就好了。只把数据的部分复制到各进程中去可以解决共享对象中对绝对地址的重定位问题,这种技术叫地址无关代码技术。
这种技术是通过把地址引用计数分成4种方式分别处理的,划分的标准为模块内外和指令还是数据引用。
类型一模块内部调用或跳转
这种情况无需重定位,因为都是模块内部与绝对地址无关,只需要相对地址即可。
类型二模块内部数据访问
同样很简单,虽然绝对地址是变动的,但是相对地址还是固定的,所以你只要得到一条指令的绝对地址,其他的也就知道了。
那么如何获取某条指令的绝对地址就是一个问题了,而绝对地址存放在PC中,所以问题就转变为如何获取PC值。
PC(Program Counter):程序计数器,是一个16位的计数器。用于存放和指示下一条要执行的指令的地址。寻址范围达216。PC有自动加1功能,以实现程序的顺序执行。PC没有地址,是不可寻址的,无法用指令对它进行读写。但在执行转移、调用、返回等指令时能自动改变其内容,以改变程序的执行顺序。
关于这一点,书中说得不直接。
书中这段汇编代码与8086CPU的不太一样,看起来有些费劲。
书中讲述的其实是黑体这一段,但是还涉及到红色箭头所指这一段。程序首先调用494处的函数把栈顶指针的内容,而这个内容就是执行call之前的主调函数的发出调用语句的地址,存储到ecx寄存器中,然后返回。说得简单一点就是它用了call的性质把要用到的绝对地址保存起来而已。
类型三模块间的数据访问
由于模块间的目标地址只有等到装载时才能确定,所以ELF在自己的数据段中建立了一个指针数组,用来指向其他模块的全局变量。这个指针数组叫做全局偏移表(Global
Offset Table,GOT)。
查找目标地址的过程是先查找GOT,然后根究GOT表项查找目标地址。
GOT中的各项数据是连接器在链接的时候查找各目标地址后填充的。
GOT存放在数据段,可以被修改,每个进程都有一个副本。
要想获得目标地址就必须首先获得GOT的地址,GOT地址的获取也是根据PC值再加上GOT相对于当前指令的偏移量得到的,这个和类型二中的方法相同。得到GOT的绝对地址以后,再加上目标地址相对于GOT的偏移量就可以得到目标地址。
类型四模块间调用,跳转
类似于类型三的方法,只不过这回从变量变成了函数。
DSO(Dynamic Shared Object):动态共享目标文件。
如果一个DSO经过下面语句后,在TEXTREL(代码段重定位地址表)中有输出,那就代表该DSO不是PIC(地址无关代码)的,因为真正的PICDSO是没有TEXTREL的。
PIE(Position Independent Executable,地址无关可执行文件):以地址无关代码方式编译的可执行文件。
如何处理定义在模块内部的全局变量?
当一个模块A引用了一个定义在其他模块B的全局变量C的时候。编译器无法判断C是在A中的其他目标文件还是在另外一个共享对象之中,即无法判断是否为模块间调用。
解决方案:程序主模块的代码并不是地址无关的,可执行文件在运行时不进行代码重定位,那么全局变量地址的确定就应该是在链接时完成的。在链接的过程中,链接器会在可执行文件的BSS段中创建一个C的副本。这样,同一个全局变量C在共享对象和可执行文件中都有一个副本,那么程序在实际执行过程中也不知道该用哪个,这会导致程序执行失败。
既然是歧义,那就消除歧义。就是说让所有用C的地方都使用BSS中的C,可使用GOT方式实现之。
以上是针对代码段而言的,本节来谈谈数据段的情况。
变量的地址会随着共享对象的装载而确定,但是共享对象的装载地址是不确定的,所以变量的地址也是不确定的。
可用装载重定位的方法解决此问题。
代码段也可以使用装载时重定位而不使用代码无关技术,但是此时它就不能被多进程共享,也失去了节省空间的优点,但是它运行速度快了。
延迟绑定,即函数被用到时才绑定,而不是说在程序开始执行时就对所有的函数等进行符号的解析和重定位。这样做可以提升动态链接的速度。
PLT(Procedure Linkage Table):假设某共享目标文件A要调用函数B,A必须要绑定函数B的地址,如何做到这一点?必须要有函数C,C要确定B具体在什么地方,哪个模块的哪个函数B。
PLT并不是通过GOT来确定目标地址的,而是通过PLT结构,PLT就像个数组。
这是实现延迟绑定的代码,它把指令2的地址放到了bar@GOT中,所以语句1的效果就是跳转到语句2上去。234合起来就是解析函数符号和重定位的过程。这个n是是该函数在PLT结构中的下标,ID是模块的ID。不同于一开始就把目标函数的地址放入GOT表中,PLT是一开始把一个数字放入GOT中,所以在程序开始阶段前者要花时间确定目标函数的地址而后者仅仅是压入一个数字,复杂度为O(1)。等真正用到这个函数的时候再根据n把目标函数插入到GOT表中,这就实现了延迟绑定。
GOT其实分成2个——got和got.plt。got.plt就是上面提到的PLT,它的前三项具有特殊意义:
1、第一项dynamic保存了本模块动态链接相关信息。
2、本模块ID。
3、解析函数的地址。
其他相对应的就是外部函数的引用了。
在动态链接情况下,操作系统不会在可执行文件装载完毕后就把控制权交给该可执行文件,因为还没有和共享目标文件链接起来。
这时操作系统会先启动一个叫动态链接器的东西,动态链接器也是个共享目标文件,它同样被加载到虚拟地址空间中,然后操作系统把控制权转交给动态链接器。
然后动态链接器就开始初始化,这之后开始动态链接,链接完成后再把控制权转交给可执行文件。
动态链接器的位置是由可执行文件决定的。
ELF文件中有一个interp段,该段存放着动态链接器的路径。
它保存了动态链接器所需要的基本信息,可以看做是动态链接下的ELF文件头。
dynsym:动态符号表段,它保存了与动态链接相关的符号,它表示各模块之间符号的导入导出关系。比如说,模块A的中B函数被模块C调用了,就是A导出了B,C导入了B。
dynsym有若干辅助表,比如动态符号字符串表,它用来在程序运行时查找符号。
动态链接目标文件中极有可能包含导入符号的引用,这些符号的绝对地址不到运行时是不会确定的,只有在运行时才能重定位。
该表就是为解决这个问题而存在的。
这个动态链接重定位表主要指2个——rel.dyn和rel.plt。前者负责对数据引用的修正,后者负责函数引用的修正。如果ELF文件不是以代码无关PIC模式编译的,对外部函数的引用还可能出现在rel.dyn中。
堆栈里面除了保存了进程执行环境和命令行参数以外,还保存了动态链接所需要的一些辅助信息数组。
辅助信息在进程堆栈的位置如下图所示:
这是堆栈的初始化图,由该图可以看出辅助信息数组位于环境指针的后面。
分三步:
1、启动动态链接器。
2、装载所需共享对象。
3、重定位和初始化。
动态链接器本身不依赖于任何共享对象。
动态链接器本身所需要的全局和静态变量的重定位工作由自身完成,这决定连接器启动代码不能使用任何静态和全局变量,同样不能调用函数,因为它们都会用到GOT/PLT,而这些都需要被重定位,但是此时实际没有任何重定位。
自举是指具有一定限制条件的启动代码,即,自举代码。
动态链接器的入口地址就是自举代码的地址。
动态链接器会把所有具有依赖关系的共享对象放入一个集合里面,这个集合是装载集合。
一个共享对象中的全局符号会被另一个共享对象中的同名全局符号覆盖掉,这种现象叫做全局符号接入。解决这个问题的方法是只要某个共享对象中出现的全局符号已经在全局符号表中了那么其他共享对象中的同名符号就不再加以考虑了。
为了解决由全局符号介入导致的函数功能异常的问题,本模块要把专属于本模块的符号编译单元私有化。
动态链接器就是根据全局符号表进行重定位。
这完成以后动态链接器就把控制权交给可执行文件了。
静态链接而成的可执行文件的入口是ELF文件头里的e_entry指定的入口,由于动态链接还要链接共享目标文件所以控制权还要交给动态链接器。
动态链接器本身是静态链接而成的,因为它不依赖于其他共享对象。
动态链接器本身是代码无关的,因为如果不是的话,代码段无法共享浪费内存,也会是本身初始化变得困难。
动态链接器作为一个共享对象,被装载在0x00000000处。
它有时候被简称为运行时加载。即程序在运行时需要哪个模块就加载哪个模块,不需要哪个模块就卸载哪个模块。
一般的共享对象不需要进行修改就可以进行运行时加载,这种共享对象叫做动态装载库。
动态库和一般的共享对象唯一的区别就是,一般的共享对象是由动态链接器在程序启动之前加载和链接的,而动态库通过由动态链接器提供的API进行操作的。
操作的步骤为打开动态库、查找符号、错误处理、关闭动态库。
dlopen函数负责完成这一功能,并将动态库加载进进程的地址空间中。
P247~P248介绍了dlopen函数的步骤。
dynamic load symbol,它是运行时装载的核心,它的功能是在动态库中查找相应的符号。
先前讲到的不同模块的同名全局符号会被第一个同名符号覆盖,这种优先级方式叫做装载序列。而所谓依赖序列是指对某个符号在某个共享对象中进行查找还会涉及到在该共享对象所依赖的共享对象中进行查找的过程。
dynamic load error用于判断上次调用是否成功。
dynamic load close,将一个已经加载的模块卸载。