mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2228 字
6 分钟
代码生成与链接
2026-01-23

代码生成和链接是编译器的最后阶段——代码生成将机器指令写入目标文件,链接器将多个目标文件和库合并为可执行文件。理解这个过程,你就能理解 undefined referencemultiple definition 等链接错误的根本原因。

一、从机器指令到可执行文件#

1.1 编译的最后阶段#

flowchart TB MIR["机器指令<br/>MachineInstr"] -->|代码发射| OBJ["目标文件<br/>.o"] OBJ -->|链接器| EXE["可执行文件<br/>a.out"] OBJ -->|归档器| LIB["静态库<br/>.a/.lib"] LIB -->|链接器| EXE style MIR fill:#e3f2fd,stroke:#1565c0 style OBJ fill:#fff3e0,stroke:#e65100 style EXE fill:#e8f5e9,stroke:#2e7d32 style LIB fill:#fce4ec,stroke:#c62828

1.2 代码生成的任务#

任务说明输出
代码发射将 MachineInstr 编码为二进制机器码字节
栈帧布局安排局部变量和临时变量的栈偏移栈帧大小
函数序言/结语生成函数入口和出口代码push/pop, 栈调整
数据段输出输出全局变量、常量池.data, .rodata
调试信息生成 DWARF 调试信息.debug_*

二、ELF 文件格式#

2.1 ELF 文件结构#

flowchart TB ELF["ELF 文件"] --> HEADER["ELF Header<br/>魔数、架构、入口点"] ELF --> PHDR["Program Header Table<br/>段加载信息"] ELF --> SECTIONS["Sections<br/>实际内容"] ELF --> SHDR["Section Header Table<br/>段描述信息"] SECTIONS --> TEXT[".text<br/>代码段"] SECTIONS --> DATA[".data<br/>已初始化数据"] SECTIONS --> BSS[".bss<br/>未初始化数据"] SECTIONS --> RODATA[".rodata<br/>只读数据"] SECTIONS --> SYMTAB[".symtab<br/>符号表"] SECTIONS --> RELA[".rela.text<br/>重定位表"] SECTIONS --> STRTAB[".strtab<br/>字符串表"] style ELF fill:#e3f2fd,stroke:#1565c0 style TEXT fill:#e8f5e9,stroke:#2e7d32 style DATA fill:#fff3e0,stroke:#e65100 style SYMTAB fill:#fce4ec,stroke:#c62828

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_REL1可重定位文件.o
ET_EXEC2可执行文件(无)
ET_DYN3共享对象文件.so
ET_CORE4Core 文件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 Header
readelf -h hello.o
readelf -h hello
# 查看段表
readelf -S hello.o
# 查看符号表
readelf -s hello.o
# 查看重定位表
readelf -r hello.o
# 查看段内容
readelf -x .text hello.o
readelf -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 符号解析#

链接器的核心任务是符号解析——将每个符号引用绑定到唯一的定义:

flowchart TB REF["符号引用<br/>call printf"] --> SEARCH["搜索符号定义"] SEARCH --> FOUND{找到定义?} FOUND |是| BIND["绑定引用到定义"] FOUND |否| WEAK{弱符号?} WEAK |是| BIND_W["绑定到弱符号<br/>或默认值 0"] WEAK |否| ERROR["错误:<br/>undefined reference"] style REF fill:#e3f2fd,stroke:#1565c0 style BIND fill:#e8f5e9,stroke:#2e7d32 style ERROR fill:#fce4ec,stroke:#c62828

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 静态链接的过程#

flowchart TB A["a.o"] --> MERGE["合并段"] B["b.o"] --> MERGE LIBC["libc.a"] --> MERGE MERGE --> RESOLVE["符号解析"] RESOLVE --> RELOC["重定位"] RELOC --> EXE["可执行文件"] style A fill:#e3f2fd,stroke:#1565c0 style B fill:#e3f2fd,stroke:#1565c0 style EXE fill:#e8f5e9,stroke:#2e7d32

4.2 段合并规则#

合并规则对齐
.text按顺序拼接所有 .text16 字节
.data按顺序拼接所有 .data8 字节
.bss合并大小(不占文件空间)8 字节
.rodata按顺序拼接8 字节
.symtab合并所有符号8 字节

4.3 静态链接 vs 动态链接#

特性静态链接动态链接
链接时机编译时运行时
可执行文件大小大(包含库代码)小(只有引用)
启动速度稍慢(需加载共享库)
内存使用每个进程一份多个进程共享
更新需重新编译替换 .so 即可
典型使用嵌入式、Go 默认桌面/服务器默认
Note

Go 默认使用静态链接,生成自包含的可执行文件——这是 Go 的设计哲学:“部署就是复制一个文件”。C/C++ 默认动态链接 libc,但可以用 -static 切换为静态链接。

五、动态链接#

5.1 动态链接的机制#

flowchart TB EXE["可执行文件<br/>包含 .dynamic 段"] --> LOADER["动态链接器<br/>ld-linux-x86-64.so"] LOADER --> LOAD["加载依赖 .so"] LOAD --> RESOLVE2["延迟符号解析<br/>PLT + GOT"] RESOLVE2 --> RUN["运行"] style EXE fill:#e3f2fd,stroke:#1565c0 style LOADER fill:#fff3e0,stroke:#e65100 style RUN fill:#e8f5e9,stroke:#2e7d32

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符号有多个强定义使用 staticweak
cannot find -lfoo找不到库文件安装库或指定路径 -L
relocation overflow地址超出范围使用 -mcmodel=large
symbol version mismatchABI 不兼容重新编译或使用兼容版本

6.2 链接顺序问题#

# 错误:链接顺序很重要!
gcc main.o -lm -lfoo # 如果 foo 依赖 math,可能失败
# 正确:依赖库放在后面
gcc main.o -lfoo -lm # foo 在前,math 在后
# 原因:链接器从左到右处理,只记住当前未解析的符号
# 如果 -lm 在 -lfoo 前面,链接器处理 -lm 时还没有看到对 math 的引用
Warning

链接顺序是 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 栈帧布局#

flowchart TB subgraph 栈帧["函数栈帧"] ARGS["参数 7+<br/>(在栈上)"] RET["返回地址"] RBP_S["保存的 RBP"] LOCAL["局部变量"] TEMP["临时变量/溢出"] ALLOCA["alloca 空间<br/>(动态分配)"] end 高地址 --> ARGS --> RET --> RBP_S --> LOCAL --> TEMP --> ALLOCA --> 低地址 style ARGS fill:#e3f2fd,stroke:#1565c0 style LOCAL fill:#fff3e0,stroke:#e65100 style TEMP fill:#fce4ec,stroke:#c62828

八、动手实践#

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.o

8.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_static
ls -l link_test link_test_static

8.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 文件中的每个段都有特定的用途和对齐要求:

段名类型内容对齐标志
.textSHT_PROGBITS机器指令16SHF_ALLOC + SHF_EXECINSTR
.dataSHT_PROGBITS已初始化全局/静态变量8SHF_ALLOC + SHF_WRITE
.bssSHT_NOBITS未初始化变量(不占文件空间)8SHF_ALLOC + SHF_WRITE
.rodataSHT_PROGBITS只读数据(字符串常量等)8SHF_ALLOC
.symtabSHT_SYMTAB符号表8
.strtabSHT_STRTAB字符串表1
.rela.textSHT_RELA.text 的重定位表8SHF_INFO_LINK
.gotSHT_PROGBITS全局偏移表8SHF_ALLOC + SHF_WRITE
.pltSHT_PROGBITS过程链接表16SHF_ALLOC + SHF_EXECINSTR
.dynamicSHT_DYNAMIC动态链接信息8SHF_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 描述操作系统如何将文件映射到内存:

flowchart LR subgraph 文件["ELF 文件"] TEXT_F[".text"] RODATA_F[".rodata"] DATA_F[".data"] BSS_F[".bss"] end subgraph 内存["进程内存"] TEXT_M["0x400000 .text<br/>R+X"] RODATA_M["0x600000 .rodata<br/>R"] DATA_M["0x601000 .data<br/>R+W"] BSS_M["0x602000 .bss<br/>R+W"] end TEXT_F --> TEXT_M RODATA_F --> RODATA_M DATA_F --> DATA_M BSS_F --> BSS_M style TEXT_M fill:#e8f5e9,stroke:#2e7d32 style RODATA_M fill:#e3f2fd,stroke:#1565c0 style DATA_M fill:#fff3e0,stroke:#e65100 style BSS_M fill:#fce4ec,stroke:#c62828

十、重定位类型深入#

10.1 x86-64 常见重定位类型#

类型计算典型场景
R_X86_64_641S + A绝对地址引用(数据指针)
R_X86_64_PC322S + A - PPC 相对调用(同模块函数调用)
R_X86_64_PLT324L + A - PPLT 调用(跨模块函数调用)
R_X86_64_GOTPCREL9G + A - PGOT 引用(全局变量访问)
R_X86_64_GOTPCRELX41G + A - P优化的 GOT 引用
R_X86_64_REX_GOTPCRELX42G + A - PREX 前缀的优化 GOT 引用
R_X86_64_3210S + A32 位绝对地址
R_X86_64_32S11S + A32 位符号扩展地址

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,完全省去 GOT
Note

GOTPCRELX 优化是现代链接器(如 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 的工作流程#

sequenceDiagram participant Code as 调用代码 participant PLT as PLT 条目 participant GOT as GOT 表 participant Linker as 动态链接器 Note over Code,Linker: 第一次调用 printf Code->>PLT: call printf@PLT PLT->>GOT: jmp *GOT[printf] Note over GOT: GOT[printf] 指向 PLT 下一条指令 PLT->>Linker: push index, jmp resolver Linker->>Linker: 查找 printf 实际地址 Linker->>GOT: 更新 GOT[printf] = 实际地址 Linker->>Code: 跳转到 printf 执行 Note over Code,Linker: 后续调用 printf Code->>PLT: call printf@PLT PLT->>GOT: jmp *GOT[printf] Note over GOT: GOT[printf] 已是实际地址 GOT->>Code: 直接跳转到 printf

11.3 延迟绑定 vs 立即绑定#

模式启动速度运行时确定性安全性启用方式
延迟绑定快(按需解析)差(首次调用延迟)低(可被 GOT 覆盖攻击)默认
立即绑定慢(启动时全部解析)好(无运行时延迟)高(启动后 GOT 只读)LD_BIND_NOW=1
Warning

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 架构深入,看看现代编译器基础设施的设计与实现。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

代码生成与链接
https://blog.souloss.com/posts/compiler/code-generation-and-linking/
作者
Souloss
发布于
2026-01-23
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时