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 的工作原理是这样的:

  1. 程序调用一个外部函数时,实际上跳转到 PLT 中的对应条目。
  2. PLT 条目一开始指向一个解析函数(_dl_runtime_resolve)。
  3. 第一次调用时,解析函数找到目标函数的真实地址,填入 GOT 表。
  4. 后续调用直接跳转到 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 串起来,动态链接的完整流程是这样的:

  1. 编译时:编译器生成 PIC 代码,所有外部符号访问都通过 GOT/PLT 间接进行。
  2. 加载时:加载器把动态库映射到某个虚拟地址,然后遍历 GOT,把全局变量的地址填进去。
  3. 第一次调用函数:PLT 触发延迟绑定,_dl_runtime_resolve 找到函数地址,写入 GOT。
  4. 后续调用:直接通过 GOT 跳转,不再解析。

下面这张图展示了核心的数据流和控制流:

动态链接核心机制:PIC · GOT · PLT 程序代码 PIC 编译 相对寻址 PLT(过程链接表) 延迟绑定 第一次调用触发解析 GOT(全局偏移表) 存放全局变量/函数地址 运行时由加载器填充 动态链接器 _dl_runtime_resolve 符号解析与重定位 函数调用 jmp *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