Zig 编译器架构:代码库全局导览
前置知识
- ›Zig 语言基础知识(comptime、@import、错误联合类型、packed struct)
- ›对编译器概念有基本了解(词法分析、语法解析、IR、代码生成)
Zig 编译器架构:代码库全局导览
Zig 编译器是一个自托管的多阶段编译器,整个项目存放在单一的 monorepo 中。在撰写本文时,仅 src/ 目录就超过 30 万行 Zig 代码,x86_64 后端又额外贡献了约 19 万行。在读懂任何一个函数之前,你首先需要在脑海中建立一张全局地图——哪些文件是关键的,它们之间如何关联,数据又是如何流转的。本文就是这张地图。
我们将逐一介绍代码库的目录结构,梳理从源码到二进制的完整 IR 链,认识三个核心数据结构,并理解让自托管成为可能的自举流程。
代码库目录结构
Zig 的 monorepo 将编译器、标准库、构建系统以及内置的链接器都收纳在同一棵目录树下。整体结构如下:
| 目录 | 用途 |
|---|---|
src/ |
编译器核心——Sema、代码生成、链接器、CLI |
lib/std/ |
标准库,包含编译器前端 |
lib/std/zig/ |
词法分析器、解析器、AstGen、ZIR——供工具共享使用 |
stage1/ |
自举产物:zig1.wasm、wasm2c.c、wasi.c |
build.zig |
编译器的构建系统定义 |
bootstrap.c |
纯 C 程序,负责串联 zig1 → zig2 → zig3 |
test/ |
编译器测试套件 |
lib/compiler/ |
内置的 compiler-rt、aro(C 解析器)等 |
这里最出人意料的一点是:编译器前端——词法分析器、解析器以及 AstGen——位于 lib/std/zig/,而不是 src/。这是一个经过深思熟虑的架构决策,我们将在第 2 篇文章中详细探讨。
graph TD
subgraph "Repository Root"
A["src/"] --> B["Compiler Core"]
C["lib/std/zig/"] --> D["Frontend (shared)"]
E["stage1/"] --> F["Bootstrap Artifacts"]
G["build.zig"] --> H["Build System"]
I["bootstrap.c"] --> J["C Bootstrap Chain"]
end
D -->|"used by"| B
D -->|"used by"| K["zig fmt, ZLS"]
提示: 在浏览代码库时,请记住编译器代码中的
@import("std")实际上引用的是lib/std/。因此std.zig.Zir对应的是lib/std/zig/Zir.zig,而不是src/下的任何文件。
编译流水线总览
每一个 .zig 源文件在最终变成机器码之前,都要经历一条中间表示(IR)的转换链。整个流水线共分六个阶段:
flowchart LR
A["Source\nBytes"] --> B["Tokens"]
B --> C["AST"]
C --> D["ZIR"]
D --> E["AIR"]
E --> F["MIR"]
F --> G["Machine Code\n/ Binary"]
style A fill:#e8f5e9
style D fill:#fff3e0
style E fill:#e3f2fd
style G fill:#fce4ec
| 阶段 | 文件 | 位置 |
|---|---|---|
| 词法分析(Tokenization) | tokenizer.zig |
lib/std/zig/ |
| 语法解析(Parsing) | Parse.zig |
lib/std/zig/ |
| AstGen(AST → ZIR) | AstGen.zig |
lib/std/zig/ |
| Sema(ZIR → AIR) | Sema.zig |
src/ |
| 代码生成(AIR → MIR) | codegen.zig |
src/ |
| 链接(MIR → Binary) | link.zig |
src/ |
lib/std/zig/ 与 src/ 之间的边界,正是无类型表示与有类型表示的分界线。ZIR 是最后一个无类型 IR;Sema 将其转换为携带完整类型信息的 AIR(Analyzed IR)。这条边界同时也划分了 zig fmt、ZLS 等工具可以复用的代码,与仅供编译器内部使用的代码。
入口:main.zig 与命令分发
编译器的入口在 src/main.zig#L166,pub fn main() 在这里完成全局分配器的初始化——根据构建模式在调试分配器、libc 分配器和 SMP 分配器之间做出选择。
分配器初始化完成后,控制权转移到 mainArgs(),这是一个通过字符串比较来分发命令的大型 if-else 链:
flowchart TD
M["main()"] --> MA["mainArgs()"]
MA -->|"build-exe"| BOT["buildOutputType()"]
MA -->|"build-lib"| BOT
MA -->|"build-obj"| BOT
MA -->|"test"| BOT
MA -->|"run"| BOT
MA -->|"cc / c++"| BOT
MA -->|"fmt"| FMT["fmt.zig"]
MA -->|"build"| CMD["cmdBuild()"]
MA -->|"fetch"| FETCH["cmdFetch()"]
核心编译路径经由 buildOutputType() 执行,这是一个体量庞大的函数,负责解析 CLI 参数、构建 Compilation 对象,并在其上调用 update()。build-exe、build-lib、build-obj、test、run,乃至 cc/c++ 调用,都由这一个函数统一处理。
注意每个命令前的 dev.check() 调用——这些是编译时特性开关,我们将在自举章节中详细介绍。例如,第 254 行 的 dev.check(.build_exe_command) 用来确认 build-exe 命令在当前构建环境中是否可用。
三大核心数据结构:Compilation、Zcu、InternPool
整个编译过程由三个相互关联的数据结构来协调驱动。理解它们之间的关系,是读懂代码库任何部分的前提。
flowchart TD
COMP["Compilation\n(top-level orchestrator)"]
ZCU["Zcu\n(Zig Compilation Unit)"]
IP["InternPool\n(types + values store)"]
COMP -->|"zcu: ?*Zcu"| ZCU
ZCU -->|"comp: *Compilation"| COMP
ZCU -->|"intern_pool"| IP
COMP -->|"config: Config"| CFG["Config"]
COMP -->|"bin_file: ?*link.File"| LNK["Linker Output"]
COMP -->|"work_queues"| WQ["Job Queues"]
Compilation(src/Compilation.zig)是顶层编排者,负责管理编译配置、任务队列、链接输出、C 对象编译以及线程基础设施。并非每次 Compilation 都涉及 Zig 代码——比如执行 zig build-exe foo.o 时就没有 Zig 源码——因此它持有的 zcu: ?*Zcu 可能为空。
Zcu(src/Zcu.zig)即 Zig Compilation Unit(Zig 编译单元),在存在 Zig 源码需要编译时才会存在。它负责管理模块依赖图、文件追踪、导出信息以及 InternPool,并通过 comp: *Compilation 指向其父级 Compilation。
InternPool(src/InternPool.zig)是所有类型与值的统一存储中心。类型和值都以 u32 索引的形式存储在这个结构中。为了支持并发访问,它采用分片设计,每个线程有独立的 Local 存储,并共享 Shard 数组。InternPool 还承载着驱动增量编译的依赖追踪基础设施。
提示: 当你看到某个函数接受
pt: Zcu.PerThread参数时,这是一个线程安全的*Zcu包装器,其中携带了用于 InternPool 分片选择的线程 ID。这是编译器中最常见的参数类型。
自举流程与 dev.zig 特性开关
如何从零开始编译一个自托管的编译器?Zig 的答案是三阶段自举,加上一套巧妙的特性开关机制。
整个过程从 bootstrap.c 开始,这是一个纯 C 程序,负责串联起整条自举链:
sequenceDiagram
participant CC as System C Compiler
participant W2C as wasm2c
participant Z1 as zig1 (bootstrap)
participant Z2 as zig2 (core)
participant Z3 as zig3 (full)
CC->>W2C: Compile stage1/wasm2c.c
W2C->>Z1: Convert zig1.wasm → zig1.c
CC->>Z1: Compile zig1.c + wasi.c
Z1->>Z2: Build zig2.c (-ofmt=c, C backend only)
CC->>Z2: Compile zig2.c + compiler_rt.c
Z2->>Z3: Build full compiler (all backends)
第一阶段(zig1): stage1/ 中预编译好的 zig1.wasm 通过 wasm2c 转换为 C 代码,再由系统 C 编译器编译。zig1 运行在 bootstrap 环境下,只能输出 C 代码(-ofmt=c)。
第二阶段(zig2): zig1 将编译器源码编译为 zig2.c。bootstrap.c 脚本生成一个 config.zig,其中设置 pub const dev = .core;,启用 core 环境。系统 C 编译器随后将 zig2.c 编译为本地可执行文件。
第三阶段(zig3): zig2 构建出启用了所有后端和特性的完整编译器。
支撑这一切运转的核心是 src/dev.zig。它定义了一个 Env 枚举,包含 bootstrap、core、full 等变体(以及若干面向开发场景的变体)。每个变体通过 supports() 函数声明自己所支持的 Feature 列表。
第 297 行 的 check() 函数是整个机制的关键所在:它被声明为 inline,当某个特性不被支持时会返回 noreturn。这意味着编译器在编译期就能对整个子系统进行死代码消除。bootstrap 环境只支持 6 项特性(build-exe、build-obj、ast_gen、sema、c_backend、c_linker),因此生成的 zig1 二进制文件体积远小于完整编译器。
当前环境由 第 320 行 的逻辑决定:
pub const env: Env = if (@hasDecl(build_options, "dev"))
@field(Env, @tagName(build_options.dev))
else if (@hasDecl(build_options, "only_c") and build_options.only_c)
.bootstrap
else ...
.full;
build.zig 中声明了版本号 0.16.0,并从 src/dev.zig 导入 DevEnv,将环境配置贯穿整个编译器的构建过程。
接下来
有了这张全局地图,我们就可以深入流水线的第一个环节了。在第 2 篇文章中,我们将探索编译器前端——位于 lib/std/zig/ 的词法分析器、语法解析器以及 AstGen 阶段。我们将追踪源码字节是如何一步步变成 ZIR 的,并深入研究 ZIR 所采用的扁平指令数组数据结构——正是这一设计,使得 Sema 在顺序处理 ZIR 时能够如此高效。