15、动态链接原理:位置无关代码(PIC),全局偏移表(GOT)
动态链接,说白了就是程序跑起来以后才去链接库。你想想看,如果每个程序都把 libc 静态链接进去,那磁盘和内存得多浪费?我早年做嵌入式开发时,就吃过这个亏——一个简单的 HTTP 服务器,静态链接完居然 5MB 多,Flash 根本塞不下。后来换成动态链接,体积直接缩到 300KB。
但动态链接有个核心问题:代码段没法固定地址。库被加载到哪个虚拟地址,得看运行时的心情(其实是看 mmap 的分配策略)。那代码里那些绝对地址跳转、全局变量访问怎么办?嗯,这就是我们今天要聊的——位置无关代码(PIC)和全局偏移表(GOT)。
15.1 为什么需要位置无关代码?
先看一个最简单的场景。你写了个共享库 libfoo.so:
// libfoo.c
int global_var = 42;
void func() {
global_var++;
}
编译成动态库:
gcc -shared -fPIC -o libfoo.so libfoo.c
注意这个 -fPIC 选项。如果不加,编译器会生成绝对地址的代码。但动态库加载地址不确定,绝对地址就废了。加了 -fPIC,编译器会生成位置无关的指令——所有对全局变量和函数的访问,都通过一个间接层来完成。
核心思想:代码段本身不包含任何绝对地址,所有需要重定位的符号都放在一个单独的数据结构中——这就是 GOT。
15.2 全局偏移表(GOT)长什么样?
GOT 本质上就是一个指针数组,放在数据段里。每个需要重定位的全局符号(变量或函数),都在 GOT 中占一个槽位。代码访问符号时,先去 GOT 里查一下实际地址,再跳转或读写。
我画个图帮你理解:
15.3 代码怎么通过 GOT 访问全局变量?
看个具体的例子。假设 libfoo.so 里要访问 global_var,编译器生成的汇编大概是这样的:
# 通过 GOT 间接访问全局变量
movq global_var@GOTPCREL(%rip), %rax # 获取 GOT 中 global_var 的地址
movl (%rax), %edx # 读取实际值
addl $1, %edx # 自增
movl %edx, (%rax) # 写回
这里的关键是 @GOTPCREL 这个修饰符。它告诉汇编器:不要直接放 global_var 的绝对地址,而是放 GOT 中对应槽位的地址。而且这个地址是相对于 RIP(指令指针)的,所以代码无论加载到哪里,都能正确计算。
我个人习惯:在调试动态库问题时,经常用 readelf -r libfoo.so 查看重定位表。你会看到类似 R_X86_64_GLOB_DAT 或 R_X86_64_RELATIVE 这样的条目——前者就是 GOT 重定位,后者是数据段内的相对重定位。
15.4 函数调用:PLT + GOT 的延迟绑定
函数调用比全局变量访问多了一层——过程链接表(PLT)。为什么要多这一层?说白了是为了延迟绑定。
你想想看,一个动态库可能导出几百个函数,但程序运行时可能只用到了其中几个。如果加载时就把所有函数地址都解析好,那太浪费了。所以 Linux 搞了个懒人方案:第一次调用时才解析。
看个例子,调用 func() 时的流程:
# 调用方代码
call func@PLT
# PLT 条目(第一次调用时)
func@PLT:
jmp *GOT[1] # GOT[1] 初始指向下一行
push $index # 压入函数索引
jmp PLT[0] # 跳转到 PLT 公共入口,调用动态链接器
# 动态链接器解析后,将 func 的真实地址写入 GOT[1]
# 第二次调用时:
func@PLT:
jmp *GOT[1] # 直接跳转到 func 的真实地址
第一次调用时,GOT[1] 里存的是 PLT 中下一行的地址(也就是 push $index 那条指令)。所以 jmp *GOT[1] 相当于跳回了 PLT 自己,然后通过 push 和 jmp PLT[0] 进入动态链接器。链接器查完符号表,把真实地址写回 GOT[1]。第二次调用时,GOT[1] 已经指向真正的函数了,直接跳过去,不再经过链接器。
我曾经踩过一个坑:在实时性要求很高的音频处理程序里,用了动态链接库。结果第一次调用某个函数时,延迟突然飙升到几十毫秒——因为动态链接器在做符号解析。后来我改成在初始化阶段主动调用一遍所有关键函数,强制提前完成绑定,问题就解决了。
15.5 数据段内的重定位
除了代码段,数据段里也可能有需要重定位的地方。比如全局指针数组:
// libfoo.c
int a = 10, b = 20;
int *arr[] = {&a, &b};
这个 arr 数组里存的是 a 和 b 的地址。但 a 和 b 在动态库的数据段里,它们的地址也是运行时才能确定的。所以编译器会在 .data.rel.ro 段(可读可重定位的数据段)里放这个数组,并生成 R_X86_64_RELATIVE 类型的重定位条目。
动态链接器加载时,会遍历这些条目,把里面的值加上基地址偏移。比如 arr[0] 原本存的是 0x100(相对于库基址的偏移),加载后基址是 0x7f1234560000,那实际值就变成 0x7f1234560100。
15.6 性能代价与优化
PIC 不是免费的。我整理了一下主要的性能开销:
| 开销类型 | 原因 | 典型影响 |
|---|---|---|
| GOT 间接访问 | 每次访问全局变量多一次内存读取 | 约 5-10% 的变量访问开销 |
| PLT 跳转 | 函数调用多一次间接跳转 | 约 3-5% 的函数调用开销 |
| 延迟绑定 | 第一次调用触发符号解析 | 首次调用延迟可达毫秒级 |
| 寄存器压力 | 需要保留 GOT 基址寄存器 | 编译器优化受限 |
不过在现代 x86_64 架构上,由于 RIP 相对寻址的引入,GOT 访问的开销已经小了很多。我实测过,大部分场景下 PIC 的性能损失在 3% 以内,完全可以接受。
避坑指南:如果你在编译可执行文件时用了 -fno-pie,那生成的是非位置无关的可执行文件。这种文件加载地址固定,性能稍好,但牺牲了地址空间随机化(ASLR)的保护。我个人建议:除非你有明确的性能瓶颈证据,否则保持默认的 PIE(位置无关可执行文件)就好。
15.7 总结一下关键点
- PIC 让共享库的代码段可以在任意地址运行,核心手段是间接寻址。
- GOT 是全局符号的地址表,放在数据段,运行时由动态链接器填充。
- PLT 配合 GOT 实现延迟绑定,只有第一次调用时才解析函数地址。
- 数据段内的指针也需要重定位,通过
R_X86_64_RELATIVE条目处理。 - PIC 有性能代价,但现代 CPU 和编译器已经优化得相当好。
嗯,动态链接的原理其实不复杂,就是多绕了一层。但这一层绕得值——它让我们的系统可以灵活地加载和共享代码,节省了大量内存和磁盘空间。下次你用 ldd 查看程序依赖时,可以想想背后 GOT 和 PLT 是怎么工作的。
公众号:蓝海资料掘金营,微信deep3321