基于RISC-V的编译链接和装载09之动态链接1
基于RISC-V的编译链接和装载09之动态链接1
1 为什么要使用动态链接?
静态链接与动态链接:
编译过程如下所示,根据链接方式不同分为静态链接与动态链接。

上一章已经详细的分析了静态链接,静态链接在链接阶段需要我们传入-static参数,如果不传-static参数,默认是动态链接。
静态链接只涉及一个阶段:
- 链接时:静态链接只在链接阶段完成,把
.a里的目标文件提取出来,合并进最终可执行文件。程序运行时不再涉及链接动作。
动态链接涉及三个阶段:
- 编译时:要生成位置无关代码(
-fPIC)、产生外部符号引用和重定位项 - 链接时:生成 PLT/GOT、
.dynamic、.dynsym等,把符号绑定信息写进可执行文件 - 运行时:动态加载器(
ld.so)真正加载.so,做符号解析和重定位(延迟绑定还可能推迟到首次调用)
可以看到,静态链接比较简单,更容易理解,那为什么我们又引入相对复杂的动态链接呢?
这是因为静态链接有两个较难忍受的缺点:体积大,不容易维护。
静态链接:
静态链接有两个典型缺点:体积大,不容易维护。
-
使用静态链接,假设基础库
libc.a大小为1MB,如果有2000个程序依赖libc.a库,那么就要浪费2GB(非优化情况)空间,所以静态链接对空间浪费十分严重 -
使用静态链接,假设基础库
libc.a有Bug需要更新,那么2000 个程序都需要重新编译发布,这也是非常大的工作量,所以静态链接对于程序的更新、部署、发布很不友好
当然静态链接也有优点,其相比动态链接比较突出的优点是:运行阶段与静态库无关,一个ELF可执行文件放到不同的RISC-V linux环境中都可以正常执行(理论上)。
动态链接:
为了减少代码体积,引入动态链接,其核心是:将程序按照模块拆分成各个相对独立的部分(动态库),在程序运行时才将它们链接到一起形成一个完整的程序,而不是像静态链接一样在运行前就将所有程序模块链接成单独可执行文件。通过这种方式实现库的共享。
动态链接的优缺点:
-
优点是:文件体积小,更新维护容易,某个动态库有新的版本时,往往只用更新这一个动态库,不需要重新全部编译
-
缺点是:独立性较弱,需要操作系统支持,性能稍有损失(动态链接采用PLT延迟绑定等优化后,相比静态链接性能损失一般小于5%)
以前面提到的 01_helloworld举例:
# STATIC_FLAGS=-static,编译得到静态链接的 helloworld_static.elf
$ cd riscv-elf-link-load/test/01_helloworld
$ make
# 修改Makefile,将`STATIC_FLAGS`变量置空,重新make 得到动态链接的 helloworld.elf
$ make clean
$ make
查看可执行文件的Code size:
$ riscv64-unknown-linux-gnu-size helloworld.elf helloworld_static.elf
text data bss dec hex filename
810 552 8 1370 55a helloworld.elf
424233 21900 21640 467773 7233d helloworld_static.elf
最直观的结果是:
-
静态链接ELF:
helloworld_static.elf一共 456KB -
动态链接ELF:
helloworld.elf一共1KB多
可见静态链接比动态链接的ELF体积大很多。
注意:上面的例子并不十分合适,原因是因为动态链接生成的ELF并不包含libc.so库(动态链接在运行阶段会加载libc.so),而静态链接生成的ELF链接了libc.a,所以只比较一个可执行程序体积或许并不恰当,但考虑多个ELF场景,如下图所示,如果这些ELF都调用printf函数,则需要链接libc库:

对比可见,如果存在多个ELF都要调用相同的库,使用动态加载只需要保留一份动态库,动态链接的确可以大大降低内存的占用。
静态链接和动态链接的详细对比:
静态链接除了占用内存大之外,还有维护不便等缺点,以下是静态链接和动态链接的详细对比:
| 静态链接 | 动态链接 | |
|---|---|---|
| 行为 | 编译时,将库代码复制到可执行文件中 | 编译时仅记录依赖,运行时加载库 |
| 可执行文件大小 | 大 | 小(共享) |
| 独立性 | 强,不依赖外部环境 | 弱,链接具有不确定性,需要操作系统支持 |
| 更新维护 | 困难,需重新编译整个程序 | 容易,只需替换库文件 |
| 性能 | 启动快,调用开销小 | 启动稍慢(需要链接),调用有微小开销 |
| 常用场景 | 1. 需要独立分发,避免依赖问题的程序(如发给不懂电脑的用户) 2. 对性能有极致要求的核心代码 3. 嵌入式系统(可能没有完整的操作系统环境) |
1. 绝大多数桌面应用和服务器软件 2. 系统的核心组件和基础库 3. 需要频繁更新库的场景 |
2 动态链接的实现
上面提到,动态链接的本质是将链接过程推迟到程序运行时,这样不同的程序等到运行时再链接同一个动态库,这样实现了动态库的共享,解决了静态链接体积大及维护难两个缺点。
2.1 动态链接过程:链接时->加载时->运行时
在链接时,动态链接包含如下三步:
- 节合并:除了常规的代码和数据节,链接器会额外生成专用于动态链接的节,如
.got(全局偏移表)、.plt(过程链接表)、.dynsym(动态符号表)和.dynamic(动态段)。 - 符号解析:确定哪些符号是外部共享库提供的(标记为
UND),并建立动态符号表以供后续查找。 - 重定位准备:这是与静态链接最本质的区别。静态链接在这里会直接修正机器码;而动态链接器不会修改代码段(
.text)或数据段的最终地址,而是生成重定位表(如.rela.dyn和.rela.plt)以及动态段(.dynamic)。
真正的地址修正工作被推迟到程序加载和运行阶段:
- 加载时重定位:程序加载后,应用程序
main执行前,动态链接器会解析全局变量等符号,并填充.got表。 - 运行时延迟绑定:对于外部函数,只有在首次调用时,才通过
.got.plt和_dl_runtime_resolve机制,将实际函数地址填入对应的 GOT 槽位。
2.2 动态链接遇到的问题以及解决方案
动态库是怎么实现被被多个程序之间共享的呢?其核心是:动态库共享要解决全局变量以及函数的访问问题,以下分别进行讨论:
问题1:访问动态库中的全局变量
考虑如下情况,假设现在有三个运行的程序和一个动态库libc. so,动态库中定义了一个全局变量var,程序1设置变量var=10,程序2设置变量var=20,程序3设置var=30,如果变量的物理地址一样,那么这样就会造成变量冲突(比如:过段时间,程序1再次访问变量var,发现其值并不是设置的10,程序运行就会出错)。

怎么解决这个冲突呢?
我们需要严格区分共享库中的不被修改部分(代码段)和被修改部分(数据段),代码段可以被共享,数据段不能共享。
具体来说如下:
| libc.so 段 | 是否共享 |
|---|---|
.text (代码段,只读) |
所有进程共享 |
.rodata (只读数据) |
所有进程共享 |
.data (可写数据) |
每个进程有私有副本 |
.bss (未初始化数据) |
每个进程有私有副本 |
也即采用的解决方案是:
-
代码段共享
-
数据段各个程序自己维护,这其实就是COW(写时复制)机制。COW机制负责在背后为数据创建私有副本,下图是COW机制创建私有副本示意图。

COW创建了私有副本后,不同进程中的 var的虚拟地址可能相同或者不同(受ASLR随机化影响,相同只是巧合)。关键是:虚拟地址通过每个进程独立的页表,被映射到了不同的物理内存页上,也即私有副本的物理地址不同。
COW机制解决了访问共享库全局变量的冲突,但共享库的代码段怎么知道各个程序全局变量私有副本的地址呢?这就是GOT的工作,各个模块自己记录私有副本的地址。另外还需注意COW机制需要操作系统以及MMU支持。
简单来说:COW 让全局变量"物理上每个进程一份";GOT 让代码"找得到自己那一份"。前者解决冲突,后者解决寻址。
问题2:访问动态库中的函数
假设动态库libc.so中访问一个全局函数printf,这个地址可以是固定地址吗?
答案是: 不能,动态库中函数地址不能是固定的绝对地址。
有如下原因:
场景1:单个进程内的库冲突
假设我们有两个库他们有相同的固定地址,当进程尝试加载这两个库时,0x40000000位置该放谁?libA.so还是libB.so?这样便造成了冲突,因为同一个虚拟地址空间内,一个地址只能被一个库占用。
- libA.so 要求加载到固定地址 0x40000000
- libB.so 也要求加载到固定地址 0x40000000

场景2:地址空间布局随机化(ASLR)
现代操作系统默认启用ASLR,每次运行程序时,动态库的加载基地址都会随机变化。
动态库中的函数地址= 库的加载基地址 + 函数在库内的偏移量,也即动态库中函数地址并不能固定。
# 第一次运行
$ ldd /bin/ls | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a1b000000)
# 第二次运行(地址不同!)
$ ldd /bin/ls | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a3c400000)
在linux 多进程场合,各个进程加载动态库的位置并不能保证完全相同,所以动态库代码段需要做成地址无关的(下一章介绍地址无关的概念),这也需要引入GOT来解决个问题。

2.3 引入GOT表
计算机领域有个中间层法则:"计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决"。
对于上述两个问题,可以引入GOT表来解决。GOT 表是怎么解决上述两个问题呢?
GOT的工作示意如下伪代码所示:
// GOT表示例(在数据段,内容可被加载器修改)
got[0] = &shared // 指向全局变量shared
got[1] = &printf // 指向libc中的printf
对于上节问题1:
各个程序维护全局变量的不同副本,也即共享库中的普通全局变量最终指向的物理地址并不相同(GOT表记录的地址不同),修改互不影响
注意:有的时候我们希望各个程序访问共享库同一全局变量怎么办?这时候需要共享库定义的共享的全局变量(特殊申请,MAP_SHARED),那么各个程序引用这个全局变量则是同一个(物理地址相同)。
对于上节问题2:
不同进程GOT表中,同一个函数的物理地址相同,使用GOT表记录函数的虚拟地址(GOT表记录的虚拟地址可能相同或者不同,受ASLR影响),关键是其指向的物理地址是相同的。共享库的核心价值就在于代码段的物理内存共享。

总的来说:GOT 是"访问外部符号的跳板"。
2.4 引入GOT后的简单工作流程
动态库共享要解决全局变量以及函数的访问问题,解决方法是:
- 代码段共享
- 数据段由各个程序自己维护
上一节从宏观上讲述了怎么引入GOT表来解决动态库共享的两个问题,这里结合实际案例进行简单描述。
注意:动态链接机制比较复杂,有些知识可能要结合后面几篇文章来理解。
例子如下:
共享库libc.so定义:
-
全局变量
share -
全局函数
printf
程序1 与程序2 都需要读写全局变量share,同时都调用了libc.so 提供的printf函数。
问题:动态链接是怎么通过引入GOT表来实现动态库共享(代码段共享,数据段私有)的呢?
加载期:GOT 填地址
-
libc.so中.text段(含printf)只读,可被程序1、程序2 共享同一份物理页,.data段(含share)初始也是只读映射 -
各自重定位:程序1 的
ld.so处理自己的.rela.dyn节,把share的地址写进程序1 自己的.got表(非libc.so的got表):GOT_1[share] = &share。程序2 的
ld.so同样写GOT_2[share] = &share。GOT_1[share]和GOT_2[share]里的数值可能相同也可能不同(取决于 ASLR/加载基址),但都指向各自进程里的share,此时share还是共享一份物理页
写 share:COW 让数据私有
- 程序1 执行写
share:先从GOT_1[share]取出地址&share,再往这个虚拟地址写值。这个写操作触发libc.so数据页(含share所在页)的 COW,内核复制一份私有物理页给程序1,程序1 把值写进自己的副本。虚拟地址&share没变,GOT_1[share]里的值也没变(还是&share) - 程序2 首次写
share时,它看到的还是原来那份共享只读页,于是也触发一次 COW,得到程序2 自己的私有页,然后写入程序2自己的值
结果:程序1、程序2 的 &share 虚拟地址可能一样,但物理页不同,值互不影响,实现了全局变量私有。
调 printf:PLT/GOT 延迟绑定
-
程序1 首次调用
printf,经过延迟绑定(下一章详细讲)后,程序1 的ld.so把printf函数的运行时地址写回程序1的.got.plt[printf]。 -
程序2 同理,程序2 的
ld.so把printf函数的运行时地址写回程序2的.got.plt[printf], 各自解析、各自写回。两者解析出的
printf虚拟地址可能相同也可能不同(取决于 ASLR 下 libc 的加载基址),但无论虚拟地址是否相同,它们最终都映射到libc.so的.text的同一份物理页——这就是"代码段共享":函数体只有一份物理副本,多进程共享。
至此,动态库共享的两个访问问题(全局变量、函数)都得到了解决。
参考:

浙公网安备 33010602011771号