Read OSS

语义分析与 InternPool:编译器的核心

高级

前置知识

  • 第 1 篇:Zig 编译器的整体架构
  • 第 2 篇:从源代码到 ZIR
  • 对类型系统和类型推导概念有基本了解
  • 熟悉 Zig 中的 comptime 求值机制

语义分析与 InternPool:编译器的核心

Sema(语义分析的缩写)是 ZIR 真正"活起来"的阶段。在这里,编译器完成类型解析、comptime 表达式求值、类型检查,并最终产出 AIR(Analyzed IR)。src/Sema.zig 约有 37,000 行,是整个编译器中最大的单一文件,文件开头的注释也直言不讳:"This is the heart of the Zig compiler."

但 Sema 并非单打独斗。它依赖 InternPool —— 一个统一存储所有类型与值的仓库,二者均以 u32 索引表示。将类型与值统一到同一个 interning pool 中,是 Zig 编译器最具特色的架构决策之一。

Sema 的职责与核心状态

Sema 将无类型的 ZIR 转换为带类型的 AIR。它以函数(或 comptime 块)为粒度实例化,生存于栈上。以下是 src/Sema.zig#L41-L77 中的核心字段:

pt: Zcu.PerThread,           // thread-safe Zcu access
gpa: Allocator,              // general purpose allocator
arena: Allocator,            // temporary arena, cleared on Sema destroy
code: Zir,                   // input ZIR
air_instructions: std.MultiArrayList(Air.Inst) = .{},  // output AIR
air_extra: std.ArrayList(u32) = .empty,
inst_map: InstMap = .{},     // maps ZIR indices → AIR indices
owner: AnalUnit,             // the root entity being analyzed
func_index: InternPool.Index,// current function being analyzed
fn_ret_ty: Type,             // return type of current function
branch_quota: u32 = default_branch_quota,
branch_count: u32 = 0,
classDiagram
    class Sema {
        +pt: PerThread
        +code: Zir
        +air_instructions: MultiArrayList
        +inst_map: InstMap
        +owner: AnalUnit
        +func_index: Index
        +fn_ret_ty: Type
        +branch_quota: u32
        +analyzeBodyInner()
    }
    class Zir {
        +instructions: Slice
        +extra: []u32
        +string_bytes: []u8
    }
    class Air {
        +instructions: Slice
        +extra: ArrayList
    }
    Sema --> Zir : reads
    Sema --> Air : writes

inst_map 是其中的关键 —— 它负责将 ZIR 指令索引映射到对应的 AIR 指令引用。当一条 ZIR 指令引用另一条指令的结果时(例如 %5 引用 %3 的结果),Sema 就通过 inst_map 找到对应的 AIR 等价物。

owner: AnalUnit 标识了当前正在分析的对象。在整个 Sema 的生命周期内,它始终不变 —— 即便内联函数调用触发了对另一个函数体的分析,owner 也不会改变。

analyzeBodyInner 分发循环

Sema 的核心是 analyzeBodyInner,即从 src/Sema.zig#L1154 开始的主分发循环。它逐一遍历 ZIR 指令,并将每条指令派发给对应的处理函数:

flowchart TD
    LOOP["analyzeBodyInner loop"] --> READ["Read ZIR instruction tag"]
    READ --> SWITCH["inst: switch(tags[inst])"]
    SWITCH -->|".alloc"| A["zirAlloc()"]
    SWITCH -->|".bit_and"| B["zirBitwise()"]
    SWITCH -->|".bitcast"| C["zirBitcast()"]
    SWITCH -->|".call"| D["zirCall()"]
    SWITCH -->|".cmp_lt"| E["zirCmp()"]
    SWITCH -->|"..."| F["200+ other handlers"]
    A --> AIR["Produce AIR instruction"]
    B --> AIR
    C --> AIR
    D --> AIR
    E --> AIR
    AIR --> NEXT["i += 1; continue loop"]

分发逻辑使用了 Zig 的带标签 switch 模式(inst: switch (tags[...])),允许某些处理函数通过 continue :inst 在不递增索引的情况下重新分发。这一机制用于控制流转换场景,一条 ZIR 指令可能需要经历多次处理。

每个处理函数都遵循统一的模式:

  1. 从 ZIR 指令中提取操作数(通常通过 extraData
  2. 解析操作数类型(查找 inst_map 中的条目)
  3. 执行类型检查和类型强制转换
  4. 若可在编译期求值,则直接计算结果
  5. 否则,生成一条 AIR 指令

处理函数的命名遵循统一约定:zirAlloczirBitwisezirCall 等 —— 统一以 zir 为前缀,并以其处理的 ZIR 指令标签命名。快速搜索 fn zir 可以找到 200 多个处理函数。

提示: 调试语义分析问题时,先找到对应的 ZIR 指令标签(可通过 zig dump-zir 导出 ZIR),再在 Sema.zig 中搜索对应的 zir* 处理函数,是最高效的定位方式。

InternPool:类型与值的统一存储

src/InternPool.zig 中的 InternPool 是编译器中最重要的数据结构之一。其开头注释言简意赅:"All interned objects have both a value and a type. This data structure is self-contained."

核心设计理念:类型与值是同一种东西。二者都是指向 InternPool 的 u32 索引。类型 u32 是一个索引,值 42 是一个索引,类型 *const u8 也是一个索引。这种统一设计极大地简化了整个编译器 —— 只需一套查找机制、一套 interning 机制、一种相等性比较方式(直接比较索引即可)。

该 pool 采用分片(sharding)设计以支持并发访问:

locals: []Local,   // one per thread, indexed by tid
shards: []Shard,   // power-of-two count for concurrent writers

每个 Local 拥有独立的分配 arena,Shard 数组通过加锁支持多线程同时进行 interning。tid_shift_30tid_shift_31tid_shift_32 字段缓存了位移量,将线程 ID 嵌入索引的高位,从而保证各线程生成的索引全局唯一,无需额外协调。

预 intern 的类型

Index 枚举 的开头预定义了一批常用类型,这些类型无需任何查找即可直接使用:

pub const Index = enum(u32) {
    u0_type, i0_type, u1_type,
    u8_type, i8_type, u16_type, i16_type,
    u32_type, i32_type, u64_type, i64_type,
    // ... many more ...
    bool_type, void_type, type_type,
    anyerror_type, comptime_int_type, noreturn_type,
    // ...
};

这些类型在编译期已知,直接嵌入枚举中。判断一个类型是否为 bool 只需一次整数比较:index == .bool_type,无需哈希查找,无需间接跳转。

Type 与 Value:InternPool.Index 的薄封装

为了提供更符合人体工程学的 API,编译器将 InternPool.Index 封装为两个 newtype 结构体:

src/Type.zig:

ip_index: InternPool.Index,

pub fn zigTypeTag(ty: Type, zcu: *const Zcu) std.builtin.TypeId {
    return zcu.intern_pool.zigTypeTag(ty.toIntern());
}

src/Value.zig:

ip_index: InternPool.Index,

两者的大小恰好都是一个 u32。它们从不将数据从 pool 中复制出来,而是通过 InternPool 提供属性查询方法。这对性能至关重要:传递一个 Type 等同于传递一个整数,两个类型相等当且仅当它们的索引相等。

classDiagram
    class InternPool {
        +locals: []Local
        +shards: []Shard
        +Index: enum(u32)
        +zigTypeTag(Index) TypeId
        +typeOf(Index) Index
        +indexToKey(Index) Key
    }
    class Type {
        +ip_index: Index
        +zigTypeTag(Zcu) TypeId
        +abiSize(Zcu) u64
    }
    class Value {
        +ip_index: Index
        +typeOf(Zcu) Type
        +isUndef(Zcu) bool
    }
    Type --> InternPool : wraps Index
    Value --> InternPool : wraps Index

提示: 阅读 Sema 代码时,经常会看到 .toIntern().fromInterned() —— 它们用于在封装类型与原始 InternPool.Index 值之间相互转换。

编译器的工作粒度由两个概念定义:Nav(Named Addressable Value,具名可寻址值)和 AnalUnit(Analysis Unit,分析单元)。

AnalUnit 是一个将 Kindid 打包在一起的 u64

pub const AnalUnit = packed struct(u64) {
    kind: Kind,
    id: u32,

    pub const Kind = enum(u32) {
        @"comptime", nav_val, nav_ty, type, func, memoized_state,
    };
};

每个 AnalUnit 代表一项语义分析工作:函数体分析、comptime 块求值、类型解析、Nav 值解析 —— 每一项都是独立的 AnalUnit。它们构成了驱动增量编译的依赖图中的节点(将在第 5 篇详细介绍)。

Nav 代表一个具名声明,其生命周期分为三个阶段:

stateDiagram-v2
    [*] --> unresolved: Declaration discovered
    unresolved --> type_resolved: Type analysis complete
    type_resolved --> fully_resolved: Value analysis complete
    fully_resolved --> [*]: Sent to linker

Nav 结构体存储了名称、完全限定名、可选的分析信息(命名空间 + ZIR 索引),以及一个在 unresolvedtype_resolvedfully_resolved 之间流转的 status union。只有完全解析且具有运行时类型的 Nav 才会被送往链接器。

这种两阶段解析(先解析类型,再解析值)非常重要:它允许编译器在代码生成开始之前完成所有类型的解析,从而打破类型解析与值解析交织时可能产生的循环依赖。

AIR:带类型的中间表示

AIR 是 Sema 的输出产物。它定义于 src/Air.zig,与 ZIR 采用相同的扁平结构 —— instructionsMultiArrayList,辅以 extra 数组 —— 但有几处关键区别:

  1. 每个引用都携带类型信息。 ZIR 只说"将这两个东西相加";AIR 则明确表达"将这两个 i32 值相加,结果为 i32"。
  2. 每个函数独享一份 AIR。 ZIR 覆盖整个文件;AIR 的作用域仅限于单个函数。
  3. comptime 已完全求值。 if (comptime_condition) 分支已被求值,死代码分支已被消除。
  4. 泛型实例化已完成。 每个具体实例化都有自己独立的 AIR。

第 38 行 中的 AIR 指令标签全部是带精确类型的操作:addadd_safeadd_optimizedadd_wrapadd_sat —— 仅加法就有五种指令,各自具有精确的语义。这与 ZIR 形成对比 —— ZIR 的指令更少、更通用。

flowchart LR
    subgraph "ZIR (untyped)"
        Z1[".add %3, %5"]
    end
    subgraph "Sema"
        S["Type check\nCoerce operands\nChoose AIR tag"]
    end
    subgraph "AIR (typed)"
        A1[".add_safe %3:i32, %5:i32 → i32"]
    end
    Z1 --> S --> A1

AIR 还携带活跃性信息(由 Air/Liveness.zig 单独计算),告知代码生成器在每条指令处哪些值仍处于活跃状态。这使得寄存器分配无需单独的活跃性分析阶段即可完成。

接下来

至此,我们已经完整梳理了从 ZIR 到 AIR 的转换过程 —— 这正是编译器的核心所在。第 4 篇将跟随 AIR 进入编译器的后端:代码生成(AIR → MIR → 机器码)与链接。我们将深入分析两阶段代码生成的设计思路、连接各后端特定表示的 AnyMir union,以及最终汇编二进制文件的自托管链接器。