今天简单聊聊动态链接库的实现原理。
假设有这样两段代码,第一段代码定义了一个全量变量a以及函数foo,函数foo中引用了下一段代码中定义的全局变量b。
第二段代码定义了全局变量b以及main函数,同时在main函数中调用了第一个模块中定义的函数foo。
接下来编译器出场,编译器会把这个两个源文件编译成对应的目标文件。
目标文件中主要有两部分,代码段和数据段,这两部分里面分别包含什么内容呢?
我们定义的全局变量会被放到数据段,代码被编译生成的二进制指令会被放到代码段,第二个目标文件也一样。
注意看第一段代码,这里引用了一个其它模块定义的全局变量b,这一信息记录在第一个目标文件,第二段代码引用了其它模块定义的函数foo,这一信息记录在第二个目标文件。
注意看第一段代码,这里定一个全局变量a和函数foo,我们记录下来,第二段代码定义了全局变量b和函数main,同样记录下来。
接着我们开始一个叫做连连看的游戏。
第一个模块引用了变量b,变量b的定义可以在第二个模块找到。
第二个模块引用了函数foo,foo的定义可以在第一个模块找到。
这个过程叫做符号解析。
这里看到的引用以及定义的符号保存在所谓的符号表中。
而如果第二个模块引用了一个叫做bar的变量,链接器翻遍所有其它模块都没找到bar这个符号的定义,而只找到了一个叫做foo的定义,这时链接器就会报一个叫做符合未定义的错误,这个错误写c/c++的程序员一定不陌生。
接下来链接器会把数据段合并到一起,代码段合并到一起并确定符号的内存地址,这个过程叫做重定位。
了解了这些就可以开始讲动态库的实现原理了,动态库又叫做共享库,我们的问题是,动态库是怎么实现可以被程序之间共享的呢?
假设现在有两个运行的程序和一个动态库liba. so,动态库中定了一个全局变量a,第一个程序把变量a修改为了10。
然后第二个程序开始运行,第二个程序也使用该动态库,然后把全局变量a修改为了20。
这是第一个程序运行一段时间后决定打印变量a,这时你会惊讶的发现变量a从10变成了20,但是为什么。
原因就是这两个程序共享了同一个数据段,所以一个程序对数据的修改对另一人程序是可见的,因此动态库中的数据段不能共享,每个程序需要有自己的数据段。
现在数据的问题解决了,我们来看函数。
假设动态库liba.so需要引用外部定义的foo函数,由于程序1和程序2都使用了该动态库,因此必须定义出foo函数。
我们知道函数调用最终会被编译器翻译成call机器指令后跟函数地址。
接下来我们需要解析出foo函数的地址到底是什么,这就是刚才我们提到的重定位,只不过动态库将这一过程推迟到了运行时。
由于程序1的foo函数位于内存地址0x123这个位置,因此链接器将call指令后的地址修正为0x123。
这时CPU执行这条call指令就能正确的跳转到第一个程序的foo函数。
而第二个程序的foo函数为内存地址0x456这个位置,接下来第二个程序开始运行,CPU开始执行foo函数,由于第二个程序的foo函数在0x456,因此我们希望CPU能跳转到这里,但由于动态库中call指令后跟的是0x123这个内存地址,因此CPU执行foo函数时依然会跳转到第一个程序的foo函数。
这时系统就出现了错误。
问题出在了哪里呢?
主要是call这条机器指令,这条指令后跟了一个绝对的内存地址,而不要忘了,这条指令或者说动态库是要被各个程序共享的,显然我们不能直接使用绝对地址。
该怎么办呢?
计算机中所有问题都可以通过增加一个中间层来解决。
这样我们就摒弃了直接调用,而采用间接调用。
而我们这里对函数的讨论对于全局变量的应用也是一样的道理,全局变量的使用也存在同样的问题,只不过是从函数调用变成了内存读写,解决问题的方法一样,我们从直接应用改为间接引用。
接下来我们依然以函数调用为例来讲解。
那么这个中间层到底是什么呢?
答案就是got。
还记得刚才提到的每个程序都有自己的数据区吗,这个got段就属于数据区的一部分。
got中有什么呢?got中记录了引用的全局变量或者函数的地址,在程序运行时链接器会找到foo的内存地址,然后填到got表中,这样通过查got表我们就能知道函数foo的内存地址了。
接下来的问题就是当CPU调用foo函数时怎么才能知道got表在哪里呢?
注意刚提到每个程序都有自己的数据区,实际上对于动态库来说也有自己的代码区。
我们现在只需要知道每个程序运行在自己的地址空间中,这些地址空间最终会被映射到真正的物理内存,动态库中的数据区会被映射到不同的内存区域,但代码段会被映射到同一段物理内存中,从而实现共享的目的。
接下来我们重点看进程地址空间中的动态库布局。
注意看,动态库的数据区和代码区总是相邻的,也就是代码区和got段的相对位置总是不变的,而不管动态库被放到了哪个位置。
多个程序也一样,也就是代码区和数据区的相对位置总是固定的,这个相对位置在编译时编译器就能确定。
现在foo会被编译成call指令,而程序在加载时链接器会向got段中写入foo的内存地址,显然两个程序的foo地址是不一样的。
接下来CPU开始执行第一个程序的call指令,此时CPU会做一个相对跳转,这个跳转距离是编译器确定的,CPU会跳转到got表,然后查找foo的地址发现是0x123,然后开始执行0x123这个位置的函数。
而如果CPU执行第二个程序中的foo函数,那么CPU同样会进行相对跳转,这不过这次跳转到的是第二个程序的got表,然后发现foo的地址是0x456,然后开始执行第二个程序中的foo函数。
这样我们就实现了执行同一个指令但却会跳转到不同地址的目的,从而在不改动动态库代码的前提先实现共享。
而如果一个动态库中引用了很多外部函数会怎么样呢?
这样程序在启动时链接器不得不对所有函数进行重定位,因此会拖慢程序启动速度。
而我们知道一个程序中不是所有的函数都会被调用到,经常调用的都是少数几个函数,为了利用这一点编译链接系统使用procedure linkage table, plt来推迟重定位这个过程,也就是程序在启动时不进行函数重定位,而是推迟到真正调用函数时,没用调用过的函数根本就不进行重定位,从而加快程序启动速度。
从这个一过程我们可以看到动态库的这种间接调用实际上会对程序性能有一定影响,但相对于动态库带来的好处与便捷,这点影响可以忽略不计。
这样,不管动态库被加载到内存的哪个位置都能正确被各个程序共享。
动态库的这个特性被称之为位置无关代码,简称position-independent code, pic,这就是为什么你在编译生成动态库时要加上pic编译选项的原因。
希望这篇对大家理解动态库有帮助。
动态链接库的实现原理是什么?