Read OSS

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.wasmwasm2c.cwasi.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#L166pub 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-exebuild-libbuild-objtestrun,乃至 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"]

Compilationsrc/Compilation.zig)是顶层编排者,负责管理编译配置、任务队列、链接输出、C 对象编译以及线程基础设施。并非每次 Compilation 都涉及 Zig 代码——比如执行 zig build-exe foo.o 时就没有 Zig 源码——因此它持有的 zcu: ?*Zcu 可能为空。

Zcusrc/Zcu.zig)即 Zig Compilation Unit(Zig 编译单元),在存在 Zig 源码需要编译时才会存在。它负责管理模块依赖图、文件追踪、导出信息以及 InternPool,并通过 comp: *Compilation 指向其父级 Compilation

InternPoolsrc/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 枚举,包含 bootstrapcorefull 等变体(以及若干面向开发场景的变体)。每个变体通过 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 时能够如此高效。