代码生成与链接:从 AIR 到二进制
前置知识
- ›本系列第 1 至 3 篇文章
- ›对机器码和指令编码有基本了解
- ›熟悉可执行文件格式(ELF、Mach-O 或 PE/COFF)
代码生成与链接:从 AIR 到二进制
Sema 为函数生成类型化的 AIR 之后,编译器的后端就接手了。流水线的后半段——代码生成与链接——负责将抽象操作转换为具体的机器指令,并将它们组装成可执行的二进制文件。Zig 编译器支持多个代码生成后端和多套链接器实现,统一由一个通用分发层进行调度。
本文将逐步追踪 AIR 如何流经代码生成分发器、降级为后端专属的 MIR、输出为机器码,最终被链接为产物文件。
代码生成分发器与后端选择
代码生成的入口位于 src/codegen.zig,通过 importBackend() 将请求路由到对应的后端:
fn importBackend(comptime backend: std.builtin.CompilerBackend) type {
return switch (backend) {
.stage2_aarch64 => aarch64,
.stage2_c => @import("codegen/c.zig"),
.stage2_llvm => @import("codegen/llvm.zig"),
.stage2_riscv64 => @import("codegen/riscv64/CodeGen.zig"),
.stage2_sparc64 => @import("codegen/sparc64/CodeGen.zig"),
.stage2_spirv => @import("codegen/spirv/CodeGen.zig"),
.stage2_wasm => @import("codegen/wasm/CodeGen.zig"),
.stage2_x86, .stage2_x86_64 => @import("codegen/x86_64/CodeGen.zig"),
// ...
};
}
这是编译期分发——backend 参数在编译时已知,因此 importBackend 会直接解析为具体模块。结合 dev.zig 中的特性门控(通过 第 32 行 的 devFeatureForBackend),不需要的后端会被彻底死代码消除。
flowchart TD
AIR["AIR"] --> DISP["codegen.zig dispatcher"]
DISP -->|"x86_64"| X86["x86_64/CodeGen.zig\n~190K lines"]
DISP -->|"aarch64"| ARM["aarch64/\nCodeGen.zig"]
DISP -->|"C"| CBE["c.zig\n~8K lines"]
DISP -->|"LLVM"| LLVM["llvm.zig\n~13K lines"]
DISP -->|"wasm"| WASM["wasm/CodeGen.zig"]
DISP -->|"riscv64"| RV["riscv64/CodeGen.zig"]
DISP -->|"spirv"| SPIRV["spirv/CodeGen.zig"]
两阶段代码生成:MIR 与输出
代码生成分为两个阶段,各自对应独立的函数。这种划分将生成什么指令与如何将字节写入输出文件的正确位置解耦开来。
第一阶段:generateFunction(第 141 行)将 AIR 降级为后端专属的 MIR(Machine IR)。它接收一个 Air,产出一个 AnyMir:
pub fn generateFunction(
lf: *link.File, pt: Zcu.PerThread,
src_loc: Zcu.LazySrcLoc, func_index: InternPool.Index,
air: *const Air, liveness: *const ?Air.Liveness,
) CodeGenError!AnyMir { ... }
第二阶段:emitFunction(第 176 行)将 MIR 转换为原始机器码字节。它由链接器调用,并可能查询链接器状态(例如用于解析重定位目标):
pub fn emitFunction(
lf: *link.File, pt: Zcu.PerThread,
src_loc: Zcu.LazySrcLoc, func_index: InternPool.Index,
atom_index: u32, any_mir: *const AnyMir,
w: *std.Io.Writer, debug_output: link.File.DebugInfoOutput,
) ...
flowchart LR
AIR["AIR\n(typed)"] -->|"generateFunction"| MIR["MIR\n(backend-specific)"]
MIR -->|"emitFunction"| MC["Machine Code\n(bytes)"]
MC -->|"linker writes"| BIN["Output Binary"]
style AIR fill:#e3f2fd
style MIR fill:#fff3e0
style MC fill:#fce4ec
第 100 行定义的 AnyMir 联合体是两个阶段之间的桥梁:
pub const AnyMir = union {
aarch64: @import("codegen/aarch64/Mir.zig"),
riscv64: @import("codegen/riscv64/Mir.zig"),
x86_64: @import("codegen/x86_64/Mir.zig"),
wasm: @import("codegen/wasm/Mir.zig"),
c: @import("codegen/c.zig").Mir,
// ...
};
这个联合体让通用的代码生成/链接器接口得以传递后端专属数据,无需模板或类型擦除。当前活跃字段由所使用的后端决定,访问错误字段属于逻辑错误,类型系统会直接捕获。
提示: C 后端比较特殊——它的"MIR"本质上是一棵 C AST,且
emitFunction永远不会被调用。取而代之的是,link.C直接理解 C 后端的 MIR,并将其渲染为.c文件。
后端深度解析:x86_64、C 与 LLVM
三个最重要的后端各自承担不同的职责:
| 后端 | 位置 | 规模 | 职责 |
|---|---|---|---|
| x86_64 | src/codegen/x86_64/CodeGen.zig |
~190K 行 | 最成熟的自托管后端 |
| C | src/codegen/c.zig |
~8K 行 | 引导链所必需(输出 .c 文件) |
| LLVM | src/codegen/llvm.zig |
~13K 行 | 对接外部 LLVM 的生产路径 |
x86_64 后端是旗舰级自托管后端,负责寄存器分配、指令选择以及直接的 x86_64 编码。它拥有约 19 万行代码,是编译器中体积最大的单一组件,甚至超过了 Sema。
C 后端输出的是 C 源代码而非机器码,这正是引导链得以成立的基础:zig1 使用 C 后端生成 zig2.c,再由系统 C 编译器负责编译。尽管承担着如此关键的职责,它的体积却只有约 8000 行,相当精简。
LLVM 后端不直接生成机器码,而是通过 LLVM 的 C API 将 AIR 转换为 LLVM IR,然后交由 LLVM 完成优化和本地代码生成。对于追求极致优化的用户,这是首选的生产路径。
此外,第 299 行还有一个 generateSymbol 函数,用于处理非函数声明(全局变量、常量等)。与 generateFunction 不同,它不经过 MIR,而是直接写入值的字节表示。
链接器文件抽象层
链接器层从 src/link.zig#L380 中定义的 File 结构体开始:
pub const File = struct {
tag: Tag,
comp: *Compilation,
emit: Path,
file: ?fs.File,
// ...
};
Tag 枚举标识具体的链接器实现:
pub const Tag = enum {
coff2, elf, elf2, macho, c, wasm, spirv, plan9, lld,
pub fn Type(comptime tag: Tag) type {
return switch (tag) {
.coff2 => Coff2, .elf => Elf, .elf2 => Elf2,
.macho => MachO, .c => C, .wasm => Wasm,
.spirv => SpirV, .lld => Lld, .plan9 => unreachable,
};
}
};
classDiagram
class File {
+tag: Tag
+comp: *Compilation
+emit: Path
+prelink()
}
class Elf {
+base: File
+zig_object: ?*ZigObject
+sections: MultiArrayList
+files: MultiArrayList
}
class MachO {
+base: File
}
class C {
+base: File
}
class Wasm {
+base: File
}
File <|-- Elf
File <|-- MachO
File <|-- C
File <|-- Wasm
可以注意到这里有两套 ELF 实现:elf(原版)和 elf2(新版重写)。fromObjectFormat 函数会根据 use_new_linker 标志在两者之间切换。在过渡期同时维护新旧两套实现,这在 Zig 项目中是常见的做法。
链接器通过编译工作队列中的 link_nav 和 link_type 任务接收来自代码生成的工作。当一个 Nav 的值完全解析后,系统会将一个 link_nav 任务加入队列,进而调用对应的链接器实现,将该声明写入输出二进制文件。
自托管 ELF 链接器深度解析
ELF 链接器位于 src/link/Elf.zig,是最复杂的自托管链接器实现。其结构体内嵌 base: link.File,并附加了 ELF 专属状态:
base: link.File,
zig_object: ?*ZigObject,
rpath_table: std.StringArrayHashMapUnmanaged(void),
image_base: u64,
// ... 许多 z_* 链接器选项标志
files: std.MultiArrayList(File.Entry) = .{},
sections: std.MultiArrayList(Section) = .{},
链接器管理的基本单位是 Atom(定义于 Elf/Atom.zig)——可重定位代码或数据的最小单元。每个函数和全局变量都会成为一个 Atom。ZigObject 代表 ELF 文件中由 Zig 生成的对象,而 SharedObject 条目则代表动态链接库。
在增量链接模式下,ELF 链接器可以就地修补单个 atom,无需重新链接整个二进制文件。某个函数发生变更时,只有对应的 atom 会被重新输出。这得益于初次链接时会预留填充空间,供后续更新填充使用。
flowchart TD
subgraph "ELF Linker"
ZO["ZigObject\n(Zig code atoms)"]
OBJ["Object Files\n(C objects, etc.)"]
SO["Shared Objects\n(.so files)"]
SECT["Sections\n(.text, .data, .rodata)"]
SHDR["Section Headers"]
OUT["ELF Binary"]
end
ZO --> SECT
OBJ --> SECT
SO --> SECT
SECT --> SHDR
SHDR --> OUT
代码生成与链接在任务系统中的运作
如第 1 篇文章所述,编译过程使用分阶段的任务队列。代码生成与链接通过 src/Compilation.zig 中的该系统进行协调。
processOneJob 函数处理 codegen_func 任务时会依次执行以下步骤:
- 检查 AIR 中所有类型是否已完全解析
- 为函数创建
SharedMir - 若后端支持,则为代码生成派生一个工作线程;否则在当前线程上执行
- 分发
link_func任务,将机器码写入输出二进制文件
任务优先级系统将 codegen_func 和 resolve_type_fully 设为 第 0 阶段(最高优先级),其他任务如 analyze_mod 和 link_nav 则为第 1 阶段。这样可以确保代码生成线程保持忙碌,同时 Sema 继续分析其他函数:
sequenceDiagram
participant WQ as Work Queue
participant T1 as Thread 1 (Sema)
participant T2 as Thread 2 (Codegen)
participant LNK as Linker
T1->>WQ: Queue codegen_func (stage 0)
T1->>T1: Continue analyzing next function
T2->>WQ: Dequeue codegen_func
T2->>T2: generateFunction (AIR → MIR)
T2->>LNK: Dispatch link_func task
LNK->>LNK: emitFunction (MIR → bytes)
提示:
separateCodegenThreadOk()方法决定代码生成是否可以在独立于 Sema 的线程上运行。由于部分后端存在线程安全问题(记录于Zcu.Feature.separate_thread),编译器在必要时会回退到单线程执行。
下一步
至此,我们已完整追踪了从源码字节到二进制输出的全部路径。在最后一篇文章中,我们将把视角拉远,审视整个编排层:Compilation.update() 如何驱动完整的编译周期、三种缓存模式如何运作、InternPool 的依赖追踪如何实现细粒度增量重编译,以及多阶段引导链如何将一切串联在一起。