代码生成和链接是编译器的最后阶段——代码生成将机器指令写入目标文件,链接器将多个目标文件和库合并为可执行文件。理解这个过程,你就能理解 undefined reference、multiple definition 等链接错误的根本原因。
一、从机器指令到可执行文件
1.1 编译的最后阶段
1.2 代码生成的任务
| 任务 | 说明 | 输出 |
|---|---|---|
| 代码发射 | 将 MachineInstr 编码为二进制 | 机器码字节 |
| 栈帧布局 | 安排局部变量和临时变量的栈偏移 | 栈帧大小 |
| 函数序言/结语 | 生成函数入口和出口代码 | push/pop, 栈调整 |
| 数据段输出 | 输出全局变量、常量池 | .data, .rodata |
| 调试信息 | 生成 DWARF 调试信息 | .debug_* |
二、ELF 文件格式
2.1 ELF 文件结构
2.2 ELF Header
// ELF Header 结构(64 位)typedef struct { unsigned char e_ident[16]; // 魔数 + 类 + 字节序 + 版本 uint16_t e_type; // 文件类型:REL/EXEC/DYN/CORE uint16_t e_machine; // 架构:EM_X86_64=62, EM_AARCH64=183 uint32_t e_version; // 版本 uint64_t e_entry; // 入口点虚拟地址 uint64_t e_phoff; // Program Header Table 偏移 uint64_t e_shoff; // Section Header Table 偏移 uint32_t e_flags; // 架构特定标志 uint16_t e_ehsize; // ELF Header 大小 uint16_t e_phentsize; // Program Header 条目大小 uint16_t e_phnum; // Program Header 条目数 uint16_t e_shentsize; // Section Header 条目大小 uint16_t e_shnum; // Section Header 条目数 uint16_t e_shstrndx; // 段名字符串表索引} Elf64_Ehdr;2.3 ELF 文件类型
| 类型 | 值 | 说明 | 扩展名 |
|---|---|---|---|
| ET_REL | 1 | 可重定位文件 | .o |
| ET_EXEC | 2 | 可执行文件 | (无) |
| ET_DYN | 3 | 共享对象文件 | .so |
| ET_CORE | 4 | Core 文件 | core |
2.4 用 readelf 探索 ELF
# 创建测试文件cat > hello.c << 'EOF'#include <stdio.h>int global_var = 42;int main() { printf("%d\n", global_var); return 0; }EOF
gcc -c hello.c -o hello.o # 编译为目标文件gcc hello.o -o hello # 链接为可执行文件
# 查看 ELF Headerreadelf -h hello.oreadelf -h hello
# 查看段表readelf -S hello.o
# 查看符号表readelf -s hello.o
# 查看重定位表readelf -r hello.o
# 查看段内容readelf -x .text hello.oreadelf -x .data hello.o三、符号表与重定位
3.1 符号表
符号表记录了目标文件中定义和引用的全局符号:
// ELF 符号表条目typedef struct { uint32_t st_name; // 符号名(字符串表索引) uint8_t st_info; // 类型 + 绑定 uint8_t st_other; // 可见性 uint16_t st_shndx; // 所在段索引 uint64_t st_value; // 符号值(地址或偏移) uint64_t st_size; // 符号大小} Elf64_Sym;
// 符号类型// STT_NOTYPE = 0 未指定// STT_OBJECT = 1 数据对象(变量)// STT_FUNC = 2 函数// STT_SECTION = 3 段
// 符号绑定// STB_LOCAL = 0 局部符号(文件内可见)// STB_GLOBAL = 1 全局符号(跨文件可见)// STB_WEAK = 2 弱符号(可被覆盖)3.2 符号解析
链接器的核心任务是符号解析——将每个符号引用绑定到唯一的定义:
3.3 重定位
重定位是链接器修正代码中地址引用的过程——编译时不知道最终地址,链接时填入:
// 重定位条目typedef struct { uint64_t r_offset; // 需要修正的位置(段内偏移) uint32_t r_info; // 符号索引 + 重定位类型 int64_t r_addend; // 附加常量} Elf64_Rela;
// 常见重定位类型(x86-64)// R_X86_64_64 = 1 绝对地址 64 位// R_X86_64_PC32 = 2 PC 相对 32 位// R_X86_64_PLT32 = 4 PLT 相对(函数调用)// R_X86_64_GOTPCREL = 9 GOT 相对(全局变量)3.4 重定位过程
class Relocator: """简化的重定位处理器"""
def relocate(self, section, relocations, symbol_table, base_addr): """处理重定位""" data = bytearray(section.data)
for rel in relocations: # 查找符号定义 sym = symbol_table[rel.symbol_index] sym_addr = base_addr + sym.value
# 根据重定位类型修正 offset = rel.offset if rel.type == 'R_X86_64_64': # 绝对地址 struct.pack_into('<Q', data, offset, sym_addr + rel.addend) elif rel.type == 'R_X86_64_PC32': # PC 相对地址 pc = base_addr + section.offset + offset value = sym_addr + rel.addend - pc struct.pack_into('<i', data, offset, value) elif rel.type == 'R_X86_64_PLT32': # PLT 相对(动态链接) plt_addr = self._get_plt_entry(sym) pc = base_addr + section.offset + offset value = plt_addr + rel.addend - pc struct.pack_into('<i', data, offset, value)
return bytes(data)四、静态链接
4.1 静态链接的过程
4.2 段合并规则
| 段 | 合并规则 | 对齐 |
|---|---|---|
| .text | 按顺序拼接所有 .text | 16 字节 |
| .data | 按顺序拼接所有 .data | 8 字节 |
| .bss | 合并大小(不占文件空间) | 8 字节 |
| .rodata | 按顺序拼接 | 8 字节 |
| .symtab | 合并所有符号 | 8 字节 |
4.3 静态链接 vs 动态链接
| 特性 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 可执行文件大小 | 大(包含库代码) | 小(只有引用) |
| 启动速度 | 快 | 稍慢(需加载共享库) |
| 内存使用 | 每个进程一份 | 多个进程共享 |
| 更新 | 需重新编译 | 替换 .so 即可 |
| 典型使用 | 嵌入式、Go 默认 | 桌面/服务器默认 |
Go 默认使用静态链接,生成自包含的可执行文件——这是 Go 的设计哲学:“部署就是复制一个文件”。C/C++ 默认动态链接 libc,但可以用 -static 切换为静态链接。
五、动态链接
5.1 动态链接的机制
5.2 PLT 与 GOT
PLT(Procedure Linkage Table)和 GOT(Global Offset Table)实现了延迟绑定——函数地址在第一次调用时才解析:
; PLT 条目(函数调用跳转到这里)printf@PLT: jmp *GOT[printf] ; 第一次:跳转到解析器 push reloc_index ; 压入重定位索引 jmp resolver ; 跳转到动态链接器
; 解析后,GOT[printf] 被更新为实际地址; 后续调用直接跳转到 printf 的实际地址5.3 动态链接的优缺点
| 优点 | 缺点 |
|---|---|
| 共享内存,节省 RAM | 启动时有加载开销 |
| 库更新无需重新编译 | 版本兼容性问题(ABI 稳定性) |
| 可执行文件更小 | 部署复杂(需要 .so 文件) |
| 插件机制 | 性能微损(间接调用) |
六、链接错误诊断
6.1 常见链接错误
| 错误 | 原因 | 解决方案 |
|---|---|---|
undefined reference | 符号有引用无定义 | 添加定义或链接缺少的库 |
multiple definition | 符号有多个强定义 | 使用 static 或 weak |
cannot find -lfoo | 找不到库文件 | 安装库或指定路径 -L |
relocation overflow | 地址超出范围 | 使用 -mcmodel=large |
symbol version mismatch | ABI 不兼容 | 重新编译或使用兼容版本 |
6.2 链接顺序问题
# 错误:链接顺序很重要!gcc main.o -lm -lfoo # 如果 foo 依赖 math,可能失败
# 正确:依赖库放在后面gcc main.o -lfoo -lm # foo 在前,math 在后
# 原因:链接器从左到右处理,只记住当前未解析的符号# 如果 -lm 在 -lfoo 前面,链接器处理 -lm 时还没有看到对 math 的引用链接顺序是 C/C++ 构建系统中最常见的陷阱之一。LLD(LLVM 的链接器)比 GNU ld 更宽容,但正确的链接顺序仍然是最佳实践。
七、栈帧与函数调用约定
7.1 x86-64 调用约定
| 寄存器 | 用途 | 调用者/被调用者保存 |
|---|---|---|
| RDI | 第 1 个参数 | — |
| RSI | 第 2 个参数 | — |
| RDX | 第 3 个参数 | — |
| RCX | 第 4 个参数 | — |
| R8 | 第 5 个参数 | — |
| R9 | 第 6 个参数 | — |
| RAX | 返回值 | — |
| RBX | 通用 | 被调用者保存 |
| RBP | 帧指针 | 被调用者保存 |
| RSP | 栈指针 | 被调用者保存 |
| R12-R15 | 通用 | 被调用者保存 |
7.2 栈帧布局
八、动手实践
8.1 实验一:探索目标文件
cat > link_test.c << 'EOF'int add(int a, int b) { return a + b; }int main() { return add(3, 4); }EOF
gcc -c link_test.c -o link_test.o
# 查看段readelf -S link_test.o
# 查看符号nm link_test.o
# 查看反汇编objdump -d link_test.o
# 查看重定位readelf -r link_test.o8.2 实验二:观察链接过程
# 详细链接输出gcc -Wl,--verbose link_test.o -o link_test 2>&1 | head -100
# 查看可执行文件的段readelf -l link_test
# 查看动态依赖ldd link_test
# 静态链接对比gcc -static link_test.o -o link_test_staticls -l link_test link_test_static8.3 实验三:自定义链接脚本
# 查看默认链接脚本ld --verbose | grep -A 100 "SECTIONS"
# 自定义链接脚本(简化)cat > custom.ld << 'EOF'ENTRY(main)SECTIONS { . = 0x400000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) }}EOF
gcc -T custom.ld -nostdlib link_test.o -o link_test_custom九、ELF 段的深入结构
9.1 关键段详解
ELF 文件中的每个段都有特定的用途和对齐要求:
| 段名 | 类型 | 内容 | 对齐 | 标志 |
|---|---|---|---|---|
.text | SHT_PROGBITS | 机器指令 | 16 | SHF_ALLOC + SHF_EXECINSTR |
.data | SHT_PROGBITS | 已初始化全局/静态变量 | 8 | SHF_ALLOC + SHF_WRITE |
.bss | SHT_NOBITS | 未初始化变量(不占文件空间) | 8 | SHF_ALLOC + SHF_WRITE |
.rodata | SHT_PROGBITS | 只读数据(字符串常量等) | 8 | SHF_ALLOC |
.symtab | SHT_SYMTAB | 符号表 | 8 | — |
.strtab | SHT_STRTAB | 字符串表 | 1 | — |
.rela.text | SHT_RELA | .text 的重定位表 | 8 | SHF_INFO_LINK |
.got | SHT_PROGBITS | 全局偏移表 | 8 | SHF_ALLOC + SHF_WRITE |
.plt | SHT_PROGBITS | 过程链接表 | 16 | SHF_ALLOC + SHF_EXECINSTR |
.dynamic | SHT_DYNAMIC | 动态链接信息 | 8 | SHF_ALLOC + SHF_WRITE |
9.2 Section Header 结构
// Section Header 条目(64 位)typedef struct { uint32_t sh_name; // 段名(字符串表索引) uint32_t sh_type; // 段类型 uint64_t sh_flags; // 段标志 uint64_t sh_addr; // 虚拟地址(加载后) uint64_t sh_offset; // 文件内偏移 uint64_t sh_size; // 段大小 uint32_t sh_link; // 关联段索引 uint32_t sh_info; // 附加信息 uint64_t sh_addralign; // 对齐要求 uint64_t sh_entsize; // 条目大小(如果段是表)} Elf64_Shdr;9.3 Program Header 与段映射
Program Header 描述操作系统如何将文件映射到内存:
十、重定位类型深入
10.1 x86-64 常见重定位类型
| 类型 | 值 | 计算 | 典型场景 |
|---|---|---|---|
R_X86_64_64 | 1 | S + A | 绝对地址引用(数据指针) |
R_X86_64_PC32 | 2 | S + A - P | PC 相对调用(同模块函数调用) |
R_X86_64_PLT32 | 4 | L + A - P | PLT 调用(跨模块函数调用) |
R_X86_64_GOTPCREL | 9 | G + A - P | GOT 引用(全局变量访问) |
R_X86_64_GOTPCRELX | 41 | G + A - P | 优化的 GOT 引用 |
R_X86_64_REX_GOTPCRELX | 42 | G + A - P | REX 前缀的优化 GOT 引用 |
R_X86_64_32 | 10 | S + A | 32 位绝对地址 |
R_X86_64_32S | 11 | S + A | 32 位符号扩展地址 |
S = 符号地址, A = 加数, P = 修正位置地址, G = GOT 条目地址, L = PLT 条目地址
10.2 重定位优化:GOTPCRELX
现代链接器可以对 GOT 引用做优化——如果符号在同一个模块内定义,可以省去 GOT 间接访问:
; 优化前:通过 GOT 间接访问movq global@GOTPCREL(%rip), %rax ; 加载 GOT 条目地址movq (%rax), %rax ; 通过 GOT 间接访问
; 链接器优化后:直接访问(同模块内)leaq global(%rip), %rax ; 直接 LEA,省去一次内存访问movq (%rax), %rax
; 更激进优化:如果只读movq global(%rip), %rax ; 直接 MOV,完全省去 GOTGOTPCRELX 优化是现代链接器(如 LLD、Gold)的重要优化。它可以将间接访问变为直接访问,减少一条指令和一次内存加载。这在静态链接或同模块调用时效果显著。
十一、GOT 与 PLT 的完整机制
11.1 GOT 的结构
GOT(Global Offset Table)是一个指针数组,存储全局变量和函数的实际地址:
// GOT 的概念结构struct GOT { // GOT[0]: .dynamic 段的地址(动态链接器使用) // GOT[1]: link_map 的地址(动态链接器使用) // GOT[2]: _dl_runtime_resolve 的地址(解析器) // GOT[3+]: 全局变量/函数的地址 void *dynamic_ptr; void *link_map_ptr; void *resolver_ptr; void *global_var_1; // 全局变量地址 void *printf_addr; // printf 函数地址 void *malloc_addr; // malloc 函数地址 // ...};11.2 PLT 的工作流程
11.3 延迟绑定 vs 立即绑定
| 模式 | 启动速度 | 运行时确定性 | 安全性 | 启用方式 |
|---|---|---|---|---|
| 延迟绑定 | 快(按需解析) | 差(首次调用延迟) | 低(可被 GOT 覆盖攻击) | 默认 |
| 立即绑定 | 慢(启动时全部解析) | 好(无运行时延迟) | 高(启动后 GOT 只读) | LD_BIND_NOW=1 |
GOT 覆盖攻击(GOT Overwrite)是常见的攻击手段——攻击者通过缓冲区溢出修改 GOT 条目,劫持控制流。RELRO(Relocation Read-Only)是一种缓解措施:部分 RELRO 将 .got 设为只读,完整 RELRO 在启动时解析所有符号后将 .got.plt 也设为只读。编译时用 -Wl,-z,relro,-z,now 启用完整 RELRO。
十二、本章小结
在上一章中,指令选择将 IR 操作映射到目标机器指令,指令调度则重排指令以利用处理器流水线。但优化后的机器指令还只是内存中的数据结构——它们需要被编码为二进制、写入目标文件,再由链接器合并为可执行文件。这最后一步,正是代码生成与链接要完成的工作。
| 概念 | 要点 |
|---|---|
| 代码生成 | 机器指令 → 目标文件,栈帧布局,函数序言/结语 |
| ELF 格式 | Header + Sections + Section Headers,可重定位/可执行/共享 |
| ELF 段结构 | .text/.data/.bss/.rodata/.got/.plt 等,各有对齐和标志 |
| 符号表 | 记录全局符号的定义和引用,LOCAL/GLOBAL/WEAK |
| 重定位 | 修正代码中的地址引用,绝对/PC相对/GOT相对/PLT相对 |
| 重定位优化 | GOTPCRELX 可将间接访问优化为直接访问 |
| 静态链接 | 编译时合并,自包含,Go 默认 |
| 动态链接 | 运行时加载,PLT+GOT 延迟绑定,共享内存 |
| GOT/PLT | 延迟绑定的核心机制,首次调用时解析,后续直接跳转 |
| 调用约定 | 参数传递、寄存器保存、栈帧布局 |
| 链接错误 | undefined reference、multiple definition、链接顺序 |
这一章把代码生成与链接的核心问题讲透了。下一章进入 LLVM 架构深入,看看现代编译器基础设施的设计与实现。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






