本页目录
链接与加载
编译器输出 .o 后,到能运行还差链接和加载——重定位把符号引用变成地址,PLT/GOT 惰性绑定让动态库只在第一次调用时解析,LTO 把跨模块优化推迟到链接时做全程序分析。
概述
编译器输出 .o(目标文件)后,到"能运行的可执行文件"还差两件事:链接(把多个 .o 和库的符号引用解析成地址)和加载(把可执行文件映射到进程地址空间,初始化后跳转到入口)。链接器不只是"拼接"——它在这一步做重定位(relocation)、做 GC sections 去未引用代码、做 LTO (跨编译单元的全局优化)。这篇沿着"编译器输出 → 静态链接 → 动态链接 → 动态加载"的链路,把链接和加载的每一层机制讲清楚。
编译器的输出:目标文件(ELF)
一个 .o 文件(ELF 格式)包含:
- .text: 机器码 (代码段)
- .data: 已初始化的全局变量
- .bss: 未初始化 (或零初始化) 的全局变量 —— 不占文件空间, OS 在加载时映射零页
- .rodata: 只读数据 (字符串字面量, 浮点常量)
- .symtab: 符号表 (本文件导出/导入的符号)
- .rela.text: 文本节的重定位表 (哪些指令引用了外部符号, 需要链接器修补)
- .rela.data: 数据节的重定位表
- .debug_info, .debug_line, ...: DWARF 调试信息
编译器的最后一步(汇编器)输出 .o。链接器读取所有 .o 和库的符号表,做重定位,产出可执行文件。
重定位:从"符号引用"到"地址"
.o 文件的机器码里,引用外部符号的地方填的是 0 或占位符。.rela.text 记录这些坑位:
典型的重定位项 (R_X86_64_PC32):
偏移: 0x42 ← .text 中需要修补的位置
符号: printf ← 引用哪个符号
类型: R_X86_64_PC32 ← 相对跳转 (PC + offset)
加数: -4 ← 补偿 (指令中 PC 指向下一条, 差 4 字节)
链接器在分配好所有节的加载地址后,遍历所有重定位项,计算目标符号的最终地址,按类型公式改写目标位置的字节:
R_X86_64_PC32 修补公式: *(target) = sym_addr - (target_addr + 4) + addend
R_X86_64_64 修补公式: *(target) = sym_addr + addend (绝对地址)
静态链接:.a (archive)
静态库是一组 .o 的打包(ar 格式),链接时按需取出:
gcc main.o -L. -lmylib → 链接器做:
1. 收集 main.o 中 unresolved 的符号
2. 在 libmylib.a 中查找每个 unresolved 符号
3. 找到 → 从 .a 中提取对应的 .o, 加到链接集合
4. 提取的 .o 可能又引入新的 unresolved → 递归 (或分遍 scan)
5. 所有符号 resolved → 做重定位 → 输出可执行文件
链接器的符号解析是顺序敏感的——传统 Unix 链接器从左到右处理 .o 和 .a,如果 libfoo.a 放在引用它的 .o 之前,可能不被扫描(因为没有 unresolved 符号需要它里面的符号)。现代链接器(LLD)有 --start-group / --end-group 做迭代扫描,消除顺序问题。
链接器 GC(dead section elimination)
编译器以函数为单位生成 .text 节片段(section fragments,gold 和 LLD 有 --gc-sections)。链接器从入口点(_start)出发,追踪 call/jump 引用,把未引用的函数节标记为 dead 并丢弃。这减少最终二进制大小——类似 DCE 但跨 .o 单元。
动态链接:.so (shared object)
动态库不是在链接时拷贝到可执行文件里,而是在链接时记录依赖,在运行时加载并动态解析。
GOT 和 PLT:惰性绑定的两层
目标代码与 ABI 提了 PIC 需要 GOT。动态链接的完整图是 GOT + PLT:
这就是 lazy binding:动态库的符号只在第一次被调用时才解析,GOT 充当缓存。LD_BIND_NOW=1 环境变量强制在加载时立即解析所有符号(不惰性)——启动慢,但运行时无 PLT 开销。
符号插入(symbol interposition)
动态链接支持符号插入:如果多个 .so 都定义了同名的 malloc,最先加载的定义生效。可执行文件定义的符号覆盖 .so 的(默认行为),LD_PRELOAD 显式插入。这是 jemalloc/tcmalloc 替换系统 malloc 的机制——但也是性能坑:插入阻止编译器内联 malloc,每次调用都走 PLT。
dlopen:运行时加载
程序运行时可以动态加载任意库:
void *handle = dlopen("libplugin.so", RTLD_NOW);
void (*fn)(int) = dlsym(handle, "plugin_init");
fn(42);
dlclose(handle);
JIT 编译器常用 dlopen 加载编译好的 .so——编译的代码写出 .o→ 链接成 .so→ dlopen→ dlsym 调具体函数。V8 和 Julia 都用这条路——把 JIT 编译的机器码包装为 .so 并动态加载,比直接写可执行内存安全(OS 保证 .text 段的 NX/写保护,不需要手动 mprotect)。
LTO(Link-Time Optimization):链接时全程序优化
传统的每一个 .c(编译单元)单独编译成 .o,优化只在单元内做——跨单元的函数内联、死代码消除、常量传播都看不见其他单元。LTO 把编译单元间的优化推迟到链接时:
- ThinLTO:全程序优化的可扩展方案——不合并所有 IR 到单一模块(内存太大),而是按"调用关系"做跨模块摘要,在每个模块内各自优化,只对热点跨模块调用做内联。ThinLTO 是 gold/LLD 的默认 LTO 模式。
LTO 对编译时间有显著影响——整个程序的优化在链接时重新跑,大型项目链接时间可能翻倍。收益也非常大:跨模块内联消除了调用边界,常带 5–15% 的性能提升。
参考
- Levine: "Linkers and Loaders" — 链接与加载的经典教材
- Ian Lance Taylor: "Linkers" 系列 (https://lwn.net/Articles/276782/) — gold 链接器的作者写链接器原理
- System V AMD64 ABI: 动态链接部分 (GOT/PLT 的正式规范)
Keywords: static linking, dynamic linking, archive, .a, shared object, .so, ELF, .text, .data, .bss, .symtab, relocation, R_X86_64_PC32, GOT, PLT, lazy binding, _dl_runtime_resolve, symbol interposition, LD_PRELOAD, dlopen, dlsym, linker script, gc-sections, LTO, ThinLTO, link-time optimization, gold, LLD