动态链接是那种没人想关心的事情——直到二进制启动不了、库升级搞崩了线上服务、或者一个看似无害的函数调用变成了性能热点。这几个场景我都调试过足够多次,ELF 结构和加载器的行为已经刻在我脑子里了。这篇文章从最底层开始讲这些机制——不是作为参考手册,而是作为一个用 gdb 追踪 PLT stub 追踪到不想再追的人。

你会遇到这些问题

几个场景,了解动态链接能救你:

  • ldd 显示能找到库,但二进制崩溃报 undefined symbol。库更新了,SONAME 变了,但旧符号链接没更新。
  • 共享库函数首次调用神秘地增加了 50ms 延迟,因为它在延迟敏感的路径上触发了延迟绑定。
  • 你用 LD_PRELOAD 植入了一个调试库,但符号没有被拦截。应用通过 PLT 调用,而预加载库没有导出预期的符号名。
  • GOT 覆盖利用在你的测试环境能工作,但生产内核上失败——因为你在 Partial RELRO 下测试,但生产是 Full RELRO。

这些不是理论问题。我全踩过。

为什么动态链接有真实的性能代价

静态链接把所有代码合并到一个二进制里。每个函数调用是直接的 call 指令。每个全局变量访问是直接内存引用。指令编码最小化:call offset 占 5 字节,mov addr, %reg 占 5–7 字节。

动态链接增加间接性。对于每个从共享库导入的符号:

  • 全局变量:编译为 从 GOT 加载 → 解引用 而不是 从绝对地址加载。每次访问多一次内存读取——缓存未命中时是 50–100ns。
  • 函数调用:编译为 call PLT_stub → jmp *(GOT+n)。解析后,多一次间接跳转(CPU 的分支预测器不一定能处理好)。首次调用时,是动态链接器里的数百条指令。

常说的 1–5% 开销是真实的但具有误导性。在 CPU 密集型的代码里,如果在热循环中调用库函数,开销可以达到 10–15%——因为 GOT 寄存器压力(你少了一个通用寄存器来存 GOT 基址)和 PLT 相关的 icache 污染。在 IO 密集或启动时间主导的代码里,你注意不到。最坏的情况是延迟敏感路径在运行时触发了延迟绑定——首次调用可能花数十微秒让动态链接器解析符号。

如果你在构建高性能共享库且每条指令都很重要,考虑那些函数是否真的需要导出,或者能否用 -fvisibility=hidden 只导出 API 表面。很多项目默认导出所有符号(-fvisibility=default),链接器必须为它们生成 PLT 条目和 GOT 槽位,即使只是在内部使用。

PIC:位置无关代码

为什么 PIC 存在

ASLR 随机化共享库的加载地址。库在编译时不能知道自己的基址。没有 PIC 的话,每个绝对地址都需要重定位条目,加载器必须在加载时修补每条指令。PIC 通过让所有内存引用相对于当前指令指针或间接通过表来避免这个问题。

代价是什么

让我用真实的指令序列展示实际代价。考虑一个简单的变量访问:

extern int top;
void increment(void) { top++; }

无 PIC(编译为固定地址的可执行文件):

mov    0x804a010, %eax    ; 5 字节,绝对值编码在指令中
add    $0x1, %eax
mov    %eax, 0x804a010     ; 5 字节

有 PIC(编译为位置无关):

mov    -0xc(%ebx), %eax   ; 3 字节,从基址寄存器的 GOT 偏移
mov    (%eax), %eax       ; 从 GOT 加载真实地址
lea    0x1(%eax), %edx    ; 递增
mov    -0xc(%ebx), %eax   ; 重新加载 GOT 条目(寄存器压力!)
mov    %edx, (%eax)       ; 通过 GOT 存储

五条指令替代三条。两次内存读取替代一次。而且 %ebx 在整个函数期间被钉在 GOT 基址上,你少了一个通用寄存器。在寄存器压力大的代码中,这强制溢出,增加更多内存流量。

x86-64 上,rip 相对寻址有所改善:

mov    top(%rip), %rax    ; 单条指令,RIP 相对
add    $0x1, %rax
mov    %rax, top(%rip)    ; RIP 相对存储

不需要 GOT 基址寄存器了,但间接性仍然存在——链接器为 top(%rip) 生成重定位条目,加载器在加载时修补偏移。

__i686.get_pc_thunk 技巧

32 位 x86 上没有直接读 eip 的指令。编译器用一个小技巧:

call    __i686.get_pc_thunk.bx   ; 压入返回地址 = 当前 EIP
add     $0x1b6c, %ebx            ; 现在 ebx = GOT 基址

thunk 函数很简单:

__i686.get_pc_thunk.bx:
    mov    (%esp), %ebx           ; 复制返回地址(就是 eip)到 ebx
    ret

每个访问全局数据或调用导入函数的函数在入口处都要付这个开销。x86-64 上不需要 call; pop 序列,因为 [rip + offset] 寻址是原生的。

GOT 和 PLT:运行时解析层

GOT(全局偏移表)

GOT 是一个指针数组,每个导入符号和每个需要重定位的内部数据引用各一个。它有自己的 section(.got.got.plt)。理解 GOT 的关键:

  • 它是可写的(Partial RELRO 下)。动态链接器把解析后的地址写进去。
  • 它是攻击面。 如果攻击者能写 GOT,他们就能重定向任何库调用。这就是 FULL RELRO 存在的原因。
  • 它在二进制里,不在库里。 每个加载共享库的进程在自己的地址空间里有一份 GOT 副本。

对于变量,GOT 条目在加载时由动态链接器填入(main() 运行之前)。这些是 R_*_GLOB_DAT 重定位。加载器从库的数据段读取符号的实际地址,写入 GOT。

对于函数,GOT 条目可能被延迟解析——或者不,取决于 BIND_NOW 设置。

PLT(过程链接表)

PLT 是一组 trampoline stub,每个导入函数一个。x86-64 上一个典型条目:

push@plt:
    jmp    *GOT[push](%rip)       ; 通过 GOT
    push   $reloc_index           ; 标识要解析的函数
    jmp    plt0                   ; 调用解析器

PLT[0](也叫解析器 stub)是所有函数共享的。它压入 GOT[1] 的 link map 指针,跳到 GOT[2] 指向的解析器函数。

我第一次用 gdb 追踪这个时,顿悟时刻是看到 GOT 条目指回 PLT 自身——一个自引用指针,使延迟绑定成为一次原子交换就能激活。

延迟绑定,一步一步

下面是 main() 第一次从 libstack.so 调用 push() 时的精确序列:

第 1 步:调用 PLT

main:
    call   80483d8 <push@plt>     ; 调用 PLT stub

第 2 步:PLT stub 读 GOT

80483d8  jmp    *0x804a008        ; push 的 GOT 条目

GOT[push] 当前是 0x80483de——PLT 中下一条指令的地址。jmp 直接穿过。

第 3 步:压入重定位索引并调用解析器

80483de  push   $0x10             ; 重定位索引(.rel.plt 的偏移)
80483e3  jmp    80483a8           ; PLT[0] → 动态链接器

第 4 步:动态链接器解析 动态链接器(ld-linux.so.2ld-linux-x86-64.so.2)收到重定位索引,在 .rel.plt 表里查找,找到符号名 push,搜索已加载的共享对象,找到 libstack.so,计算运行时地址,写入 GOT[push]:

解析前:GOT[push] = 0x80483de(垃圾,指回 PLT) 解析后:GOT[push] = 0xb803f47c(真实地址)

然后链接器跳到解析后的地址。

第 5 步:第二次调用跳过全部 下一次调 push@plt 执行 jmp *0x804a008,现在读到 0xb803f47c,直接跳到 libstack.sopush()。不再调用解析器。

用 gdb 追踪

$ gdb ./main
(gdb) start
(gdb) s                                  ; 步入 push() 调用
0x080483d8 in push@plt ()

(gdb) x/gx 0x804a008                     ; 检查 GOT 条目
0x804a008: 0x080483de                    ; ← 还没解析,指回

(gdb) si                                 ; jmp *GOT — 穿过
0x080483de in push@plt ()

(gdb) si                                 ; push 重定位索引
0x080483e3 in push@plt ()

(gdb) si                                 ; jmp 到解析器
0x080483a8 in ?? ()

(gdb) si                                 ; 进入 ld-linux
0xb806a080 in ?? () from /lib/ld-linux.so.2

(gdb) finish                             ; 让解析器干活
(gdb) x/gx 0x804a008                     ; 再检查 GOT 条目
0x804a008: 0xb803f47c                    ; ← 现在解析了!
(gdb) x/5i 0xb803f47c
0xb803f47c: push   %ebp                  ; 真实函数,真实代码

一个关键观察:jmp *GOT[push] 是通过内存的间接跳转。CPU 必须等待内存加载完成才能取下一条指令。缓存未命中时这有代价。在紧凑循环中,这很重要。

动态链接器的重定位表

readelf -r 查看动态链接器要解析什么:

$ readelf -r libstack.so

Relocation section '.rel.dyn' at offset 0x2bc contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
000016bc  00000606 R_386_GLOB_DAT   00000000   g_share
000016c0  00000806 R_386_GLOB_DAT   00000000   __cxa_finalize

Relocation section '.rel.plt' at offset 0x2ec contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
000016d8  00000407 R_386_JUMP_SLOT  00000000   g_func
000016dc  00000807 R_386_JUMP_SLOT  00000000   __cxa_finalize

动态链接的四种重要重定位类型:

类型 何时解析 内容
R_*_GLOB_DAT .got 加载时 全局变量的地址
R_*_JUMP_SLOT .got.plt 延迟(或 BIND_NOW 时加载时) 函数地址
R_*_RELATIVE .got 加载时 base_address + addend(库内部数据)
R_*_TPOFF .got.tls 加载时 线程局部存储偏移

readelf -r 输出中的 offset 列就是实际的 GOT 槽地址。动态链接器在那里写入解析后的地址。

一个奇怪的细节:实际的 RELRO

现代 Linux 上大多数二进制默认是 Partial RELRO。以下是对你评估利用可能性的意义:

  • 无 RELRO——整个 GOT .got.got.plt 在同一个可写页面。单次写原语就能覆盖任何函数的 GOT 条目。现代系统上少见,但在嵌入式或遗留代码中常见。

  • Partial RELRO——编译器重排 section,使 .got(用于 R_*_GLOB_DAT——变量地址)放在 .data 之前,初始重定位后标记为只读。但 .got.plt(用于 R_*_JUMP_SLOT——函数地址)保持可写,因为延迟绑定需要在运行时写它。这是默认,因为它保留延迟绑定和启动时间优势。

  • Full RELRO-Wl,-z,relro,-z,now)——动态链接器在 main() 开始前解析所有 R_*_JUMP_SLOT 条目。然后调 mprotect() 使整个 GOT 只读。延迟绑定被禁用。启动更慢(所有符号预先解析),但 GOT 覆盖攻击面被消除。

检查一个二进制是哪种:

$ readelf -l ./a.out | grep -A1 GNU_RELRO
  GNU_RELRO      0x000e78 0x00000000000e78 ... R   0x1
$ readelf -S ./a.out | grep -E 'got|plt'
  [22] .got               PROGBITS  0000000e78 ...
  [23] .got.plt           PROGBITS  0000000f08 ...

如果 .got.plt 落在 GNU_RELRO 段范围内,就是 Full RELRO。如果在后面,就是 Partial。

实践情况:大多数发行版二进制(Ubuntu 22.04+、Fedora 35+)用 Full RELRO。但如果你只是本地 gcc -o test test.c 编译,得到的是 Partial RELRO。这种不匹配导致大量利用演示在"实验室条件"下能工作,在生产系统上失败。

性能:实用建议

一些我从 profiling 中学到的东西:

  1. 延迟绑定延迟峰值是真实的。 在运行时调 dlopen 加载插件的服务器里,每个插件函数的首次调用触发解析。如果发生在请求路径上,你会看到 1–10ms 范围的延迟离群值(取决于符号表大小)。修复:在初始化时预调用函数来预热 GOT,或用 LD_BIND_NOW

  2. 热路径上的 PLT 偷 icache。 每个 PLT 条目在 x86-64 上占 16 字节。数百个导入函数时,PLT 可能消耗多个缓存行。每个条目开头的间接跳转也会干扰分支预测——CPU 的间接分支预测器只有有限条目(通常 4K–16K BTB)。如果你的热路径调用很多库函数,你会在 PLT 处看到分支预测错误。

  3. 紧凑循环中的 GOT 间接访问有代价。 从共享库读全局变量的循环,每次迭代都多一次通过 GOT 的加载。修复:在循环入口加载到局部变量:

// 慢:每次迭代从 GOT 重新加载
for (int i = 0; i < n; i++)
    buf[i] = global_config.max_size;

// 快:捕获一次
size_t max = global_config.max_size;
for (int i = 0; i < n; i++)
    buf[i] = max;
  1. 用 perf 诊断 PLT 开销:如果在 perf report 中看到 PLT 条目,库边界在花你的钱。考虑:
    • -fvisibility=hidden + 显式 __attribute__((visibility("default"))) 在 API 上
    • 把函数移到头文件作为 static inline
    • -flto(链接时优化)让链接器跨编译单元内联

运行时禁用延迟绑定做诊断:

LD_BIND_NOW=1 ./a.out     # 启动时解析所有

比较 LD_BIND_NOW=1 和默认的启动时间与稳态延迟。如果稳态改善,说明延迟绑定在干扰热路径(可能是解析器的 icache 污染)。如果变差,说明你在为不常用的函数付出解析代价。

FAQ

Q1:为什么 PLT 第一条指令用 jmp 而不是 call
A:jmp *GOT[n] 是尾部调用。如果去了真实函数(解析后),目标函数里的 ret 直接返回原始调用者——不经过 PLT。如果穿过(解析前),解析器最终跳到目标,保持相同行为。

Q2:能手动调动态链接器解析符号吗?
A:可以,通过 dlsym(RTLD_NEXT, "funcname")dlvsym。这就是 LD_PRELOAD 包装器拦截函数后又调用原始函数的方式。

Q3:LD_PRELOAD 如何与 PLT 解析交互?
A:LD_PRELOAD 库被先加载,它们的符号优先。动态链接器解析 R_*_JUMP_SLOT func 时,先找到预加载库的 func,把它的地址写入 GOT。

Q4:PLT[0] 里的 __gmon_start__ 是什么?
A:gprof 用的 profiling 钩子。通常未解析。如果在 PLT 里看到它,没事——只是一个未使用的条目。

参考链接