Zig 编译器内部架构与执行流程深度分析

2026-01-26

jiacai2050 | 2026-01-26

Table of Contents

    • 摘要
    • 1. 引言与设计哲学
      • 1.1 懒惰编译(Lazy Compilation)的理论基础
      • 1.2 数据导向架构(Data-Oriented Design)
      • 1.3 增量编译模型
    • 2. 编译器执行流程总览
      • 2.1 核心阶段图谱
      • 2.2 逻辑流程图 (Mermaid Representation)
    • 3. 第一阶段:解析与 AST 构建 (Parsing)
      • 3.1 标记化 (Tokenization)
      • 3.2 抽象语法树 (AST) 的内存布局
    • 4. 第二阶段:AST 降级与 ZIR 生成 (AstGen)
      • 4.1 ZIR 的核心特性:无类型 (Untyped)
      • 4.2 结果位置语义 (Result Location Semantics)
      • 4.3 ZIR 指令集概览
    • 5. 第三阶段:统一存储 (The InternPool)
      • 5.1 类型即值 (Types as Values)
      • 5.2 值的表示
    • 6. 第四阶段:语义分析 (Sema) —— 编译器的核心
      • 6.1 Sema 作为 ZIR 解释器
      • 6.2 inst_map: 跨越时空的桥梁
      • 6.3 懒惰依赖解析 (Lazy Dependency Resolution)
      • 6.4 泛型单态化 (Monomorphization)
    • 7. 第五阶段:分析后中间表示 (AIR)
      • 7.1 AIR 的结构特征
      • 7.2 活跃度分析 (Liveness Analysis)
      • 7.3 为什么需要两层 IR (ZIR vs AIR)?
    • 8. 第六阶段:代码生成 (CodeGen) 与后端
      • 8.1 语义与代码生成的交织 (Interleaving)
      • 8.2 LLVM 后端
      • 8.3 自举后端 (Self-Hosted Backends)
      • 8.4 C 后端
    • 9. 第七阶段:链接 (Linking)
      • 9.1 原地增量链接
      • 9.2 跨平台交叉编译
    • 10. 自举过程 (Bootstrapping) 理论
      • 10.1 阶段演进
    • 11. 总结
        • Works cited

摘要

这篇文章的核心在于剖析 Zig 编译器独特的“懒惰编译”(Lazy Compilation)哲学、数据导向设计(Data-Oriented Design, DOD)在 AST/ZIR/AIR 阶段的具体应用,以及其如何通过统一的 InternPool 结构实现类型与值的同构存储。报告将详细阐述从源码解析到机器码生成的完整流水线,特别关注语义分析阶段(Sema)如何作为解释器与代码生成器的混合体,实现编译期代码执行(Comptime)与运行时指令发射的交织处理。此外,报告还将探讨 Zig 自举(Bootstrapping)过程的理论基础及其增量编译系统的实现原理。

1. 引言与设计哲学

Zig 编译器不仅仅是一个将文本转换为二进制的转换器,它更像是一个针对源代码的查询引擎。与传统的 C/C++ 编译器通常采用的“以文件为单位的线性批处理”模式不同,Zig 编译器采用了一种基于依赖图的、按需驱动的架构。这种设计不仅是为了编译速度的优化,更是为了支撑 Zig 语言的核心特性——编译期代码执行(Comptime)与泛型元编程。

1.1 懒惰编译(Lazy Compilation)的理论基础

在传统的编译模型中,包含一个头文件通常意味着该文件中的所有声明都会被解析和分析。然而,Zig 引入了极致的懒惰策略:只有当一个声明(函数、变量、结构体等)被程序入口点(如 main 函数或 test 块)直接或间接引用时,它才会被语义分析。

从理论上讲,这种机制使得 Zig 的泛型系统成为可能。在 Zig 中,泛型函数本质上是编译期执行的函数,其逻辑可能包含仅在特定类型参数下合法的分支。如果编译器采用“急切编译”(Eager Compilation),那么未被使用的、包含类型错误的分支将会导致编译失败。懒惰编译确保了只有实际实例化的代码路径才会受到类型检查系统的约束 1。

1.2 数据导向架构(Data-Oriented Design)

Zig 编译器的内部实现彻底摒弃了传统的面向对象编译器设计(即为每个 AST 节点分配一个独立的堆对象)。相反,它采用了数据导向设计(DOD)。

  • 结构数组(SoA):AST 节点、ZIR 指令、AIR 指令均存储在巨大的连续内存数组中(通常是 MultiArrayList)。
  • 索引引用:节点之间不通过 64 位的指针相互引用,而是通过 32 位的整数索引。这不仅减少了 50% 的指针内存开销,更重要的是极大地提高了 CPU 缓存(L1/L2 Cache)的命中率,因为遍历树结构变成了遍历连续的内存块 3。
  • 内存池化:通过 ArenaAllocator 等机制,编译器在阶段结束时可以一次性释放整个阶段的内存,避免了数百万次微小对象析构带来的性能损耗。

1.3 增量编译模型

Zig 的增量编译并非通过对比文件时间戳实现,而是基于细粒度的依赖追踪。核心组件 Zcu (Zig Compilation Unit) 维护着一个全程序的依赖图。当源文件发生变更时,编译器仅重新分析受影响的声明及其传递依赖项,而非重新构建整个模块。这种设计要求中间表示(IR)必须高度可序列化且无上下文相关性,以便于缓存和重用。

2. 编译器执行流程总览

Zig 的编译管线可以抽象为一系列数据变换阶段。每一个阶段都产生一种特定的中间表示(IR),其抽象程度逐级降低,类型信息逐级丰富。

2.1 核心阶段图谱

  1. 词法与语法分析 (Parse):源码 –> 抽象语法树 (AST)。
  2. AST 降级 (AstGen):AST –> Zig 中间表示 (ZIR)。
  3. 语义分析 (Sema):ZIR –> 分析后中间表示 (AIR) + 编译期常量 (Values)。
  4. 代码生成 (CodeGen):AIR –> 机器码 / LLVM IR / C 代码。
  5. 链接 (Link):目标文件 –> 可执行文件。

2.2 逻辑流程图 (Mermaid Representation)

为了满足“流程图”的需求,以下是 Zig 编译器核心控制流的结构化描述:

How Zig compiler works

3. 第一阶段:解析与 AST 构建 (Parsing)

解析阶段是编译器与源代码的第一次接触。Zig 的解析器设计注重速度和错误恢复能力。

3.1 标记化 (Tokenization)

词法分析器(Tokenizer)扫描 UTF-8 编码的源文件字节流,生成标记(Token)流。Zig 语言的语法设计尽量减少了上下文敏感性,使得词法分析可以高效并行进行。

  • SIMD 优化:虽然具体实现随版本迭代,但其设计允许利用 SIMD 指令快速跳过空白符或识别标识符。
  • 零拷贝:生成的 Token 并不拷贝字符串内容,而是存储指向源文件缓冲区的切片(Slice),即 start_index 和 length。

3.2 抽象语法树 (AST) 的内存布局

Zig 的 AST 不是一棵由对象指针连接的树,而是一组并行的数组(Struct of Arrays)。

  • 主节点数组:存储 Tag(节点类型,如 fn_decl、var_decl)和主要数据(如标记索引)。
  • 额外数据数组:对于无法放入标准节点结构中的复杂节点(如包含多个参数的函数声明),主节点会存储一个索引,指向 extra_data 数组,那里存储了完整的子节点列表。 这种紧凑的布局使得整个 AST 可以完全驻留在 CPU 的 L2 或 L3 缓存中,大幅提升了后续遍历的速度。

4. 第二阶段:AST 降级与 ZIR 生成 (AstGen)

AstGen(AST Generation)阶段负责将树状的 AST 转换为线性的指令序列——ZIR(Zig Intermediate Representation)。

4.1 ZIR 的核心特性:无类型 (Untyped)

ZIR 最关键的特征是它是无类型的 3。在这个阶段,编译器只关注语法结构,而不关心类型语义。例如,对于表达式 const x = a + b;,ZIR 会生成一条“加法”指令,引用 a 和 b,但此时编译器并不知道 a 和 b 是整数、浮点数还是向量,甚至不知道它们是否存在。

这种设计的必要性源于 Zig 的 comptime 特性。因为类型本身可以由函数在编译期返回(First-class Types),所以在执行 comptime 代码之前,AST 中很多节点的类型是不可知的。如果试图在 AstGen 阶段进行类型检查,就会陷入“为了知道类型需要执行代码,为了执行代码需要知道类型”的死锁。

4.2 结果位置语义 (Result Location Semantics)

在将递归的 AST 转换为线性的 ZIR 时,Zig 引入了 ResultLoc(结果位置)的概念,以优化数据流向 6。当 AstGen 遍历一个 AST 节点时,它会向下传递一个 ResultLoc 参数,告诉子节点:“计算完结果后,请直接把数据写入这个位置。”

  • None/Discard:父节点不需要这个值(例如 _ = foo();)。子节点生成计算指令,但不生成存储指令。
  • Return:结果直接作为函数的返回值。
  • Instruction:结果作为一个临时值,供后续指令引用。
  • Block:结果应写入当前基本块的末尾。 这种机制有效地实现了“返回值优化”(RVO)的广义形式,避免了大量的临时变量拷贝,使得生成的 ZIR 极其精简。

4.3 ZIR 指令集概览

ZIR 指令集比机器码高级,但比 AST 低级。常见指令包括:

  • alloc:在栈上分配空间(类型未知)。
  • ret_node:返回控制流。
  • call:调用函数(目标和参数均为索引引用)。
  • block / block_end:定义词法作用域。
  • extended:用于处理极其复杂的结构,如包含大量字段的结构体初始化。

由于 ZIR 不包含类型信息且不依赖外部文件,因此 ZIR 是文件级缓存的理想对象。一旦源文件生成了 ZIR,只要文件内容不变,ZIR 就可以被持久化缓存并在下次编译时直接加载 4。

5. 第三阶段:统一存储 (The InternPool)

在深入语义分析之前,必须理解 Zig 编译器的“数据仓库”——InternPool

5.1 类型即值 (Types as Values)

在 Zig 语言理论中,类型(Type)只是值(Value)的一种特例。u32 是一个类型,同时它也是一个类型为 type 的值。为了在编译器内部统一处理这两者,Zig 使用 InternPool 存储所有的常量值和类型定义 3。

  • 去重(Interning):InternPool 保证了结构上的唯一性。如果在代码中两处定义了相同的结构体 struct { a: i32 },或者两次计算出了整数 42,它们在 InternPool 中只会被存储一次,并拥有完全相同的索引(Index)。
  • 轻量级比较:判断两个类型是否相等,只需比较它们在 InternPool 中的索引是否相同(即 u32 比较)。这使得类型检查极度高效。

5.2 值的表示

InternPool 中的值可以是简单的(如整数、布尔值),也可以是复杂的(如结构体实例、数组、函数体)。对于复杂值,索引指向池中的一段载荷(Payload)。这种集中式管理使得编译器在进行常量折叠(Constant Folding)时,可以直接操作池中的索引,而无需频繁分配内存。

6. 第四阶段:语义分析 (Sema) —— 编译器的核心

语义分析(Semantic Analysis,简称 Sema)是 Zig 编译器最复杂、最核心的阶段。它不仅仅是类型检查器,本质上它是一个混合了解释执行功能的 ZIR 虚拟机 7。

6.1 Sema 作为 ZIR 解释器

Sema 的工作方式是遍历 ZIR 指令流,并为每一条指令维护其状态。对于每条 ZIR 指令,Sema 执行以下逻辑循环:

  1. 操作数解析:查找该指令引用的操作数。这些操作数可能是之前指令产生的结果。
  2. 类型推导与检查:根据操作数的类型,验证当前操作的合法性。
  3. 着色(Coloring)与分流
    • 编译期路径 (Comptime):如果所有操作数都是编译期已知的常量(即它们在 inst_map 中对应的是 InternPool 的值),且操作本身支持编译期执行,Sema 会直接在其内部的虚拟机中计算结果。计算结果被存回 InternPool,并在 inst_map 中记录为“常量”。此过程不生成任何运行时代码。
    • 运行时路径 (Runtime):如果操作数包含运行时变量,或者操作具有副作用(如 I/O),Sema 会发射一条对应的 AIR 指令。该指令的引用被记录在 inst_map 中。

6.2 inst_map: 跨越时空的桥梁

inst_map 是 Sema 阶段的关键数据结构,它映射了 ZIR 指令索引 -> 语义结果。

这个“语义结果”是一个联合体(Union),它可能是一个:

  • 常量值 (Value):指向 InternPool,表示该指令在编译期就被计算完成了。
  • AIR 引用 (Air.Inst.Ref):指向生成的 AIR 代码缓冲区,表示该指令将在运行时执行。

这种机制完美地解释了 Zig 如何在同一个语法结构中混合处理 comptime 和 runtime 代码。对于编译器来说,它们只是 inst_map 中不同的结果变体而已。

6.3 懒惰依赖解析 (Lazy Dependency Resolution)

Sema 并不按顺序分析文件。它从根声明开始。当分析到一条引用其他函数(如 foo())的指令时:

  1. Sema 检查 foo 是否已经被分析。
  2. 如果没有,Sema 挂起当前函数的分析任务。
  3. Sema 找到 foo 对应的 ZIR,并为 foo 启动一个新的 Sema 分析任务。
  4. 一旦 foo 分析完成(即推导出返回值类型),Sema 恢复之前的任务,继续往下执行。 这种递归的、按需的遍历确保了未被引用的代码永远不会被分析,从而避免了不必要的编译错误。

6.4 泛型单态化 (Monomorphization)

当 Sema 遇到一个函数调用,且该函数带有 comptime 参数(例如 fn list(T: type) 中的 T)时,它会触发单态化机制。

  1. Sema 将传入的参数(如 u32)作为键。
  2. Sema 检查该函数是否已经针对 u32 生成了实例。
  3. 如果没有,Sema 会克隆该函数的 ZIR,并将 T 绑定为 u32,然后对这个新的 ZIR 实例进行分析。
  4. 生成的 AIR 是专门针对 u32 优化的。

7. 第五阶段:分析后中间表示 (AIR)

AIR(Analyzed Intermediate Representation)是 Sema 阶段的产出物。与 ZIR 不同,AIR 是全类型(Fully Typed)且经过初步优化的 8。

7.1 AIR 的结构特征

  • 全类型:AIR 中的每一条指令都明确携带了类型信息。例如,ZIR 中的“加法”在 AIR 中会变体为 add_u32、add_f64 或 add_checked。
  • 显式控制流:ZIR 中的高级控制流(如 defer、errdefer、try)在 AIR 中会被降级(Lowering)为更底层的形式。例如,defer 会被展开到所有的退出路径中。
  • SSA 形式:AIR 基本上遵循静态单赋值形式(SSA),这使得数据流分析更加容易。

7.2 活跃度分析 (Liveness Analysis)

在生成 AIR 后,编译器立即执行活跃度分析。这一步计算每个 AIR 指令生成的值的生命周期——它在何处诞生,在何处最后一次被使用(死亡)。

  • 墓碑机制 (Tombstones):分析结果会生成一张表,标记每个指令的“死亡点”。
  • 资源回收:后端利用这些信息来重用寄存器或栈槽(Stack Slots)。如果一个值在第 10 行后不再使用,其占用的寄存器就可以在第 11 行分配给别的变量。这是 Zig 生成高效机器码的关键步骤之一 10。

7.3 为什么需要两层 IR (ZIR vs AIR)?

  • ZIR:负责抽象语法,独立于类型系统,便于缓存及 IDE 工具分析。
  • AIR:负责抽象语义,包含精确的类型和执行逻辑,便于后端生成机器码。 这种分离使得编译器前端(AstGen)和中端(Sema)可以解耦,分别专注于语法正确性和语义正确性。

8. 第六阶段:代码生成 (CodeGen) 与后端

Zig 支持多种后端,Sema 生成的 AIR 会被分发给选定的后端进行处理。

8.1 语义与代码生成的交织 (Interleaving)

为了降低内存峰值,Zig 编译器并不等待所有函数的 AIR 都生成完毕才开始代码生成。相反,一旦某个函数的 AIR 生成并经过活跃度分析,它就会立即被发送给 CodeGen 模块。

  • 即时释放:一旦后端完成了该函数的机器码生成,对应的 AIR 数据结构就可以被释放(除非在增量编译模式下需要保留)。这使得编译器能够处理数百万行代码的巨型项目而不至于内存溢出。

8.2 LLVM 后端

对于 ReleaseFast、ReleaseSafe 和 ReleaseSmall 构建模式,Zig 通常使用 LLVM 后端。

  • 转换:AIR 指令被一对一或多对一地转换为 LLVM IR 指令。
  • 优化:利用 LLVM 强大的优化管线(Pass Pipeline)进行循环展开、向量化、内联等高级优化。
  • 局限:LLVM 庞大且慢,因此主要用于发布构建。

8.3 自举后端 (Self-Hosted Backends)

对于 Debug 构建,Zig 优先使用自研的后端(如 x86_64, ARM64, WASM 后端)。

  • 直接生成:这些后端直接将 AIR 转换为二进制机器码,跳过了生成 LLVM IR 的过程。
  • 速度优势:由于省去了庞大的中间层,自举后端的编译速度通常比 LLVM 后端快数倍,极大地提升了开发时的“修改 - 编译 - 运行”循环效率 11。

8.4 C 后端

Zig 能够将 AIR 转换为标准的 C 代码。这使得 Zig 可以“搭便车”运行在任何拥有 C 编译器的平台上(如一些嵌入式架构或特殊的超级计算机),实现了极致的可移植性。

9. 第七阶段:链接 (Linking)

Zig 编译器内置了一个名为 zld(Zig Linker)的链接器,它直接集成在编译管线中。

9.1 原地增量链接

对于 ELF (Linux) 和 Mach-O (macOS) 等格式,Zig 支持原地二进制修补。

当发生增量编译时,只有发生变化的函数会被重新生成机器码。zld 会计算新代码的大小:

  • 如果新代码小于等于旧代码占用的空间(加上填充区),直接覆盖旧代码。
  • 如果新代码变大,链接器会将新代码追加到二进制文件的末尾,并修改所有调用该函数的跳转指令(Jumps/Calls)指向新地址。 这种技术使得 Zig 在大型项目中的重链接时间几乎可以忽略不计。

9.2 跨平台交叉编译

由于 Zig 自身携带了所有支持平台的 libc 定义和链接逻辑,它天生支持交叉编译。在架构上,这意味着 CodeGen 和 Link 阶段可以根据 Target Triple 动态加载不同的配置,而无需依赖宿主机的系统工具链。

10. 自举过程 (Bootstrapping) 理论

Zig 是一种自举语言,即 Zig 编译器是用 Zig 编写的。为了从零构建编译器,Zig 采用了一个严格的四阶段自举链 13。

10.1 阶段演进

  • Stage 1 (Resurrection):这是一个预编译的 WASM 二进制文件(或 C 源码),它包含了旧版本的 Zig 编译器逻辑。它不需要现存的 Zig 编译器,只需要一个 C 编译器或 WASM 解释器即可运行。
  • Stage 2 (Compiler Build):利用 Stage 1 编译器编译当前的 Zig 源码。生成的 zig2 可执行文件是一个功能完整的编译器,但由于 Stage 1 通常未进行激进优化,zig2 的运行速度较慢。
  • Stage 3 (Optimization):利用 zig2 再次编译 Zig 源码。因为 zig2 本身支持优化,生成的 zig3 是经过全优化的 Release 版本。这是最终发布给用户的版本。
  • Stage 4 (Verification):利用 zig3 再次编译源码。理论上,zig3 和 zig4 应该是二进制逐位一致的(Bit-identical),这用于验证编译器的确定性。

这个过程确保了 Zig 不依赖于 C++ 代码库(移除了旧的 C++ 实现),实现了完全的语言自洽 15。

11. 总结

Zig 编译器的内部架构展示了现代系统编程语言在编译原理上的突破。通过将懒惰语义分析数据导向的内存布局相结合,Zig 成功地在一个统一的管线中解决了元编程的灵活性与编译速度之间的矛盾。

其核心流程可以概括为:解析构建了高效的 AST 数组;AstGen 将其转化为无类型的 ZIR 指令流并处理作用域;Sema 作为解释器遍历 ZIR,通过 InternPool 统一管理类型与值,分离出编译期常量和运行时 AIR 指令;最后,CodeGenLink 阶段通过增量技术将 AIR 转化为最终的高效机器码。

这种架构不仅使得 Zig 能够拥有媲美脚本语言的元编程能力,同时保持了系统级语言所需的裸机性能与确定性内存控制,为未来的编译器设计提供了一个极具参考价值的理论范本。

Works cited

  1. How Zig incremental compilation is implemented internally? - Explain - Ziggit Dev, accessed January 24, 2026, https://ziggit.dev/t/how-zig-incremental-compilation-is-implemented-internally/3543
  2. Lazy Dependencies, Best Dependencies? - Brainstorming - Ziggit Dev, accessed January 24, 2026, https://ziggit.dev/t/lazy-dependencies-best-dependencies/5509
  3. Zig Programming Language Compiler & Toolchain | Augment Code, accessed January 24, 2026, https://www.augmentcode.com/open-source/ziglang/zig
  4. perform AstGen on whole files at once (AST->ZIR) · Issue #8516 · ziglang/zig - GitHub, accessed January 24, 2026, https://github.com/ziglang/zig/issues/8516
  5. How Zig incremental compilation is implemented internally? - #2 by mlugg - Explain - Ziggit, accessed January 24, 2026, https://ziggit.dev/t/how-zig-incremental-compilation-is-implemented-internally/3543/2
  6. Zig AstGen: AST => ZIR - Mitchell Hashimoto, accessed January 24, 2026, https://mitchellh.com/zig/astgen
  7. Implementation of Comptime - Explain - Ziggit, accessed January 24, 2026, https://ziggit.dev/t/implementation-of-comptime/5041
  8. Zig Sema: ZIR => AIR - Mitchell Hashimoto, accessed January 24, 2026, https://mitchellh.com/zig/sema
  9. zig/src/Air.zig at master · ziglang/zig - GitHub, accessed January 24, 2026, https://github.com/ziglang/zig/blob/master/src/Air.zig
  10. sometimes there is an unwanted memcpy when passing large structs by-value · Issue #17580 · ziglang/zig - GitHub, accessed January 24, 2026, https://github.com/ziglang/zig/issues/17580
  11. Stage 2 Proposal: Standardise a binary format for ZIR, and enable compilation to and from this representation · Issue #5635 · ziglang/zig - GitHub, accessed January 24, 2026, https://github.com/ziglang/zig/issues/5635
  12. Zig builds are getting faster - Hacker News discussion thread, accessed January 24, 2026.
  13. Building self-hosted from the original C++ implementation - Help - Ziggit, accessed January 24, 2026, https://ziggit.dev/t/building-self-hosted-from-the-original-c-implementation/6607
  14. Bootstrapping (compilers) - Wikipedia, accessed January 24, 2026, https://en.wikipedia.org/wiki/Bootstrapping_(compilers)
  15. Goodbye to the C++ Implementation of Zig - Zig Programming Language, accessed January 24, 2026, https://ziglang.org/news/goodbye-cpp/