从源代码到 ZIR:Zig 编译器前端深度解析
前置知识
- ›第 1 篇:Zig 编译器架构
- ›了解 tokenization 和递归下降解析的基本概念
从源代码到 ZIR:Zig 编译器前端深度解析
在第 1 篇中我们提到,Zig 编译器的前端代码位于 lib/std/zig/,而非 src/。这并非随意为之,而是一个经过深思熟虑的设计决策。正因如此,zig fmt、Zig Language Server(ZLS)以及其他工具才能共享同一套 tokenizer、parser 和 AstGen 代码,而无需引入 Sema、代码生成或链接器。本文将带你逐步追踪原始源码字节如何转变为 ZIR(Zig Intermediate Representation)—— 那个最终送入语义分析的扁平指令流。
为什么前端代码放在 lib/std/zig/
编译器前端依次产出三种产物:token 流、AST 和 ZIR。这三种表示都在标准库中定义并生成:
flowchart LR
subgraph "lib/std/zig/ (shared)"
TOK["tokenizer.zig"] --> PARSE["Parse.zig"]
PARSE --> AST["Ast.zig"]
AST --> AG["AstGen.zig"]
AG --> ZIR["Zir.zig"]
end
subgraph "Tools"
FMT["zig fmt"]
ZLS["ZLS"]
AC["zig ast-check"]
end
subgraph "src/ (compiler only)"
SEMA["Sema.zig"]
end
TOK -.->|"reuses"| FMT
AST -.->|"reuses"| FMT
AST -.->|"reuses"| ZLS
ZIR -->|"feeds into"| SEMA
这种架构让 zig fmt 能够使用编译器本身的 parser 来解析和重新渲染源码,从而保证格式的一致性。ZLS 则可以直接构建 AST 来支持 IDE 功能,完全不需要启动编译器。zig ast-check 也可以运行 AstGen 来发现错误,而无需触发 Sema。
这里有一个关键点:ZIR 之前的所有阶段都是无类型的,没有语义分析,没有类型推导,也没有 comptime 求值。正是这一特性使得这些阶段可以安全共享 —— 它们都是纯粹的语法层变换。
Tokenization:源码字节到 token 流
lib/std/zig/tokenizer.zig 中的 tokenizer 负责将原始 UTF-8 源码字节转换为 Token 值流。每个 token 的结构极为简洁:
pub const Token = struct {
tag: Tag,
loc: Loc,
pub const Loc = struct {
start: usize,
end: usize,
};
};
仅此而已 —— 一个标识 token 类型的 tag,加上一段在原始源码中的字节范围。文件顶部的 Tag 枚举涵盖了所有 Zig 关键字(通过第 12 行的 StaticStringMap 进行映射)、运算符、字面量和标点符号。
flowchart TD
SRC["Source: 'const x = 5;'"] --> T1["const → keyword_const"]
SRC --> T2["x → identifier"]
SRC --> T3["= → equal"]
SRC --> T4["5 → number_literal"]
SRC --> T5["; → semicolon"]
tokenizer 被设计为惰性的 —— 它不分配内存,也不构建列表。parser 通过调用 next() 方法逐个向前推进,每次返回一个 token。不过在完整的编译流程中,所有 token 会被预先一次性存入 MultiArrayList,以支持随机访问。这是因为后续阶段(AstGen、错误报告)需要跳转到任意 token 位置。
提示: 关键字查找使用了
std.StaticStringMap,这是一个编译期生成的完美哈希表,关键字检测的时间复杂度为 O(1),且不存在任何运行时哈希开销。
Parsing:token 流到 AST
lib/std/zig/Parse.zig 中的 parser 是一个经典的递归下降解析器,它消费 token 流并生成定义于 lib/std/zig/Ast.zig 的 AST。
Zig 的 AST 表示方式颇为独特。它并不采用堆分配的带指针树节点,而是使用一种扁平的 multi-array-list:
pub const NodeList = std.MultiArrayList(Node);
// Each node has:
// tag: Node.Tag — what kind of syntax node
// main_token: u32 — index into the token list
// data: Data — two u32 fields (lhs/rhs or extra indices)
这种结构将所有节点存储在一块连续数组中,tag、main_token 和 data 作为并行数组存放(即 MultiArrayList 布局)。节点的子节点通过整数索引而非指针引用。对于拥有超过两个子节点的节点,data 字段会存储一个指向独立 extra_data: []u32 数组的索引。
flowchart TD
subgraph "Flat AST Layout"
TAGS["tags: [fn_decl, block, return, ...]"]
TOKENS["tokens: [0, 5, 8, ...]"]
DATA["data: [{lhs:1, rhs:2}, ...]"]
EXTRA["extra_data: [3, 7, 9, ...]"]
end
DATA -->|"overflow"| EXTRA
parser 本身通过少数几个字段维护状态:一个 gpa allocator、源码字节、tokens 切片,以及当前 token 索引 tok_i。parseContainerDeclaration、parseExpr、parseStatement 等函数直接对应语法规则。
parser 具备错误恢复能力。它不会在遇到第一个语法错误时就中止,而是将错误记录到 errors: std.ArrayList(AstError) 中,然后尝试继续解析。这对 IDE 支持至关重要 —— 在 IDE 场景下,不完整或格式有误的文件才是常态。
AstGen:AST 到 ZIR
AstGen 负责将树形的 AST 压平为 ZIR —— 一个线性指令流。这一阶段位于 lib/std/zig/AstGen.zig,也是前端中最复杂的部分。
AstGen 结构体在 lowering 过程中持有大量状态:
gpa: Allocator,
tree: *const Ast,
nodes_need_rl: *const AstRlAnnotate.RlNeededSet,
instructions: std.MultiArrayList(Zir.Inst) = .{},
extra: ArrayList(u32) = .empty,
string_bytes: ArrayList(u8) = .empty,
source_offset: u32 = 0,
source_line: u32 = 0,
source_column: u32 = 0,
source_offset、source_line 和 source_column 三个字段组成一个游标,用于在整个 lowering 过程中追踪源文件中的当前位置。这个游标在整个 lowering 过程中持续维护,从而避免了 O(N²) 的行扫描 —— 对大型文件来说,这是一个不容小觑的优化。
AstGen 引入了几个在 AST 层面并不存在的核心概念:
-
Result locations:Zig 的 result location 语义(即一个表达式"知道"将结果写入何处)通过
nodes_need_rl注解集编码进 ZIR。 -
作用域追踪:AstGen 负责管理词法作用域,追踪哪些名称在当前作用域内有效,以及
break/continue/return的跳转目标如何解析。 -
Comptime 注解:
comptime块和表达式会在 ZIR 中被标记,以便 Sema 知道要在编译期对其求值。 -
源码哈希计算:AstGen 会计算声明体的增量哈希值并存储在 ZIR 中,供后续的增量编译系统使用。
flowchart TD
AST["AST Node: fn_decl"] --> SCOPE["Push function scope"]
SCOPE --> PARAMS["Generate ZIR for parameters"]
PARAMS --> BODY["Generate ZIR for body"]
BODY --> RET["Handle return type / result location"]
RET --> POP["Pop function scope"]
POP --> ZIR["ZIR instructions appended"]
ZIR 数据结构:Instructions、Extra 与 String Bytes
ZIR 的内存布局是前端流水线的最终产物。它定义于 lib/std/zig/Zir.zig,由三块并行数据存储构成:
instructions: std.MultiArrayList(Inst).Slice,
string_bytes: []u8,
extra: []u32,
instructions 是核心数组。每个 Inst 包含一个 tag(enum(u8) 类型,用于标识指令种类)和一个 data: u32 载荷。tag 决定了 data 的解释方式 —— 它可能是一个直接操作数、指向 extra 的索引,或是对另一条指令的引用。
extra 是一个变长附属存储。当一条指令携带的数据超过单个 u32 所能容纳的范围时,它会在 data 字段中存储一个指向 extra 的索引,由 extra 保存额外字段。例如,一条函数调用指令会将被调用者存储在 data 字段中,而将参数列表存储在 extra 中。
string_bytes 则是所有字符串数据的池,包括标识符、字符串字面量和错误信息。指令通过索引引用这个数组中的字符串。
flowchart LR
subgraph "ZIR Memory Layout"
direction TB
I["instructions\ntag: [alloc, load, call, ...]\ndata: [0, 3, 42, ...]"]
E["extra\n[arg0_ref, arg1_ref, ret_type, ...]"]
S["string_bytes\n['m','a','i','n',0,'x',0,...]"]
end
I -->|"data index"| E
I -->|"string index"| S
E -->|"string index"| S
当 ZIR 被缓存到磁盘时,会在开头附加一个 Header,其中记录了三个数组的长度以及文件元数据(inode、大小、mtime),用于缓存失效判断。
这种扁平表示在性能上有显著优势。与树形结构不同,ZIR 可以被 Sema 顺序处理,具有极佳的缓存局部性。所有引用都是 u32 索引,不存在指针追踪。整个文件的 ZIR 也可以作为一块连续数据序列化到磁盘或从磁盘反序列化。
提示: ZIR 是自包含的。一旦生成,它就包含了 Sema 所需的一切 —— 不再需要回头引用 AST、token 列表或源码字节。文件头部的注释对此有明确说明:"machine code, without any memory access into the AST tree token list, node list, or source bytes。"
触发 AstGen:从文件更新到 ZIR
前端与编译器的连接点在 src/Zcu/PerThread.zig。updateFile() 函数负责协调单个源文件的完整前端流水线:
sequenceDiagram
participant Comp as Compilation
participant PT as PerThread
participant FS as FileSystem
participant FE as Frontend (lib/std/zig/)
Comp->>PT: updateFile(file_index, file)
PT->>FS: stat + open source file
PT->>FE: tokenize (source → tokens)
FE-->>PT: Token list
PT->>FE: parse (tokens → AST)
FE-->>PT: Ast
PT->>FE: AstGen (AST → ZIR)
FE-->>PT: Zir
PT->>PT: Cache ZIR to disk
updateFile 首先检查文件的 stat,判断源码自上次编译以来是否发生了变化。如果文件未变且已有缓存的 ZIR,则可以跳过整个前端流水线。这是增量编译的第一层 —— 在 Sema 介入之前,未更改的文件直接复用其缓存的 ZIR。
当 AstGen 确实需要执行时,生成的 ZIR 会被同时缓存到 Zcu 上的 global_zir_cache 和 local_zir_cache 目录中。在后续编译时,缓存检查发生在 tokenization 之前,因此未更改的文件在前端阶段的开销为零。
下一步
至此,我们已经完整追踪了源码字节经过 tokenization、parsing 和 AstGen 最终生成 ZIR 的全过程。在第 3 篇中,我们将跨越 lib/std/zig/ 与 src/ 之间的边界,深入探索 Sema —— 这个多达 37K 行的编译器核心,它负责将无类型的 ZIR 转换为完全类型化的 AIR。我们还将深入剖析 InternPool,那个以 u32 索引存储整个编译过程中所有类型和值的通用仓库。