静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
但是在多进程的背景下,静态链接的缺点就显示出来了。
想象这样一种情况,一个程序使用了glibc标准库,现在计算机中同时运行着该程序的100个进程。那么,每个进程中都会有一份glibc,操作系统需要在真实的物理内存中加载100份glibc,浪费了很多内存。
进一步,有很多程序都使用了glibc标准库,每个程序又有多个进程,这样内存浪费就更大了。而且每个程序的可执行文件中都包含了glibc标准库,对磁盘空间也是一种浪费。
动态链接就是解决这个问题的。
大概思路是这样,程序a和b都依赖模块c,那么等程序a运行的时候,才链接c,c此时被加载进内存。程序b运行时,直接跟内存中的c进行链接,不再单独加载。
这样,无论是在磁盘还是内存中,都只有一份c模块,完美。
这里的模块c,由于能够实现一次加载,多程序多进程共享,在Linux中被称为动态共享对象,即dynamic shared objects。这样的模块一般以.so作为扩展名。
实际上,动态链接还有其他好处,比如提高程序的可扩展性和兼容性。
设想你在开发一个产品,需要某些第三方功能,你可以制定好需要的功能接口,第三方就按照你的接口开发动态链接文件,这样你的产品就可以在运行时动态地链接,实现程序功能的扩展。
另一方面,一个程序在不同的平台运行时可以动态链接到操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间的依赖的差异性。
天下没有免费的午餐,动态链接有这么多好处,也有不好的地方。比如,当你的程序依赖的某个动态链接库更新了版本,并且和老版本不兼容时,你的程序就不能运行了。这个问题被称为dll hell。
动态链接的例子 我们先实现一个最简单的动态链接库的例子,感受一下。
program1.c文件的内容:
#include "Lib.h"
int main()
{
foobar(1);
return 0;
}
program2.c文件的内容:
#include "Lib.h"
int main()
{
foobar(2);
return 0;
}
Lib.h文件的内容:
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
Lib.c文件的内容:
#include <stdio.h>
void foobar(int i)
{
printf("Printing from Lib.so %d\n", i);
}
program1.c和program2.c都调用了Lib.c里面的foobar函数。为了在内存中加载一次Lib.c,使program1和program2共享。我们可以将Lib.c编译成共享对象。
这里需要强调一下,这里所谓的共享,并不是共享整个Lib.c的内容,而是特指共享它的代码部分。 对于Lib.c中的数据部分,每个进程都需要一份自己的拷贝,因为它们可能需要独立地修改Lib.c中的数据。
一句话,共享的是代码,私有的是数据。图示如下:
先将Lib.c编译成共享对象:
gcc -fPIC -shared -o Lib.so Lib.c
-shared表示的是产生共享对象。 -fPIC的含义暂时先不用管,待会儿再说。
现在,我们来分别编译Program1和Program2:
gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so
这样生成的Program1和Program2 就可以执行了。你可能会问,不是说动态链接吗?为啥编译时还要带上Lib.so?
因为虽然是动态链接,但是编译的时候总要告诉Program,foobar这个函数是动态链接吧。而footbar的符号信息就在Lib.so中,所以这里编译时还是要带上Lib.so。
现在执行./Program1就可以执行,并看到如下输出:
执行Program1时,操作系统会首先在我们的虚拟进程空间中加载进一个动态链接器,动态链接器帮我们完成链接任务,然后我们的程序就开始执行了。
前面我们强调,动态链接共享的是代码,也就是共享对象的指令部分,因为指令部分是不变的。
实际上这并不准确,因为指令部分有两个地方是需要在装载时确定。
共享对象的指令中,如果访问其他模块的数据,不管用什么形式的地址,总是要指定地址才能访问到。但是其他模块的数据的地址需要在装载时才能确定,这就不可避免要修改指令中的地址。
一旦要修改,就无法被共享了。
这个问题,就是共享对象要面临的核心问题,解决了这个问题,就解决了动态链接的核心。
指令中的有些地址要在装载时才能确定,也就是不同的进程可能有不同的地址。
之前我们已经解释过,共享对象的数据段,是每个进程一份的。而数据段和代码段的相对位置又是确定的。
由此,我们就可以在数据段中建立一个指针数组,称其为GOT,global offset table。里面存放跨模块的数据的地址,当然,可以在装载时动态填入。然后,共享对象指令中对跨模块数据的访问,可以通过GOT中的指针间接访问。
这样的好处是,指令中的地址就从跨模块数据的地址,变成了got中指针的地址,而这个地址是相对代码段确定的。
以上,就是动态链接最最最核心的思想。打蛇打七寸,学习动态链接,也要先理解核心,不要一开始就陷入太多的细节中。
前面,编译共享对象时,我们使用了参数 -fPIC,它的作用就是确保代码段是地址无关的,即 position independent code。
在具体实现上,对跨模块的数据的访问,建立got表,对跨模块的函数的访问,建立got.plt表。plt这里是procedure link table。
我们可以通过查看Lib.so,来验证一下:
共享对象代码段中,对模块内全局数据的访问,也是通过got实现的。 既然是模块内,为啥不用相对地址呢?
因为其他的模块可能会使用全局数据。比如module.c中这样的代码:
extern int global;
int foo()
{
global = 1;
}
这个代码中,对global进行了赋值,既然是赋值,肯定需要global的地址,但是编译时,这里指的仅仅只包含编译,即gcc -c,不是之前的 gcc -o中带有Lib.so的选项。
gcc并不知道它在共享对象中定义了。因此,gcc会在bss段中定义global,也就是说,在编译module.c时,就为global分配了虚拟内存地址。
既然有可能出现这种情况,干脆,让共享对象访问自身的全局变量时,也通过got的方式,就避免了进程中存在多个global的可能,即在装载时,将global的虚拟内存地址存入共享变量中的got中。
前面的技术,都是为了实现共享对象的代码段地址无关,但是对共享对象的数据段,还是存在地址有关的部分,比如下面这样,若某个共享对象中有如下的代码:
static int a ;
static int* p = &a;
这种情况下,p的值是a的地址,但是a的地址是在装载时才能确定的。解决的办法就是用重定位技术,这是我们在静态链接中就熟悉的。这里之所以可以使用重定位,也是因为共享对象的数据段对每个进程都有一份,是可以修改的。
负责记录共享对象数据段中重定位段,叫做rela.dyn。
本文到现在,我们已经接触好几个新的段,got,got.plt,rela.dyn,这些段都是为了实现动态链接而建立的。下面还会接触几个段,建议用心记忆一下。
实际应用中,共享对象可能会访问大量的外部函数,也就是说,有一个庞大的got.plt表。
当加载该共享对象时,理论上,动态链接器就要将该共享对象涉及到的外部模块全部加载并链接,这可能会耗费大量时间,而且,很多外部函数,也许在整个进程生命周期内,都不会被实际调用一次,加载消耗的时间就浪费了。
为了优化这一点,引入延迟绑定(lazy binding)技术。具体办法是,调用外部函数的指令不直接从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函数,这个时机正是体现延迟绑定技术的时候。
jmp *(bar@got.plt) 这句话是说跳转到got.plt中bar函数的地址,我们知道,因为采用了延迟绑定, 此时这里的地址并不是bar的地址。那是什么地址呢?答案是链接器在初始化时,已经帮我们填好了,就是下一条push指令的地址。
于是,跳转到了下一条push语句,这个语句的n又是什么呢?
答案是,为了实现延迟绑定,还建了一个新的段,rel.plt。这个段也是一个重定位表,记录了got.plt中的bar的位置,告诉链接器,这个bar的位置要进行重定位。n就是bar函数在rel.plt中的位置。我们可以将其称作bar函数的id。
接下来,push moduleID,是把bar所在的模块的id入栈。
回顾上面两个push,我们看到,入栈了模块的id,以及要使用该模块的函数bar的id n。
然后调用 _dl_runtime_resolve,该函数就帮我们加载并链接要使用的外部模块,并在got.plt中更新bar函数的地址。该函数会使用到我们刚刚push的两个值,这是它领受的任务。
一旦这个过程完成,再次通过plt调用bar函数时,就会跳转到真正的bar函数了。
总结一下,为了实现延迟绑定,又引入了两个新的段,plt和rel.plt。
在动态链接的可执行文件中,interp段用来指定使用哪个动态链接器。
如下所示:
回想一下静态链接,elf文件有一个文件头,里面记录了一些静态链接所需要的信息,比如比如需要的符号表,重定位表等。
那么在共享对象中,需要动态链接的变量和函数也需要相应的信息,为了方便动态链接器的执行,在共享对象中,有一个专门的dynamic段,汇总了和动态链接有关的段的信息,方便动态链接器使用。
如下图所示:
可以在里面看到我们熟悉的pltgot和pltrel段的位置信息。
本文主要阐述了动态链接的基本原理和核心技术点。尽量避开无关紧要的细节,突出主要问题。