这是《链接、装载与库》读书笔记的第 6 回,以下是与该主题相关的更多知识,你可以在 技术文档 板块找到系统的学习路径。
本文提纲如下:
- 为什么要动态链接
- 共享对象需要被单独装载
- 共享对象的装载地址怎么确定
- 程序每次装载时都要进行重新链接,程序性能有损失怎么办
1. 为什么要动态链接
一定有人被面试官问过这个问题,对吧?
静态链接能够相对独立地开发和测试自己的模块,但是静态链接有很多缺点,比如浪费内存空间、模块更新困难等,需要一种更好的方式来组织程序的模块,即动态链接。
1.1 静态链接的缺点
那么首先,来看一下静态链接的缺点是什么?静态链接就是将依赖的库文件直接嵌入到可执行文件中。这样就会有以下两个问题。
缺点1:浪费内存空间
比如现在有两个程序Program1 和 Program2,都使用到了Lib.o这个模块,那么这两个程序在静态链接输出的可执行文件中都存在Lib.o这个模块,当同时运行这两个程序时,Lib.o在内存和磁盘中都有两份副本,如果存在大量的类似于Lib.o这种被多个程序共享的目标文件时(比如C语言静态库),就会浪费很多的空间。

缺点2:模块更新困难
如果依赖库有更新,必须重新编译整个可执行文件才能更新,因为库已经在编译时固定到可执行文件中。 比如程序Program1中使用的Lib.o更新了,那么Program1就需要使用最新的Lib.o重新链接,即一旦程序中有任何模块需要更新,那么整个程序就要重新链接,这样的话,通过网络来更新程序就会非常不方便,因为程序任何位置的一个小改动,都会导致整个程序重新下载。
1.2 动态链接可以解决静态链接的问题吗
动态链接不会直接将依赖的库文件直接嵌入到可执行文件中,而是将其当作独立的个体,这样的话,库文件是在各个程序之间就可以共享。
所以动态链接的基本思想是,把程序依赖的各个模块相互独立起来,在程序运行时再将它们链接形成一个完整的程序。 而不是静态地拼接在一起。
动态链接将链接过程推迟到运行时才进行,记住这句话!
比如现在两个程序Program1和Program2已经被编译成目标文件Program1.o和Program2.o,当运行Program1时,首先加载Program1.o,如果Program1.o依赖于Lib.so,那么系统继续加载Lib.so,如果还依赖于其他目标文件,将它们全部加载至内存,所有的依赖都加载完成后,系统开始进行链接工作,这个链接和静态链接比较相似,包括符号解析、地址重定位等。
动态链接库以 .so 为扩展名
链接完成之后,程序Program1开始运行,如果需要运行Program2,那么只需要加载Program2.o,不需要重新加载Lib.so,系统要做的只是将Program2.o和Lib.so链接起来,因为内存中已经有了一份Lib.so

注意对比上面两张图:
静态链接中,库文件被集成到了可执行文件间中;
动态链接中,库文件自身是独立的,在程序运行的时候,再将它们链接形成一个完整的程序;
所以通过动态链接,解决了上面的两个问题:
- 对于共享的目标文件Lib.so,在磁盘和内存中只存在一份,不会有多个副本浪费空间
- 如果要升级某个共享的模块,只需将旧的目标文件覆盖掉,新版本的目标文件会在程序下一次运行时装载到内存并进行链接
看来动态链接确实可以解决静态链接的问题,当然这篇文章不会止步于此,而是会更深入地学习一下动态链接!比如:
Linux系统中,ELF动态链接文件称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,一般是以.so为扩展名,常用的C语言运行库为libc.so,保存在/lib目录下。注意哦,整个系统只有一份libc.so。
Windows系统中,动态链接文件叫动态链接库(Dynamical Linking Library),就是以 .dll 为扩展名的文件
动态链接由动态链接器完成,动态链接将链接过程推迟到了装载的时候(静态链接是在装载前),这样程序每次被装载时都要进行重新链接,这导致程序在性能上有一些损失,但是,可以使用延迟绑定等方法对动态链接过程进行优化
2. 共享对象需要被单独装载
还记得上一篇文章中,静态链接程序运行时地址空间分布吗?对于静态链接的可执行文件来说,整个进程只有可执行文件本身需要被装载。
但是对于动态链接来说,因为动态链接不会将依赖的库文件直接嵌入到可执行文件中,所以要装载的除了可执行文件本身,还有它所依赖的共享目标文件。
来看个例子,比如现在有几个源文件:
/* Program1.c */
#include"Lib.h"
int main()
{
foobar(1);
return 0;
}
/* Program2.c */
#include"Lib.h"
int main()
{
foobar(2);
return 0;
}
/* Lib.c */
#include"stdio.h"
void foobar(int i)
{
printf("Print test %d\n", i);
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
程序Program1.c和Program2.c分别调用了Lib.c中的foobar函数。
- 首先使用GCC将lib.c编译成一个共享对象文件lib.so
gcc -fPIC -shared -o Lib.so Lib.c
//-shared 表示产生共享对象,-fPIC后面解释
- 分别编译链接Program1.c和Program2.c
gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so
现在,我们得到了两个程序,Program1和Program2。
现在来运行一下Program1,看一下动态链接程序运行时地址空间分布,如下所示:


可以看到,除了Program1本身,Lib.so、libc-2.27.so(C语言运行库)也被映射到进程的虚拟地址空间,注意还有ld-2.27.so也被映射进来,它是linux下的动态链接器,动态链接器和普通共享对象一样被映射到进程虚拟地址空间,在运行Program1之前,先由动态链接器完成链接工作后,再开始执行程序。
3. 共享对象的装载地址怎么确定
既然,共享对象也需要被装载,那么是不是和静态链接的可执行文件那样,已经确定了装载地址呢?
使用readelf -l来看一下lib.so的装载属性:

可以发现这个动态链接文件的装载地址竟然是从0开始,很明显这个地址是无效的,并且从上面的运行时进程虚拟空间看到,lib.so的装载地址并不是0,可见,共享对象的最终装载地址在编译时是不确定的,而是在装载时,动态分配一块虚拟地址空间给共享对象。
在这个例子中,在program1和program2的运行时进程虚拟空间中,lib.so的虚拟地址是不同的:

问题来了,为什么要给共享对象动态分配一块虚拟地址空间呢,直接固定不行吗?
3.1 共享对象为什么不能有固定的装载地址
假设真的所有的共享对象都固定死了装载地址,或者说操作系统统一为所有的共享对象确定了装载地址,这是非常不合理的,原因如下:
- 应该保证所有共享对象的装载地址都不一样,否则就会出现冲突
- 任何一个库地址的变动,都可能影响其他库的正常运行
- 这些共享库升级后,应该保持共享库中全局变量地址不变,否则,已经链接了这些共享库的程序就需要重新链接
- ......
所以,干脆共享对象的装载地址就在装载时动态分配好了。
3.2 地址无关代码
现在我们知道,共享对象在编译时不能假设自己在进程虚拟地址空间中的位置,换句话说,同一个共享对象lib.so,虽然在内存中只存在一份,但是其在不同的进程虚拟地址空间中的虚拟地址可能是不一样的,它们都指向相同的物理内存中的lib.so。
那么,问题是,同一份lib.so,怎么才能保证,即使被装载到不同的虚拟地址,也能正常运行,这就需要用到地址无关代码。
在生成共享库时,使用-fpic选项,输出的对象是地址无关的
3.3.1 什么是地址无关代码
简单来说,我们希望共享对象中所有对地址的引用,都不会因为装载地址的改变而被影响。
对地址的引用指的是函数调用或者数据访问。
3.3.2 如何产生地址无关代码
现在来分四种情况讨论:
- 模块内部的函数调用、跳转等
- 模块内部的数据访问,比如模块中定义的全局变量、静态变量
- 模块外部的函数调用、跳转等
- 模块外部的数据访问,比如其他模块定义的全局变量
首先来看一下这四种情况是什么意思,比如下面是某个共享对象的代码,其中标注了上面的四种情况,如下:
/* test.c*/
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1; //模块内部数据访问
b = 2; //模块外部数据访问
// .......
}
void foo()
{
bar(); //模块内部函数调用
ext(); //模块外部函数调用
}
这里需要注意的是,实际上并不能确定变量b和函数ext是模块外部的还是模块内部的,因为它们可能被定义在同一个共享对象的其他目标文件中,这个时候,编译器都把它们当作模块外部的来处理
第一种 模块内部调用或跳转
这种类型最简单,现在模块内部的函数调用都是相对地址寻址,被调用函数与调用者处于同一个模块,它们之间的相对位置不变。
其实之前的文章中也有提到过相对地址寻址,现在用上面的例子再来看一下,上面的test.c使用命令 gcc -fPIC -shared -o test.so test.c 编译之后,然后使用objdump -d test.so查看反汇编的内容,如下:

调用bar函数的指令是通过指令 e8 da fe ff ff 来实现的,其中0xfffffeda是-294的补码,是被调用函数相对于下条指令的相对偏移,在这里,call指令的下一条指令是mov指令,它的地址是0x656,而bar函数的地址是0x530,0x656 - 0x530 = 偏移量(294)。
在这个位置,call指令实际会去调用bar@plt,文章后面会讲到plt,这里只是为了体现相对寻址,所以先忽略plt
只要代码写好了,这个偏移量是不会改变的,不论这个共享对象最后被加载到了什么位置,都可以正确地找到bar函数的地址。也就是说调用bar函数的call指令是和装载地址无关的。
第二种 模块内部数据访问
和第一种情况类似。
不同的是,第一种情况中的相对位置是在代码段内部,而这种情况是数据段和代码段之间的相对位置。
一旦编译好之后,代码段和数据段之间的相对位置是固定的,那么访问模块内部数据的指令与该数据之间的相对位置就是固定的,所以和第一种情况相同。
第三种 模块间函数调用
上面例子中ext函数就是这种情况,其实这种情况也使用了相对地址寻址的思想,不同的是,函数ext函数位于其他的共享对象中,所以这个相对位置是不确定的,需要使用一些技巧,这个技巧就是全局偏移表(Global Offset Table,GOT),全局偏移表中保存有模块外部函数的地址,当需要调用函数ext函数时,首先找到GOT,然后再通过GOT找到ext函数。
使用GOT是比较简单的一种方法。实际上ELF采用了更加复杂的方法,但这不影响我们理解地址无关代码。
看到这里,一定会有很多问题,比如:
- 全局偏移表GOT本身放在什么位置
- 怎么通过全局偏移表做到地址无关
- 全局偏移表中保存有模块外部函数的地址,这个地址是什么时候放进去的
第一个问题,全局偏移表被放在数据段里面,比如共享对象A调用了共享对象B中的一个函数ext,那么在共享对象A的数据段中会有一个全局偏移表,其中保存了函数ext的地址。需要注意的是,共享对象的代码段在各个程序之间是共享的,但是数据段不是,每个程序都会有一个数据段的副本。
代码段是不可变的,共享对象的代码写成啥就是啥,所以只需要加载一份代码段,然后多个程序都可以来执行相同的代码。
数据段是可变的,每个程序可能会修改这些数据,所以为了防止不同程序之间的数据干扰,每个程序必须拥有一份自己的数据段副本。
第二个问题,首先GOT被放在数据段里面,那么在编译的时候,可以确定当前调用外部函数的指令和GOT之间的相对偏移;
其次,GOT其实可以看作一个指针数组,每个指针会指向一个外部的函数或者变量,那么就可以通过GOT内部的偏移得到外部函数的地址。
画个图来表示一下:

第三个问题在后面写延迟绑定的部分有解答。
第四种 模块间数据访问
上面例子中的变量b就是这种情况。这种情况实际上比较少,因为会导致模块之间耦合较大,但是,如果有,那么和第三种情况相同,只不过,在第三种情况中,GOT保存的是函数的地址,而这里保存的是变量地址,不再赘述。
地址无关代码不仅可以用在共享对象上面,也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文件(PIE,Position-Independent Executable),编译时使用‘-fPIE'参数
3.3 装载时重定位
实际上现在操作系统使用位置无关代码(PIC, Position Independent Code)的方法来解决共享对象地址冲突的问题。但是我们仍然可以了解一下装载时重定位这个概念
前面在静态链接时提到的重定位叫链接时重定位(Link Time Relocation),那么什么是装载时重定位(Load Time Relocation)?
在链接时,对所有绝对地址的引用不作重定位,推迟到装载时再完成,一旦模块装载地址确定,即目标地址确定,那么对程序中所有的绝对地址引用进行重定位。 比如,函数foobar函数相对于lib.so代码段的起始位置是0x100,当lib.so被装载到 0x10000000时,就可以确定foobar的地址是0x10000100(假设代码段位于模块的最开始,即代码段的装载地址是0x10000000),遍历将所有foobar的地址引用都重定位至 0x10000100,这就是装载时重定位。
那么装载时重定位有什么缺点呢 最主要的是在多进程环境下,装载时重定位的方法无法让共享对象的指令部分在多个进程间共享。举个例子说明一下。 假设有一个共享库 libA.so,它的代码段(指令部分)是这样的:
0x1000: mov eax, [0x2000] ; 从内存地址 0x2000 读取数据
0x1005: call 0x3000 ; 调用地址 0x3000 的函数
当多个进程加载 libA.so 时,操作系统希望这些进程都共享这个指令部分(也就是 0x1000 到 0x100A 的指令),以节省内存。
然而,如果 libA.so 的地址 0x2000 或 0x3000 在某个进程中已经被其他库或数据占用,系统必须将 libA.so 加载到一个不同的内存地址,比如 0x5000。此时,操作系统需要对这些指令进行装载时重定位,修改内存地址引用:
0x5000: mov eax, [0x6000] ; 修改后的指令,读取新的内存地址 0x6000
0x5005: call 0x7000 ; 修改后的调用,指向新的函数地址 0x7000
现在,由于指令被修改过,这个进程使用的代码和其他进程看到的就不一样了,系统无法让多个进程继续共享同一段指令,必须为每个进程维护不同的指令副本。这就打破了共享库的共享性,也降低了内存利用率。
总结起来就是:
- 当多个进程加载相同的共享对象(共享库或
.so 文件)时,操作系统希望节省内存,因此会让这些进程共享该共享对象的指令部分(即代码段),而不是为每个进程都复制一份指令。这意味着,多个进程可以共享同样的指令代码,这样可以节省内存资源。
- 装载时重定位会调整共享对象中的指令,修改它们的内存地址引用。问题在于,如果某个进程需要修改共享对象中的指令部分(比如重定位地址),那么共享代码就不再是相同的了。
- 因为指令部分被修改后,每个进程看到的共享库代码就不再一致,这破坏了“共享”的初衷。因此,如果共享库的指令部分被装载时重定位修改了,系统就必须为每个进程分配一份独立的代码副本,无法达到共享的目的。
产生共享对象时,使用了两个参数 “-shared“ 和 ”-fPIC“ ,如果只使用“-shared",那么输出的共享对象就是采用装载时重定位的方法。
4. 程序每次被装载时都要进行重新链接,程序在性能有损失怎么办
动态链接将链接过程推迟到运行时才进行,还记得这句话吗,所以静态链接只要连接好了,就一劳永逸,但是动态链接的程序每次开始执行时都会先链接,在加上动态链接需要使用GOT这种方法进行间接寻址,这都会造成动态链接程序的性能损失。
别慌,只要有问题,就会有答案,可以使用延迟绑定(PLT)的方法来优化动态链接的性能。
4.1 什么是延迟绑定(PLT)
在程序开始执行之前,动态链接会首先找到所依赖的共享对象,然后进行符号查找以及重定位,但是有些有些函数可能在程序执行完时都不会用到,比如一些错误处理函数,所以在动态链接时,并不需要一次性将所有的函数都链接好。
延迟绑定(PLT)就是函数第一次被用到时才进行绑定,如果没有用到就不进行绑定。这种做法可以加快程序的启动速度。
所谓绑定就是符号查找、重定位。
4.2 PLT(Procedure Linkage Table)
假设liba.so调用了libc.so中的bar函数,那么当liba.so第一次调用bar时,需要调用动态链接器中的某个函数(_dl_runtime_resolve)来完成地址绑定工作,而这个函数至少需要知道是哪个模块(liba.so)需要绑定哪个函数(bar函数)。
所谓地址绑定就是将bar函数的地址写到GOT中
前面说过,当调用模块外部函数,会通过GOT进行间接跳转,为了实现延迟绑定,这个过程中间又增加了一层跳转。即调用模块外部函数时,先通过PLT,再通过GOT。
每个外部函数在PLT中都有一个相应的项,比如bar函数在PLT中对应有一个 bar@plt:
如果使用objdump查看一个共享对象的反汇编,可以看到有一个.plt段,在这个段中,每个外部函数都对应有一项
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_reslove
来逐行看一下: jmp *(bar@GOT) , bar@GOT 表示GOT中保存的bar函数的地址,所以这条指令可以跳转到bar函数,实现函数调用。 但是如果是第一次调用bar函数,链接器并没有将bar的地址填入到 bar@GOT 中,而是将第二条指令 push n 的地址填入 bar@GOT 中,所以第一条指令会跳转到第二条指令。
push n 这条指令,是将一个数字n压入堆栈中,这个数字是bar这个符号在重定位表.rel.plt中的下标
push moduleID ,是将模块的ID压入堆栈
jump \_dl\_runtime\_resolve ,调用动态链接器的 \_dl\_runtime\_resolve 函数来完成符号解析和重定位工作,此函数会将 bar 函数的地址填入到 bar@GOT 中,以后再次调用 bar 函数时,第一条指令就可以跳转到 bar 函数中,而不会再继续重复上面的过程:

实际情况下的plt实现要更复杂一些,但基本的思想都是一样的。
好了,到此为止,我们大概知道了,实现动态链接的两个最重要的点就是地址无关代码和延迟绑定。想了解更多关于编译、链接和内存管理的底层知识,欢迎到 云栈社区 的计算机基础板块继续探索。