10、动态链接:共享库(.so/.dll)的原理,位置无关代码(PIC),全局偏移表(GOT)与过程链接表(PLT)
动态链接,说白了就是让多个程序共用同一份库代码。你想想看,如果每个程序都把 libc 静态链接进去,那磁盘和内存得多浪费?我早年做嵌入式开发时,Flash 空间只有 2MB,静态链接一个 libc 就吃掉 1.5MB,剩下的代码根本塞不进去。后来换成动态链接,整个系统才活了过来。
动态链接的核心挑战就一个:地址不确定。静态链接时,链接器知道所有代码的最终地址,直接填死就行。但动态库加载到哪个地址,得运行时由加载器决定。这就引出了我们今天要聊的三个关键机制:PIC、GOT 和 PLT。
一句话总结:PIC 让代码不依赖固定地址,GOT 负责记录全局数据的位置,PLT 负责延迟绑定函数地址。三者配合,动态链接才能高效运转。
10.1 位置无关代码(PIC)
PIC 的全称是 Position Independent Code。它的目标很简单:代码不管加载到哪个地址,都能正确执行。
怎么做到的呢?核心思路是相对寻址。所有跳转、数据访问都基于当前 PC(程序计数器)的偏移量,而不是绝对地址。举个例子:
// 非 PIC 版本:直接访问绝对地址
mov eax, [0x4000A0] // 假设全局变量在 0x4000A0
// PIC 版本:通过 PC 相对寻址
lea rdi, [rip + offset] // rip 是当前指令地址,offset 是到变量的距离
mov eax, [rdi]
我在项目中遇到过一个问题:一个动态库被 mmap 到 0x7f000000,但另一个进程加载时却到了 0x7f100000。如果是非 PIC 代码,里面所有绝对地址都得打补丁,性能开销极大。PIC 代码则完全不用管,直接跑就行。
小技巧:编译时加 -fPIC 选项就能生成位置无关代码。不加的话,链接器会报错或者生成低效的代码。我习惯在编译所有动态库时都加上这个选项,省得后面出幺蛾子。
10.2 全局偏移表(GOT)
PIC 解决了代码本身的地址问题,但全局变量和静态数据怎么办?这些数据在编译时不知道地址,运行时才能确定。GOT 就是用来干这个的。
GOT 是一张表,放在数据段里。每个全局变量在 GOT 中占一个条目,条目里存的是变量的实际地址。代码访问全局变量时,先通过 PC 相对寻址找到 GOT 表项,再间接访问。
看个例子:
// 假设有一个全局变量 global_var
// 编译器生成的 PIC 代码大概是这样:
// 1. 获取 GOT 基址
lea rbx, [rip + _GLOBAL_OFFSET_TABLE_]
// 2. 通过 GOT 表项访问变量
mov rax, [rbx + offset_to_global_var] // rax 里就是 global_var 的地址
mov eax, [rax] // 读取 global_var 的值
GOT 的好处很明显:加载器只需要在加载动态库时,把 GOT 表项填成正确的地址就行。代码本身不用改,一次编译到处运行。
| GOT 条目 | 内容 | 说明 |
|---|---|---|
| GOT[0] | .dynamic 段地址 | 用于动态链接器自身 |
| GOT[1] | link_map 指针 | 链接器内部数据结构 |
| GOT[2] | _dl_runtime_resolve | 延迟绑定的入口函数 |
| GOT[3] | 全局变量 A 的地址 | 由加载器在运行时填入 |
| GOT[4] | 全局变量 B 的地址 | 同上 |
注意:GOT 是可写的。这意味着如果攻击者能篡改 GOT 表项,就能劫持函数调用。这就是 GOT 覆写攻击的原理。我曾经在安全审计时见过一个案例,攻击者通过堆溢出改写了 GOT 条目,把 printf 指向了恶意代码。所以现在很多系统都支持 RELRO(只读重定位),把 GOT 标记为只读。
10.3 过程链接表(PLT)
函数调用比全局变量更复杂。如果一个动态库里有 1000 个函数,但程序只用了其中 10 个,难道要把 1000 个函数的地址都解析出来吗?太浪费了。PLT 就是为了解决这个问题——延迟绑定。
PLT 的工作原理是这样的:
- 程序调用一个外部函数时,实际上跳转到 PLT 中的对应条目。
- PLT 条目一开始指向一个解析函数(_dl_runtime_resolve)。
- 第一次调用时,解析函数找到目标函数的真实地址,填入 GOT 表。
- 后续调用直接跳转到 GOT 中的真实地址,不再解析。
看代码更清楚:
; 假设调用 printf 函数
; PLT 条目(在代码段中,只读)
printf@plt:
jmp *printf@got(%rip) ; 跳转到 GOT 中 printf 的条目
push $0 ; 压入 printf 在符号表中的索引
jmp _dl_runtime_resolve ; 跳转到解析函数
; 第一次调用时,printf@got 指向的是 push 指令
; 解析完成后,printf@got 指向 printf 的真实地址
我个人觉得 PLT 的设计非常优雅。它把性能开销摊到了第一次调用上,后续调用几乎零成本。我在优化一个网络库时,发现启动时有一堆函数第一次调用很慢,后来用 LD_BIND_NOW=1 强制立即绑定,把解析开销集中到启动阶段,反而让运行时性能更稳定了。
经验之谈:如果你对启动时间敏感(比如容器场景),可以用 LD_BIND_NOW 环境变量强制立即绑定。如果对运行时性能敏感,延迟绑定更合适。没有银弹,得根据场景选。
10.4 整体流程:从加载到运行
把 PIC、GOT、PLT 串起来,动态链接的完整流程是这样的:
- 编译时:编译器生成 PIC 代码,所有外部符号访问都通过 GOT/PLT 间接进行。
- 加载时:加载器把动态库映射到某个虚拟地址,然后遍历 GOT,把全局变量的地址填进去。
- 第一次调用函数:PLT 触发延迟绑定,_dl_runtime_resolve 找到函数地址,写入 GOT。
- 后续调用:直接通过 GOT 跳转,不再解析。
下面这张图展示了核心的数据流和控制流:
10.5 实战中的坑与建议
动态链接虽然方便,但用不好也会踩坑。我总结几个常见的:
- 符号冲突:两个动态库导出同名函数,谁覆盖谁?这取决于加载顺序。我曾经被这个坑过,一个库的 malloc 被另一个库的 malloc 覆盖,导致内存泄漏查了两天。解决方案是用
dlsym(RTLD_NEXT, "malloc")来获取下一个符号。 - 性能开销:虽然 PLT 延迟绑定很高效,但第一次调用还是有开销。如果某个函数在热路径上第一次被调用,可能会引起微秒级的卡顿。我建议在初始化阶段主动调用一次,把绑定提前。
- 安全性:前面提到的 GOT 覆写攻击,现在可以用 Full RELRO 来防御。编译时加
-Wl,-z,relro,-z,now,GOT 就会被标记为只读。
曾经踩过的坑:有一次我写了一个动态库,里面用了一个全局构造函数。结果这个构造函数依赖另一个动态库的符号,而那个库还没加载。程序启动直接段错误。后来我用 __attribute__((constructor(101))) 调整了构造函数的优先级,才解决问题。记住:动态库的初始化顺序不是你说了算的。
好了,动态链接的核心原理就这些。PIC 让代码灵活,GOT 管数据,PLT 管函数,三者配合,才有了我们今天用的共享库。下次你写 gcc -shared -fPIC 的时候,应该能想到背后这些机制在默默工作。
公众号:蓝海资料掘金营,微信deep3321