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条目。这些条目列出了所有依赖的共享库。

它会做以下几件事:

  1. 遍历依赖列表,找到每个共享库的文件路径
  2. 使用mmap把共享库映射到进程地址空间
  3. 递归处理共享库自身的依赖(因为libc.so可能还依赖其他库)
  4. 更新全局的_r_debug结构,记录加载信息

我在项目中遇到过一个问题:一个共享库依赖了另一个共享库,但那个库的路径没在LD_LIBRARY_PATH里。结果程序启动时直接报错“cannot open shared object file”。后来我养成了习惯,每次部署前都用ldd检查一遍所有依赖。

第三阶段:重定位与符号解析

这是最核心的阶段。动态链接器需要把每个共享库中的符号(函数、全局变量)地址,填入调用方的GOT表中。

重定位的类型有很多,最常见的是R_X86_64_GLOB_DATR_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这条指令的地址。所以流程是:

  1. 跳转到GOT中的地址(实际上是下一条指令)
  2. 压入符号索引
  3. 跳转到解析器
  4. 解析器找到printf的真实地址,写入GOT
  5. 跳转到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画一张图,把整个流程串起来。你一看就明白了。

延迟绑定(PLT)完整工作流程 main() 调用 printf printf@PLT GOT[printf] 是否已解析? 直接跳转 printf 否(第一次调用) _dl_runtime_resolve 查找符号表 更新 GOT[printf] 跳转 printf 执行 调用方 PLT表 GOT表 解析器 目标函数

实际调试技巧

如果你想知道程序到底用了哪些动态库函数,以及它们是否被延迟绑定,可以用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