11、加载器与运行时:程序加载过程,动态链接器/加载器(ld.so),延迟绑定
说实话,很多C语言开发者写了好几年代码,对编译链接的理解还停留在「点一下运行按钮」的阶段。但真正让我意识到加载器有多重要的,是一次线上事故。
那是一个深夜,我负责的一个服务突然在部署后大面积崩溃。查了半天,发现是动态库版本不兼容,ld.so 加载了错误的 .so 文件。嗯,从那以后,我花了不少时间啃这块内容。今天咱们就来聊聊,程序从磁盘上的 ELF 文件,到变成内存中活生生的进程,这中间到底发生了什么。
11.1 程序加载:从文件到进程
你想想看,一个可执行文件躺在硬盘上,它只是一堆字节。操作系统怎么把它变成能跑起来的进程?
这个过程,我们叫它「加载」。核心工作由内核中的加载器完成(在 Linux 下,就是 execve 系统调用的处理逻辑)。
我习惯把加载过程拆成三步:
- 读取 ELF 头部:内核先看一眼文件的魔数(
0x7f ELF),确认这是个合法的 ELF 文件。然后解析 Program Header Table。 - 创建虚拟地址空间:内核为进程创建一个全新的页表,建立虚拟地址到物理地址的映射。这时候还没真正读磁盘,只是「预留位置」。
- 映射段(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.so、libpthread.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 的搜索顺序是这样的:
LD_LIBRARY_PATH环境变量指定的路径/etc/ld.so.cache中的缓存(由ldconfig生成)- 默认路径
/lib、/usr/lib
这里有个坑:LD_LIBRARY_PATH 虽然方便,但千万别在生产环境乱用。我曾经见过有人把它设成当前目录,结果被恶意替换了 libc.so,整个系统安全防线直接崩溃。
11.3 延迟绑定:能省则省的哲学
好,现在 ld.so 把共享库都加载进来了。但有个问题:一个程序可能调用了成百上千个外部函数,难道在启动时就把所有符号都解析完?
当然不是。那样启动速度会慢得离谱。Linux 用了一个很聪明的机制——延迟绑定(Lazy Binding)。
说白了,就是「用到的时候再解析」。第一次调用 printf 时,才去 libc.so 里找它的地址。之后再次调用,就直接跳转,不再查找。
这个机制依赖两个表:
- GOT(全局偏移表):存放函数实际地址的地方。
- PLT(过程链接表):一段桩代码,负责调用 ld.so 的解析函数。
我画了一张图,帮你理清它们的关系:
流程是这样的:
- 程序调用
printf,实际上跳到了PLT[printf]。 - PLT 里的代码先跳转到
GOT[printf]。第一次调用时,GOT 里存的不是printf的地址,而是 PLT 中下一条指令的地址。 - 于是控制流又回到 PLT,PLT 调用
_dl_runtime_resolve。 - ld.so 的解析器找到
printf在libc.so中的地址,写入GOT[printf]。 - 然后跳转到
printf执行。 - 第二次调用时,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