从源码到进程:04.动态库的生成与使用,解密延迟绑定与重定位机制
动态库的生成与使用,解密延迟绑定与重定位机制
前言
在之前的文章中,我们讲解了实际静态库的生成与使用,说明了目标文件是怎么生成静态库的,并且说明了一个程序是怎么连接使用静态库的。
在实际的开发中,我们还会经常使用另一种文件 .so 文件,也就是动态库,本文就讲继续讲解动态库是生成与使用的,你将学会:
-
为什么动态库解决静态库的痛点。
-
如何生成、链接和运行时加载 .so。
-
GOT/PLT 和重定位表的“幕后英雄”作用。
通过了解动态库实现动态链接的方法,可以将这种想法应用到自己的需要的系统热更新功能
动态库的引出
有了静态库不够吗?我们为什么要使用动态库?
静态库确实能够做到程序复用,但是却存在几个问题
体积膨胀: 静态库在链接的时候,就将需要的符号链接进可执行文件中了,相当于包含了库的副版本。如果是多个团队协同开发程序,都使用了同一个库,比如sqlite,那么肯定是会造成程序的体积膨胀
更新困难: 静态库如果提供方更新了,每次使用者都需要重新生成可执行文件,重新连接库
编译成本高:对于很多稍微大型一点的项目,编译的成本巨大,重新编译的时间过长,相信自己编译过类似chromium等项目的同学深有体会。
因此,我们需要一种机制: 将链接过程延迟到程序运行时,让库能够被独立的加载,共享和替换
这样就能做到,当库更新的时候,我们不需要去重新编译整个可执行程序,只需要将动态库的文件进行更新就可以了
动态库实现的原理
动态库的设计思想
一个库文件,实际上对外提供的都是两种东西变量和函数,本质上都是我们之前提到的“符号(symbol)”。
对于一个库而言,实际上只要保证了在库变化的时候,只要库的符号是稳定的,或者说保证能继续提供之前保证提供的符号,那么库的更新实际上就可以做到无感的,让应用程序直接替换库。
动态库实现的核心思想就是:
不在链接的时候就将调用符号的地址写死,只是记录依赖,而在运行时真正生成这个符号的地址,也就是延迟绑定(lazy Binding)。
简单来说就是两点:
- 链接库的时候让可执行程序知道自己依赖什么库,依赖库中的什么符号
- 在运行的时候,找到对应的库,从库中找到自己的依赖的符号,并且生成这个符号的真正地址
这个“重新计算地址”的动作,依靠两张重要的重定位表完成:
| 段名 | 作用 |
|---|---|
.rela.dyn |
记录全局变量等静态数据的修正方式 |
.rela.plt |
记录外部函数调用的修正方式(延迟绑定) |
等到程序运行起来的时候,这两张表就为动态链接器提供了重新计算符号地址的关键信息,告诉它:
- 哪个地址需要修正;
- 修正哪个符号;
- 修正的计算方式(加上偏移、取相对地址等)。
而 .dynsym 则是符号表的动态版本,告诉动态链接器“有哪些符号需要动态解析”。
动态库的生成
动态库的生成静态库的生成(.a)非常类似,都是在于利用目标文件 .o 区别在于编译与链接参数。
gcc -fPIC -shared -o libmylib.so a.o b.o c.o
-fPIC生成 位置无关代码(Position Independent Code),让库能被加载到任意地址。-shared告诉链接器生成一个共享对象,而不是可执行文件。
位置无关性(PIC) 是动态库的关键:
因为动态库在不同进程中被加载到的地址往往不同,如果代码里直接写死绝对地址,会导致运行时错误。
因此,编译器会把所有外部符号的引用通过一层间接表(GOT)来访问。
链接时的约定
当主程序在链接阶段依赖一个动态库时(例如链接 -lmylib),链接器会:
- 在 ELF 文件中记录下程序依赖的库(放在
.dynamic段中); - 把那些属于动态库的符号标记为“需要运行时解析”的符号;
- 生成对应的 PLT / GOT 表结构;
- 生成
.rela.dyn和.rela.plt段,保存符号修正规则。
这样,生成的可执行文件就知道自己依赖哪些动态库、哪些符号需要运行时解析。
运行时的动态加载
程序启动时,操作系统在加载elf文件的时候,将扫描依赖的动态库,此时会进入内核态,系统的 动态链接器(ld-linux.so) 会介入整个加载过程。
1. 全局变量的重定位:.rela.dyn
动态链接器首先扫描 .rela.dyn:
- 读取每条重定位项;
- 找到对应的符号;
- 将其真实地址填入 GOT 表或数据段;
- 这样程序访问全局变量时,地址已经正确。
这部分修正是 在程序启动阶段就完成的。
2. 函数的延迟绑定:.rela.plt
对于函数调用,情况稍微复杂:
- 程序调用外部函数时,会先跳转到 PLT(Procedure Linkage Table)。
- PLT 再通过 GOT 查找函数地址。
- 如果是第一次调用,GOT 里存的是动态链接器的入口地址,程序会“陷入”动态链接器。
- 动态链接器查找
.rela.plt中的符号修正规则,解析出真实的函数地址,并把它写回 GOT。 - 之后再调用同一个函数时,PLT 直接通过 GOT 跳到真实地址,不再触发解析。
这就是 延迟绑定(Lazy Binding) 的原理。
用一句话总结整个动态链接过程:
编译阶段记录符号,链接阶段生成重定位表,运行时动态链接器根据
.rela.dyn和.rela.plt修正 GOT,程序通过 GOT 访问变量和函数的真实地址。
下面就是实际一个动态库加载与使用过程的流程图:

动态库加载与符号解析过程演示
下面我们通过一个完整实验,直观展示 动态库函数调用从“延迟绑定”到“真实地址解析”的全过程。
实验源码
文件结构:
.
├── add.cpp
├── add.h
├── main.cpp
add.h
#pragma once
int add(int a, int b);
add.cpp
#include "add.h"
int add(int a, int b)
{
return a + b;
}
main.cpp
#include <iostream>
#include "add.h"
int main()
{
std::cout << "3 + 4 = " << add(3,4) << std::endl;
std::cout << "3 + 4 = " << add(3,4) << std::endl;
return 0;
}
构建可执行文件与动态库
执行的时候注意,因为现代编译器为了安全默认关闭了延迟绑定,直接生成了函数的地址,但是间接寻址的方式没有变化,所以必须严格按照脚本生成可执行文件
# 生成动态库 libmath.so
g++ -fPIC -shared -o libmath.so add.cpp
# 生成可执行文件
g++ main.cpp -L. -lmath -no-pie -Wl,-z,lazy -g -o main
验证 ELF 依赖关系
readelf -d main | grep NEEDED
输出类似:
0x0000000000000001 (NEEDED) Shared library: [libmath.so]
说明:程序运行时依赖 libmath.so。
查看 PLT / GOT / 重定位表结构
readelf -r main
可以看到类似:
000000403ff8 000100000007 R_X86_64_JUMP_SLOT add@Base + 0
这条记录就说明:
“add 函数在运行时需要被修正,它的真实地址会被写入 GOT 表的对应槽位。”
用 GDB 验证延迟绑定
现在我们来观察 第一次调用 add() 时 GOT/PLT 的变化过程。
gdb ./main
在 GDB 中执行以下命令:
(gdb) start
Temporary breakpoint 1, main () at main.cpp:5
5 std::cout << "3 + 4 = " << add(3,4) << std::endl;
(gdb) info functions add
All functions matching regular expression "add":
.....
0x0000000000401070 add(int, int)@plt
0x00007ffff7fb70f9 add(int, int)
你可以看到:
add@plt是主程序中的“跳板”;add是动态库中真实函数的地址。
反汇编查看 PLT 跳转
(gdb) disassemble 0x0000000000401070
Dump of assembler code for function _Z3addii@plt:
0x0000000000401070 <+0>: endbr64
0x0000000000401074 <+4>: bnd jmp *0x2f9d(%rip) # 0x404018 <_Z3addii@got.plt>
0x000000000040107b <+11>: nopl 0x0(%rax,%rax,1)
End of assembler dump.
可以看到:
调用
add(int, int)@plt实际上是先jmp到.got.plt中对应的槽位。
打印 GOT 槽位的值(第一次调用前)
(gdb) x/gx 0x404018
0x404018 <_Z3addii@got.plt>: 0x0000000000401030
此时 GOT 中的地址指向动态链接器(ld-linux.so),意味着还没绑定真实地址。
运行第一次调用
(gdb) n
3 + 4 = 7
此时触发了 lazy binding,动态链接器解析出 add 的真实地址并写回 GOT。
再次查看 GOT 槽位
(gdb) x/gx 0x404018
0x404018 <_Z3addii@got.plt>: 0x00007ffff7fb70f9
可以看到地址已经变了,对应于动态库中 add 的实现地址!
再次调用时
第二次执行:
(gdb) n
3 + 4 = 7
这时不再进入链接器,直接通过 GOT 中保存的真实地址跳转,完成调用。
验证总结图
| 阶段 | GOT 内容 | 说明 |
|---|---|---|
| 程序启动 | 指向动态链接器 _dl_runtime_resolve |
还未绑定 |
| 第一次调用 add() | 调用 PLT → 链接器解析符号并修正 GOT | 完成重定位 |
| 第二次调用 add() | GOT 中是 add 的真实地址 | 直接跳转执行 |
总结性说明
通过这次实验你验证了动态链接中的关键事实:
| 机制 | 表现 |
|---|---|
| 延迟绑定 | 函数第一次调用才解析符号地址 |
| PLT 表 | 作为中间跳板,负责函数跳转 |
| GOT 表 | 保存最终解析的函数真实地址 |
| 动态链接器 | 运行时修改 GOT,实现符号解析 |
总结
动态库的出现,解决了静态库的三个根本问题:
- 可独立更新:接口不变即可替换库文件。
- 共享与复用:多个进程共用同一个
.so文件,节省内存。 - 启动与执行高效:延迟绑定减少启动开销。
而在机制层面上,它的核心就是以下三张表的配合:
| 表 / 段名 | 作用 |
|---|---|
| GOT(Global Offset Table) | 存储符号的运行时真实地址 |
| PLT(Procedure Linkage Table) | 函数调用跳板,实现延迟绑定 |
.rela.dyn / .rela.plt |
动态链接器的“施工图”,记录如何修正符号地址 |
整个动态链接过程,就是在利用动态链接器去重新生成符号地址的过程:
程序只负责声明“我需要这些符号”,而动态链接器根据
.rela.*的“图纸”,把符号的真实地址在内存中重新接好,完成加载与执行。
就是在 "库的实现发生了变化"中找到了“符号不应该变化”,通过引入一层间接访问机制(PLT / GOT),让程序可以在运行时灵活地解析、更新与共享符号,实现了真正意义上的“可替换模块化”。
这正应了计算机科学中那句著名的论断:
“All problems in computer science can be solved by another level of indirection.”
——David Wheeler

浙公网安备 33010602011771号