11、加载器与运行时:程序加载过程,动态链接器/加载器(ld.so),延迟绑定

说实话,很多C语言开发者写了好几年代码,对编译链接的理解还停留在「点一下运行按钮」的阶段。但真正让我意识到加载器有多重要的,是一次线上事故。

那是一个深夜,我负责的一个服务突然在部署后大面积崩溃。查了半天,发现是动态库版本不兼容,ld.so 加载了错误的 .so 文件。嗯,从那以后,我花了不少时间啃这块内容。今天咱们就来聊聊,程序从磁盘上的 ELF 文件,到变成内存中活生生的进程,这中间到底发生了什么。

11.1 程序加载:从文件到进程

你想想看,一个可执行文件躺在硬盘上,它只是一堆字节。操作系统怎么把它变成能跑起来的进程?

这个过程,我们叫它「加载」。核心工作由内核中的加载器完成(在 Linux 下,就是 execve 系统调用的处理逻辑)。

我习惯把加载过程拆成三步:

  1. 读取 ELF 头部:内核先看一眼文件的魔数(0x7f ELF),确认这是个合法的 ELF 文件。然后解析 Program Header Table。
  2. 创建虚拟地址空间:内核为进程创建一个全新的页表,建立虚拟地址到物理地址的映射。这时候还没真正读磁盘,只是「预留位置」。
  3. 映射段(Segment):根据 Program Header 中的 PT_LOAD 段,把代码段、数据段映射到虚拟内存。注意,这里用的是「按需分页」——只有真正访问到某个页时,才会触发缺页中断,从磁盘加载。

关键点:加载器不会一次性把整个程序读进内存。它只是建立映射关系。真正的 I/O 发生在你访问代码或数据的那一刻。这就是为什么程序启动可以这么快。

举个例子,一个典型的 ELF 可执行文件,它的 Program Header 大概长这样:

Type  Offset   VirtAddr          PhysAddr          FileSiz  MemSiz   Flg  Align
PHDR  0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R   0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R   0x1
LOAD  0x000000 0x0000000000000000 0x0000000000000000 0x0005d8 0x0005d8 R   0x1000
LOAD  0x001000 0x0000000000001000 0x0000000000001000 0x0001d5 0x0001d5 R E 0x1000
LOAD  0x002000 0x0000000000002000 0x0000000000002000 0x000130 0x000130 RW  0x1000
DYNAMIC 0x002e18 0x0000000000003e18 0x0000000000003e18 0x0001a0 0x0001a0 RW  0x8

看到那个 INTERP 段了吗?它指向了 /lib64/ld-linux-x86-64.so.2。这就是我们接下来要聊的主角——动态链接器。

11.2 动态链接器/加载器:ld.so

内核加载完可执行文件本身后,发现有个 INTERP 段。它不会直接跳转到程序的入口,而是先把 ld.so 加载进来,把控制权交给它。

说白了,ld.so 才是真正干脏活累活的角色。它的任务包括:

  • 加载依赖的共享库:你的程序链接了 libc.solibpthread.so 等等,ld.so 需要找到它们,加载到内存。
  • 重定位:共享库在编译时不知道最终加载地址,ld.so 需要修改代码中的地址引用。
  • 符号解析:把 printf 这样的符号,绑定到 libc.so 中实际的函数地址。

我记得有一次,同事抱怨程序在某个环境上启动报错「cannot open shared object file」。我过去一看,是 LD_LIBRARY_PATH 没设对。ld.so 默认会去 /lib/usr/lib 以及 /etc/ld.so.conf 里配置的路径找。如果找不到,它就罢工了。

小技巧:可以用 ldd 命令查看一个可执行文件依赖了哪些共享库,以及 ld.so 会去哪里找它们。比如 ldd /bin/ls

ld.so 的搜索顺序是这样的:

  1. LD_LIBRARY_PATH 环境变量指定的路径
  2. /etc/ld.so.cache 中的缓存(由 ldconfig 生成)
  3. 默认路径 /lib/usr/lib

这里有个坑:LD_LIBRARY_PATH 虽然方便,但千万别在生产环境乱用。我曾经见过有人把它设成当前目录,结果被恶意替换了 libc.so,整个系统安全防线直接崩溃。

11.3 延迟绑定:能省则省的哲学

好,现在 ld.so 把共享库都加载进来了。但有个问题:一个程序可能调用了成百上千个外部函数,难道在启动时就把所有符号都解析完?

当然不是。那样启动速度会慢得离谱。Linux 用了一个很聪明的机制——延迟绑定(Lazy Binding)。

说白了,就是「用到的时候再解析」。第一次调用 printf 时,才去 libc.so 里找它的地址。之后再次调用,就直接跳转,不再查找。

这个机制依赖两个表:

  • GOT(全局偏移表):存放函数实际地址的地方。
  • PLT(过程链接表):一段桩代码,负责调用 ld.so 的解析函数。

我画了一张图,帮你理清它们的关系:

延迟绑定调用流程 调用方 (main) PLT[printf] 桩代码 GOT[printf] 初始指向 PLT+6 ld.so 符号解析 _dl_runtime_resolve printf (libc) ① call ② jmp *GOT ③ 第一次:跳回 PLT ④ 调用解析器 ⑤ 写入地址 ⑥ 跳转 第二次:直接跳转 图例: 第一次调用路径 后续调用路径 核心思想:第一次调用时解析符号,之后直接跳转,避免重复开销。

流程是这样的:

  1. 程序调用 printf,实际上跳到了 PLT[printf]
  2. PLT 里的代码先跳转到 GOT[printf]。第一次调用时,GOT 里存的不是 printf 的地址,而是 PLT 中下一条指令的地址。
  3. 于是控制流又回到 PLT,PLT 调用 _dl_runtime_resolve
  4. ld.so 的解析器找到 printflibc.so 中的地址,写入 GOT[printf]
  5. 然后跳转到 printf 执行。
  6. 第二次调用时,GOT 里已经是真实地址了,直接跳转,不再经过解析器。

注意:延迟绑定虽然好,但在某些场景下需要禁用。比如写安全相关的代码时,你希望所有符号在启动时就解析完,防止攻击者篡改 GOT 表。可以用 LD_BIND_NOW=1 环境变量强制立即绑定。

11.4 实战:看一眼 GOT 和 PLT

光说不练假把式。我们来写个简单的程序,用工具看看 GOT 和 PLT 长什么样。

// test.c
#include <stdio.h>

int main() {
    printf("Hello, world!\n");
    return 0;
}

编译:gcc -o test test.c

然后用 objdump -d test 看反汇编:

0000000000001149 <main>:
    1149:   f3 0f 1e fa           endbr64
    114d:   55                    push   %rbp
    114e:   48 89 e5              mov    %rsp,%rbp
    1151:   48 8d 05 ac 0e 00 00  lea    0xeac(%rip),%rax   # 2004 <_IO_stdin_used>
    1158:   48 89 c7              mov    %rax,%rdi
    115b:   e8 f0 fe ff ff        call   1050 <printf@plt>
    1160:   b8 00 00 00 00        mov    $0x0,%eax
    1165:   5d                    pop    %rbp
    1166:   c3                    ret

看到 call 1050 <printf@plt> 了吗?这就是跳到了 PLT 表项。再去看 0x1050 处的代码:

0000000000001050 <printf@plt>:
    1050:   f3 0f 1e fa           endbr64
    1054:   f2 ff 25 7d 2f 00 00  bnd jmp *0x2f7d(%rip)   # 3fd8 <printf@GLIBC_2.2.5>
    105b:   0f 1f 44 00 00        nopl   0x0(%rax,%rax,1)

这个 jmp *0x2f7d(%rip) 就是跳转到 GOT 表项 0x3fd8 里存的值。第一次调用时,那里存的是 0x105b(也就是下一条指令),所以会继续执行后面的解析逻辑。

我个人习惯用 readelf -r test 查看重定位表,能清楚看到哪些符号需要动态链接:

Relocation section '.rela.plt' at offset 0x4d8 contains 1 entry:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
0000003fd8  000200000007 R_X86_64_JUMP_SLOT 0000000000000000 printf + 0

看到 0x3fd8 了吗?就是 GOT 中 printf 的位置。类型 R_X86_64_JUMP_SLOT 表示这是一个延迟绑定的跳转槽。

11.5 一些经验之谈

最后,分享几个我踩过的坑:

  • 别在信号处理函数里调用动态链接的函数:因为信号可能发生在 ld.so 加锁的时候,造成死锁。我遇到过因此导致的诡异崩溃,排查了很久。
  • prelink 工具:它可以提前为共享库分配固定地址,减少 ld.so 的重定位工作,加快启动速度。但现代 Linux 上 ASLR 让它的效果打了折扣。
  • 静态链接 vs 动态链接:不是所有场景都用动态链接好。嵌入式环境、性能极致敏感的场景,静态链接更可控。我做过一个实时音频处理程序,为了减少延迟抖动,最后选择了全静态链接。

调试利器:设置 LD_DEBUG=all 环境变量,ld.so 会打印出详细的加载、符号解析过程。比如 LD_DEBUG=all ./test,你会看到类似这样的输出:

      file=libc.so.6 [0];  needed by test [0]
      file=libc.so.6 [0];  generating link map
        ...
      symbol=printf;  lookup in libc.so.6 [0]
      binding file test [0] to libc.so.6 [0]: normal symbol `printf'

这比任何文档都直观。

好了,关于加载器和运行时,咱们就聊到这儿。理解这些底层机制,不是为了炫技,而是为了在遇到问题时,能快速定位根因。下次你的程序启动报错,或者莫名其妙崩溃,不妨想想:是不是 ld.so 在搞鬼?


公众号:蓝海资料掘金营,微信deep3321