动态链接浅析
阅前声明:本文是一篇“复现、验证和笔记”,更详细的内容还请查看文末链接中的第一篇博客,作者写得非常好 : )
1.为什么要动态链接
在多进程背景下,一个程序可能会使用库文件(如glibc标准库),如果运行着多个进程,则每个进程都需要一份glibc,操作系统就需要在物理内存中加载多份glibc,浪费内存。同时,每个程序的可执行文件都包含glibc库,对磁盘空间也是一种浪费。
因此我们可以考虑把程序的模块相互分割开来形成独立的文件。如果程序a和b都依赖于模块c,当程序a运行时,才链接c,此时c被加载进内存。当程序b运行时,就可以直接和内存中的模块c相链接,不用再单独加载。这样无论内存还是磁盘中,都只有一份模块c,节省了空间。也就是说,我们把链接的过程推迟到了运行时再进行,这就是动态链接的基本思想。
这里的模块c,由于能够实现一次加载,多进程程序共享,在Linux被称为动态共享对象(dynamic Shared Object),后缀名.so由此而来。
2.动态链接的例子
1 // program1.c
2 #include "Lib.h"
3
4 int main()
5 {
6 foobar(1);
7 return 0;
8 }
1 // program2.c
2 #include "Lib.h"
3
4 int main()
5 {
6 foobar(1);
7 return 0;
8 }
1 // Lib.c
2 #include <stdio.h>
3 void foobar(int i)
4 {
5 printf("Printing from Lib.so %d\n", i);
6 }
1 // Lib.h
2 #ifndef LIB_H
3 #define LIB_H
4
5 void foobar(int i);
6
7 #endif
program1.c和program2.c都调用了Lib.c中的foobar()函数。为了在内存中仅加载一次Lib.c,使program1.c和program2.c共享,我们将Lib.c编译为共享对象:
gcc -fPIC -shared -o Lib.so Lib.c
其中,-shared表示生成共享对象,-fPIC表示地址无关代码,暂且按下不表。
需要注意的是,所谓共享Lib.c,并不是完全共享它,而是只共享Lib.c的代码部分。对于Lib.c的数据部分,每个程序都需要自己的一份拷贝,因为它们可能需要独立地修改Lib.c中的数据。
即共享代码,私有数据。

然后,再来编译program1.c和program2.c:
gcc -o program1 program1.c ./Lib.so
gcc -o program2 program2.c ./Lib.so
此时带上Lib.so是因为我们总要知道,哪些函数是要动态链接(共享)的吧,而Lib.so中保存了完整的符号信息,从而链接器可以得知所引用的符号是静态符号还是动态符号,所以编译时需要Lib.so。
执行:

执行program1时,操作系统会首先在虚拟内存空间中加载进一个动态链接器,由动态链接器完成链接任务,然后执行程序。
3.GOT
前面提到,动态链接可以共享代码,也就是共享对象的指令部分,因为指令部分是不变的。
实际上这并不准确,因为指令部分有两个地方是需要在装载时确定的。
一是指令中访问其它模块的数据。共享对象的指令中,如果访问访问其它模块的数据,不管用什么形式的地址,总是要指定地址才能访问到。但是其它模块的数据的地址需要在装载时才能确定,这就不可避免地要修改指令中的地址。
一旦要修改,对于每个程序来说,这部分就无法共享了。
这个问题就是动态链接的核心问题。
二是指令中调用其它模块的函数面临同样的问题。
于是我们就需要GOT。
指令中有些地址需要在装载时才能确定,也就是不同的进程可能有不同的地址。
考虑到共享对象的数据是每个程序各有一份,而数据段和代码段的相对位置又是相对确定的。因此,我们可以在数据段建立一个存放变量的指针数组——GOT(Global Offset Table)。这样,在对跨模块数据进行访问时,指令中的地址就从跨模块的地址变成了GOT中指针的地址,也就是说可以通过GOT中的指针间接访问。
4.动态链接的实现细节
①got和got.plt
在实现细节上:对于跨模块数据的访问,建立.got表;对于跨模块函数的访问,建立.got.plt(Producer Link Table)表。
我们可以通过 objdump -h Lib.so 查看Lib.so:


②共享对象全局数据访问
在共享对象的代码段中,对模块内全局数据的访问,也是通过.got实现的。既然是在模块内,为什么不直接用相对地址访问呢。
因为其它模块可能会用到该全局数据,例如:
1 // module.c
2 extern int global;
3
4 int foo()
5 {
6 global = 1;
7 }
module.c引用了外部变量global,并对其进行赋值,因此需要知道global的地址。
在gcc -c时,gcc并不知道它在共享对象中被定义了。因此,会在.bss段中定义global,为其分配虚拟内存地址。
于是,在共享对象访问自身的全局变量时,也通过.got表,避免了进程中存在多个global的可能。
③延迟绑定PLT
共享对象可能会访问大量的外部函数,那么就会有一个庞大的.got.plt表存放函数符号。
当加载该共享对象时,动态链接器就需要将该对象所涉及的外部对象全部加载并链接,这可能耗费大量的时间;并且很多外部函数可能在整个进程的生命周期都不会被调用到,加载的时间就浪费了。
为了优化这一点,ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不绑定。具体实现方法为PLT,调用函数不直接通过got.plt,而是通过一个叫做PLT项(段)的结构,从这里取函数地址。
假设某个共享对象a要访问共享对象b中的bar函数,那么在.got.plt和.plt中都有一个bar函数的项。
.plt中的bar函数的项的内容为:
jmp *(bar@got.plt)
push n
push moduleID
jump_dl_runtime_resolve
含义如下:
我们假设,共享对象第一次调用bar函数,用到了PLT技术。
jmp *(bar@got.plt)这句话是跳转到.got.plt中bar函数的地址,因为采用了延迟绑定技术,所以这里的地址并不是bar的地址,而是链接器在初始化时已经帮我们填写好的下一条push n指令的地址。
push n中的n指的是bar函数在.rel.plt段中的位置(id),用.rel.plt这个重定位段记录.got.plt中bar的位置,并告诉链接器这个bar的位置需要重定位。
然后push moduleID是把bar所在的模块id入栈。此时已经先后入栈了bar函数的id——n,和模块的id。
最后调用jump_dl_runtime_resolve,该函数帮我们加载并链接需要的外部模块,并在.got.plt中更新bar函数的地址。当然,该函数需要使用到上面的两个id,分别用来指定需要绑定的是哪个函数、该函数绑定发生在哪个模块。
一旦这个过程完成,再次通过got.plt调用bar函数时,就会跳转到真正的bar函数的地址了。
注意到,在PLT中,我们新增了.plt和.rel.plt段。
参考博客:
浙公网安备 33010602011771号