Read OSS

从源代码到 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)

这种结构将所有节点存储在一块连续数组中,tagmain_tokendata 作为并行数组存放(即 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_iparseContainerDeclarationparseExprparseStatement 等函数直接对应语法规则。

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_offsetsource_linesource_column 三个字段组成一个游标,用于在整个 lowering 过程中追踪源文件中的当前位置。这个游标在整个 lowering 过程中持续维护,从而避免了 O(N²) 的行扫描 —— 对大型文件来说,这是一个不容小觑的优化。

AstGen 引入了几个在 AST 层面并不存在的核心概念:

  1. Result locations:Zig 的 result location 语义(即一个表达式"知道"将结果写入何处)通过 nodes_need_rl 注解集编码进 ZIR。

  2. 作用域追踪:AstGen 负责管理词法作用域,追踪哪些名称在当前作用域内有效,以及 break/continue/return 的跳转目标如何解析。

  3. Comptime 注解comptime 块和表达式会在 ZIR 中被标记,以便 Sema 知道要在编译期对其求值。

  4. 源码哈希计算: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 包含一个 tagenum(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.zigupdateFile() 函数负责协调单个源文件的完整前端流水线:

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_cachelocal_zir_cache 目录中。在后续编译时,缓存检查发生在 tokenization 之前,因此未更改的文件在前端阶段的开销为零。

下一步

至此,我们已经完整追踪了源码字节经过 tokenization、parsing 和 AstGen 最终生成 ZIR 的全过程。在第 3 篇中,我们将跨越 lib/std/zig/src/ 之间的边界,深入探索 Sema —— 这个多达 37K 行的编译器核心,它负责将无类型的 ZIR 转换为完全类型化的 AIR。我们还将深入剖析 InternPool,那个以 u32 索引存储整个编译过程中所有类型和值的通用仓库。