本页目录

链接与加载

编译器输出 .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:

GOT + PLT 惰性绑定:首次调用慢,之后直接跳(lazy binding)

首次调用 printf(缓慢路径:走动态链接器解析) call printf@plt (PLT 桩) →查 GOTprintf jmp _dl_runtime_resolve 动态链接器查符号表→找到地址 更新 GOT[printf] =真实地址→jmp printf

第 N 次调用(命中路径:GOT 已有真实地址) call printf@plt 同一 PLT 桩 GOT[printf] 已有真实地址 → jmp printf,无额外开销

GOT 相当于一层缓存:首次调用必须经过动态链接器完整解析并写回 GOT; 之后每次调用直接读 GOT 命中真实地址跳转——这就是惰性绑定(lazy binding)的省时之处。

这就是 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→ 链接成 .sodlopendlsym 调具体函数。V8 和 Julia 都用这条路——把 JIT 编译的机器码包装为 .so 并动态加载,比直接写可执行内存安全(OS 保证 .text 段的 NX/写保护,不需要手动 mprotect)。

LTO(Link-Time Optimization):链接时全程序优化

传统的每一个 .c(编译单元)单独编译成 .o,优化只在单元内做——跨单元的函数内联、死代码消除、常量传播都看不见其他单元。LTO 把编译单元间的优化推迟到链接时:

LTO:链接时把 IR 攒到一起做全程序优化,而非编译时各单元独立优化

普通编译:各编译单元独立优化,链接器只管拼接 .c opt 单元内优化 .o 机器码 链接器 拼接

LTO:.c 产出 IR 而非机器码,链接时合并做全程序优化 .c IR(非机器码) 存入 .o(bitcode) 链接器收集 所有 .o 的 IR 合并为一个模块 opt→codegen→链接

LTO 把优化推迟到链接时:链接器能看到全部 IR,做普通编译看不到的跨模块内联/DCE; 代价是链接时间变长(大型项目可能翻倍),换来 5–15% 的性能提升。
  • 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