16. 动态链接器:ld-linux.so的工作流程,延迟绑定(PLT)
说实话,动态链接器这东西,我早年刚接触Linux开发时,总觉得它像个黑盒子。程序跑起来了,它默默干活;程序崩了,它又神秘兮兮。直到有一次线上服务莫名其妙地在启动时挂掉,我被迫深入研究了ld-linux.so的源码,才算真正搞明白这家伙到底在做什么。
今天我们就来聊聊动态链接器的工作流程,以及它最核心的优化手段——延迟绑定(PLT)。
动态链接器是谁?
你写的C程序,如果链接了动态库(比如libc.so),那么最终生成的可执行文件里,并不会把printf、malloc这些函数的机器码直接塞进来。它只记录了一个信息:“我需要printf,它在libc.so里”。
那么问题来了——谁来负责在程序启动时,把这些外部函数的地址找到,并让程序能正确调用它们?
答案就是动态链接器,在Linux上就是/lib/ld-linux.so.2(32位)或/lib64/ld-linux-x86-64.so.2(64位)。
核心要点:动态链接器本身也是一个共享库,但它很特殊——它不依赖任何其他库。内核在加载完可执行文件后,会把控制权交给动态链接器,由它完成所有动态库的加载和符号解析。
动态链接器的工作流程
我个人习惯把动态链接器的工作分成四个阶段。你想想看,它其实就像一个“后勤总管”,在程序真正跑起来之前,把所有需要的东西都准备好。
第一阶段:自举(Bootstrap)
动态链接器自己也是个共享库,但它不能依赖任何其他库。所以它做的第一件事,就是把自己初始化好。这包括:
- 找到自己的GOT(全局偏移表)
- 设置好栈指针和寄存器
- 准备好调用自身的函数
嗯,这里要注意:自举阶段不能调用任何外部函数,连malloc都不行。因为此时动态链接器还没准备好。
第二阶段:加载共享库
动态链接器会读取可执行文件的.dynamic段,找到DT_NEEDED条目。这些条目列出了所有依赖的共享库。
它会做以下几件事:
- 遍历依赖列表,找到每个共享库的文件路径
- 使用
mmap把共享库映射到进程地址空间 - 递归处理共享库自身的依赖(因为libc.so可能还依赖其他库)
- 更新全局的
_r_debug结构,记录加载信息
我在项目中遇到过一个问题:一个共享库依赖了另一个共享库,但那个库的路径没在LD_LIBRARY_PATH里。结果程序启动时直接报错“cannot open shared object file”。后来我养成了习惯,每次部署前都用ldd检查一遍所有依赖。
第三阶段:重定位与符号解析
这是最核心的阶段。动态链接器需要把每个共享库中的符号(函数、全局变量)地址,填入调用方的GOT表中。
重定位的类型有很多,最常见的是R_X86_64_GLOB_DAT和R_X86_64_RELATIVE。前者用于解析外部符号,后者用于处理PIC(位置无关代码)中的地址修正。
| 重定位类型 | 作用 | 示例 |
|---|---|---|
| R_X86_64_GLOB_DAT | 解析全局符号地址 | printf、malloc |
| R_X86_64_RELATIVE | 基于加载地址修正 | 全局变量地址 |
| R_X86_64_JUMP_SLOT | 延迟绑定用 | PLT相关 |
第四阶段:初始化与启动
所有符号解析完成后,动态链接器会:
- 调用每个共享库的
.init段(构造函数) - 调用
.preinit_array中的函数 - 最后跳转到可执行文件的入口点(
_start)
至此,程序才真正开始执行你的main函数。
延迟绑定(PLT)——为什么需要它?
你可能会问:为什么不在启动时把所有符号都解析完?
原因很简单——浪费。一个大型程序可能链接了几百个动态库,但实际运行时只用到了其中一小部分函数。如果全部提前解析,启动时间会变得非常慢。
我曾经优化过一个嵌入式设备的启动时间,发现动态链接占了将近40%的开销。启用延迟绑定后,启动时间直接砍半。
延迟绑定的核心思想就是:用到哪个函数,就解析哪个函数。第一次调用时解析,后续调用直接使用已解析的地址。
PLT和GOT的工作原理
PLT(过程链接表)和GOT(全局偏移表)是延迟绑定的两大支柱。说白了,它们就是一张“跳转表”和一张“地址表”。
我们来看一个具体的例子。假设你的代码调用了printf:
// 源代码
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
编译后,对printf的调用会变成这样:
; 汇编层面
call <printf@plt>
这个printf@plt就是PLT表中的一项。它的结构大致如下:
; PLT条目(第一次调用时)
printf@plt:
jmp *GOT[printf] ; 跳转到GOT中存储的地址
push offset ; 压入printf在重定位表中的索引
jmp resolver ; 跳转到动态链接器的解析函数
第一次调用时,GOT[printf]里存的是push offset这条指令的地址。所以流程是:
- 跳转到GOT中的地址(实际上是下一条指令)
- 压入符号索引
- 跳转到解析器
- 解析器找到printf的真实地址,写入GOT
- 跳转到printf执行
第二次调用时,GOT[printf]已经被更新为printf的真实地址。所以直接跳转过去,不再经过解析器。
小技巧:你可以用objdump -d查看PLT表,用readelf -r查看重定位条目。我调试动态链接问题时,这两个命令几乎不离手。
延迟绑定的触发机制
延迟绑定并不是默认开启的。它由动态链接器的一个标志控制:LD_BIND_NOW。
- 如果
LD_BIND_NOW被设置(非空),则所有符号在启动时立即解析 - 如果未设置,则使用延迟绑定
另外,可执行文件本身的DF_BIND_NOW标志也会影响这个行为。你可以在编译时用-Wl,-z,now强制立即绑定,或者用-Wl,-z,lazy启用延迟绑定。
注意:延迟绑定虽然能加快启动速度,但在某些场景下会带来问题。比如信号处理函数中调用了动态库函数,如果此时还没解析,可能会触发重入问题。我曾经在写一个信号安全的日志库时,就因为这个踩过坑。
PLT的优化:GOT PLT
现代Linux系统其实用了更精细的机制——GOT PLT。简单来说,GOT被分成了两部分:
- .got:存放全局变量和已解析函数的地址
- .got.plt:专门用于PLT的延迟绑定
.got.plt的前三项有特殊用途:
| 偏移 | 内容 | 说明 |
|---|---|---|
| GOT[0] | .dynamic段地址 | 用于动态链接器访问 |
| GOT[1] | link_map指针 | 指向共享库的链接映射 |
| GOT[2] | 解析器入口 | 即_dl_runtime_resolve |
这种设计让解析器能快速获取上下文信息,不用再去别的地方查找。
一个完整的延迟绑定流程图
下面我用SVG画一张图,把整个流程串起来。你一看就明白了。
实际调试技巧
如果你想知道程序到底用了哪些动态库函数,以及它们是否被延迟绑定,可以用LD_DEBUG环境变量:
$ LD_DEBUG=bindings ./myprogram 2>&1 | grep printf
11234: binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `printf' [GLIBC_2.2.5]
这个输出告诉你:printf是在运行时才被绑定的。如果启用了LD_BIND_NOW,你会在程序启动时看到所有绑定信息。
个人经验:我调试动态链接问题时,最常用的组合是LD_DEBUG=all加上strace。前者看链接器内部行为,后者看系统调用。两者配合,几乎没有解决不了的问题。
延迟绑定的代价
任何优化都有代价。延迟绑定也不例外:
- 第一次调用有额外延迟:解析过程需要查找符号表,涉及字符串比较和哈希计算
- 代码段不可共享:因为GOT需要被写入,所以PLT相关的页面必须是可写的
- 安全风险:GOT可写意味着如果程序有漏洞,攻击者可能改写GOT表项,劫持函数调用
我曾经在安全审计中遇到过GOT覆写攻击。攻击者利用缓冲区溢出,把free@GOT改成了恶意函数的地址。从那以后,我在写安全敏感代码时,都会考虑使用RELRO(只读重定位)来保护GOT。
总结
动态链接器ld-linux.so的工作,说白了就是“按需加载,用到才解析”。PLT和GOT这对搭档,用一张跳转表和一张地址表,实现了高效的延迟绑定机制。
理解了这个机制,你就能明白:为什么程序启动那么快?为什么第一次调用某个函数会慢一点?为什么GOT覆写攻击能生效?
嗯,这些问题的答案,都在我们今天讲的内容里了。
公众号:蓝海资料掘金营,微信deep3321