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 里查一下实际地址,再跳转或读写。

我画个图帮你理解:

代码段(.text) call func@plt mov global_var@got, %eax ... GOT 间接访问 全局偏移表(.got) [0] → &global_var (0x7f12345678) [1] → &func (0x7f12345690) [2] → &printf (0x7f12345710) 运行时填入实际地址 动态链接器(ld-linux.so) 加载时解析符号,填充 GOT PLT 跳板 过程链接表(.plt) func@plt: jmp *GOT[1] 代码段 GOT PLT 动态链接器

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_DATR_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 自己,然后通过 pushjmp PLT[0] 进入动态链接器。链接器查完符号表,把真实地址写回 GOT[1]。第二次调用时,GOT[1] 已经指向真正的函数了,直接跳过去,不再经过链接器。

我曾经踩过一个坑:在实时性要求很高的音频处理程序里,用了动态链接库。结果第一次调用某个函数时,延迟突然飙升到几十毫秒——因为动态链接器在做符号解析。后来我改成在初始化阶段主动调用一遍所有关键函数,强制提前完成绑定,问题就解决了。

15.5 数据段内的重定位

除了代码段,数据段里也可能有需要重定位的地方。比如全局指针数组:

// libfoo.c
int a = 10, b = 20;
int *arr[] = {&a, &b};

这个 arr 数组里存的是 ab 的地址。但 ab 在动态库的数据段里,它们的地址也是运行时才能确定的。所以编译器会在 .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