语义分析与 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 指令可能需要经历多次处理。
每个处理函数都遵循统一的模式:
- 从 ZIR 指令中提取操作数(通常通过
extraData) - 解析操作数类型(查找
inst_map中的条目) - 执行类型检查和类型强制转换
- 若可在编译期求值,则直接计算结果
- 否则,生成一条 AIR 指令
处理函数的命名遵循统一约定:zirAlloc、zirBitwise、zirCall 等 —— 统一以 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_30、tid_shift_31 和 tid_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 结构体:
ip_index: InternPool.Index,
pub fn zigTypeTag(ty: Type, zcu: *const Zcu) std.builtin.TypeId {
return zcu.intern_pool.zigTypeTag(ty.toIntern());
}
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 与 AnalUnit:编译的粒度划分
编译器的工作粒度由两个概念定义:Nav(Named Addressable Value,具名可寻址值)和 AnalUnit(Analysis Unit,分析单元)。
AnalUnit 是一个将 Kind 和 id 打包在一起的 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 索引),以及一个在 unresolved → type_resolved → fully_resolved 之间流转的 status union。只有完全解析且具有运行时类型的 Nav 才会被送往链接器。
这种两阶段解析(先解析类型,再解析值)非常重要:它允许编译器在代码生成开始之前完成所有类型的解析,从而打破类型解析与值解析交织时可能产生的循环依赖。
AIR:带类型的中间表示
AIR 是 Sema 的输出产物。它定义于 src/Air.zig,与 ZIR 采用相同的扁平结构 —— instructions 为 MultiArrayList,辅以 extra 数组 —— 但有几处关键区别:
- 每个引用都携带类型信息。 ZIR 只说"将这两个东西相加";AIR 则明确表达"将这两个
i32值相加,结果为i32"。 - 每个函数独享一份 AIR。 ZIR 覆盖整个文件;AIR 的作用域仅限于单个函数。
- comptime 已完全求值。
if (comptime_condition)分支已被求值,死代码分支已被消除。 - 泛型实例化已完成。 每个具体实例化都有自己独立的 AIR。
第 38 行 中的 AIR 指令标签全部是带精确类型的操作:add、add_safe、add_optimized、add_wrap、add_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,以及最终汇编二进制文件的自托管链接器。