Zig Sema: ZIR => AIR

Mitchell Hashimoto

Zig Sema: ZIR => AIR

本文是 Zig 编译器内部机制系列的一部分。

“AstGen”之后的下一个编译器阶段是“Sema”。Sema 负责接收 AstGen 阶段输出的 ZIR 并生成 AIR。AIR 全称“Analyzed Intermediate Representation(已分析中间表示)”,是一种完全带类型的中间表示,而 ZIR 则是无类型的中间表示。AIR 随后可以直接被降低为机器代码。

正如 AstGen 页面所述,ZIR 之所以无类型,原因之一是:要为 Zig 程序完全确定类型,需要进行 comptime 求值,才能使泛型类型(以及其他东西)完全实例化。因此,Sema 还负责执行 Zig 程序的所有 comptime 求值。魔法就是在这里发生的!

AIR 是按函数生成的,而不是像 ZIR 或 AST 那样按文件生成。本页将重点介绍如何将函数体从 ZIR 转换为 AIR。未来的页面将讨论更大的编译过程如何调用 Sema。

注意:确实有一些 AIR 是在文件作用域生成的,所以说 AIR 是按函数生成的并不 100% 准确。不过,要理解文件作用域的 AIR 生成过程,还需要更深入地讨论编译过程,这一点将留待未来的文章。本页将作为通往那一理解的重要基石。

AIR 长什么样?

在深入探讨 AIR 的内部结构和构建方式之前,我们可以先看一个 AIR 示例。下面是一个简单的 Zig 程序及其生成的 AIR:

export fn add(a: u32, b: u32) u32 {
    return a + b;
}
# Begin Function AIR: add:
  %0 = arg("a", u32)
  %1 = arg("b", u32)
  %2!= dbg_stmt(2:5)
  %3 = add(%0!, %1!)
  %4!= ret(%3!)
# End Function AIR: add

你可以运行 zig build-obj --verbose-air <file.zig> 来查看任何程序的 AIR。这需要 Zig 编译器的 debug 构建版本。Zig wiki 中有一份关于如何从源码编译 Zig 编译器的指南。

AIR 是按函数生成和呈现的。在上面的输出中,可以看到以注释形式标注的 add 函数的 AIR 输出。另外请注意,AIR 只为被导出或被引用的函数生成(它是惰性生成的)。因此,出于调试目的,我通常会把想查看 AIR 的函数 export 出来。

如果你一直在阅读编译器的前几个阶段,你会注意到 AIR 的呈现形式与 ZIR 非常相似。虽然它们相似,甚至在很多情况下共享指令标签名,但 AIR 是一个完全独立的中间表示。

%1 是指令的索引。当其后跟有 ! 时,表示该指令未被 Zig 程序中任何其他已知部分使用或引用。在上面的例子中,%0%1 都被加法指令用来构造 %3,而 %3 又被返回指令使用。但调试语句 %2 和返回结果 %4 是未使用的。将 AIR 转换为最终格式的后端可以在有帮助时利用这一信息。

注意:正如引言所述,有一些 AIR 是在文件作用域而不是函数作用域生成的。例如,文件作用域的变量初始化、comptime 块等。目前无法呈现这类 AIR。不过,文件作用域的 AIR 通常只是一系列 constant 指令,因为它总是在 comptime 中求值。

AIR 的结构剖析

在研究 ZIR 如何被转换为 AIR 之前,我先描述一下 AIR 的格式以及单条 AIR 指令。理解 AIR 的结构会让理解 AIR 的构建容易得多。

AIR 的结构与 ZIR 非常相似,但有一些细微差别:

pub const AIR = struct {
    instructions: std.MultiArrayList(Inst).Slice,
    extra: []const u32,
    values: []const Value,
};

与 ZIR 一样,AIR 本质上是一系列存储在 instructions 字段中的指令。与 ZIR 和我们的 AST 类似,指令可以在 extra 切片中存储指令特有的附加数据。这两个字段的行为与 ZIR 和 AST 完全相同;如果你此刻还不能直观地理解它们,我建议回头复习那些结构(尤其是深入讲解 extra 数据如何填充的 AST 结构)。

注意:这里我也跳过了对 MultiArrayList 的解释,因为它与 ZIR 和 AST 中使用的模式完全相同,并且已在解析器探索中详细解释过。

AIR 中新出现的字段是 values 切片。它包含 ZIR 在 comptime 执行中得到的已知值。指令可以引用 comptime 已知的值。例如,如果文件的根作用域有 const a = 42;,那么值 42 就会被存储在 values 列表中,因为它是 comptime 已知的。稍后我们会看到更多 comptime 值的例子。

请注意,像字符串常量的字符串表之类的字段并不存在。AIR 的构建过程以及将来使用 AIR 的 Codegen 过程仍然可以访问 ZIR,以便在 ZIR 的字符串表中查找数据。

单条 AIR 指令的结构剖析

单条 AIR 指令的结构是 Inst 结构体。

pub const Inst = struct {
    tag: Tag,
    data: Data,
};

这个结构体在结构上与 ZIR 的 Inst 结构体完全相同。有一个作为枚举的 tag,然后是特定于该标签的 data,它是可能数据类型的联合类型。AIR 的 TagData 类型与 ZIR 的类型是不同的,但功能上完全一致。

我们来看一个基本例子。当语义分析确定一个值是常量时,它会创建 .constant 指令。带有 constant 标签的指令的数据是 ty_pl 字段,它包含常量的类型,而 payload 是指向 values 数组中 comptime 已知值的索引。

const c = 42;
%0 = constant(comptime_int, 42)
Inst{
    .tag = .constant,
    .data = .{
        .ty_pl = . {
            .ty = Type.initTag(.comptime_int),
            .pl = 7, // index into values
        },
    },
};

上面的例子展示了 Zig 代码、呈现出的 AIR 以及内部指令表示。注意,c 并未出现在任何地方,因为用于生成此 AIR 的 ZIR 只针对赋值的右侧(rhs):常量 42

Value、Type、TypedValue

Sema 中有三类非常频繁使用的类型:ValueTypeTypedValueValue 表示一个 comptime 已知的值,例如整数、结构体等。Type 是一个 comptime 已知的类型,例如 u8(注意:所有类型都是 comptime 已知的)。TypedValue 则是带有精确已知类型的值:即把值 42 与类型 u16 配对。

有一点可能令人困惑:在 Zig 中,类型本身也是合法的值。一个类型可以是一个类型为“type”的值。例如,在 Zig 中,你可以写 const c = u8c 是类型“type”、值为“u8”。下面我们会展示大量这样的例子,让这一点变得更直观。

Value

Value 结构体如下所示:

pub const Value = extern union {
    tag_if_small_enough: Tag,
    ptr_otherwise: *Payload,
}

值有一个 tag 来描述它属于哪种值。我在这里特意用“kind(种类)”而不是“type(类型)”,因为值本身是无类型的,尽管在某些情况下类型可以被轻易得知。某些 tag 值没有 payload,而另一些则需要 payload。ptr_otherwise 字段是指向 payload 的指针,其中包含获取该值所需的更多信息。

Payload 类型是指向更具体的 payload 类型中 Payload 类型字段的指针。这是 Zig 中使用多态类型的一种方式。随后使用 @fieldParentPtr 内建函数来确定完整类型。这是 Zig 中常见的模式,更详细的解释超出了本页的范围。请搜索关于 @fieldParentPtr 的指南和 Zig 资料来理解其工作原理。

整数

我们来看看整数值。常量 42 用下面的 Value 表示:

Value{
    .ptr_otherwise = &Payload.U64{
        .tag = .int_u64,
        .data = 42,
    },
};

注意:这个值并不完全正确。ptr_otherwise 字段指向的是一个 Payload,而不是完整的 Payload.U64 结构体。不过,上面的写法更清楚地表达了意图,所以我会通篇使用这种格式。

可以看到,值 42int_u64 标志表示。这是因为 int_u64 用来表示所有能装进 u64 的值。它并不意味着 42u64 类型——它仍可能是 u8u16 等。没有 TypeValue 就是无类型的。或者,如果“我们其实有点类型”这一点让你困扰,你可以认为值是并非完全带类型的

类型值(不是 TypedValue)

在 Zig 中,类型也可以是值。例如,语句 const c = u8 是完全合法的 Zig:你在把类型 u8 赋给常量 c,而 c 本身的类型是 type(它是一个持有类型的值)。这可能非常令人困惑,而且随着类型值被用于实际的语义分析,困惑还会加剧,所以在这里提前解释。

常量 u8 作为一个值,用下面的 Value 表示:

Value{
    .tag_if_small_enough = .u8_type,
};

这个值没有 payload。该值完全由标签 u8_type 表示。这个值是 u8 类型。但值本身仍是无类型的。不过,在这种情况下类型可以被轻易得知,因为 u8_type 唯一合法的类型就是 type。但 Value 结构体本身在技术上仍然是无类型的(例如,将来 Zig 可能引入关键字 inttype 来表示所有整数类型,那么该值的类型就既可以是 inttype 也可以type)。

我们来看一个更复杂的类型值:const c = [4]bool

Value{
    .ptr_otherwise = &Payload.Ty{
        .tag = .ty,
        .data = Type{
            // .tag = .array
            // type data including element type (bool), length (4), etc.
        },
    },
};

这个值的标签是 .ty(“type”的缩写)。它是一个代表某个类型的值。payload 是描述一个包含 4 个布尔元素的数组的 Type 结构。我们将在下一节更详细地描述 Type 结构。这里的重点是:Value 表示的是一个数组类型值,而不是一个数组

如果仍然有些困惑,可以看看为 Type 定义的 toValue 函数。Type 总是能转换为值,因为类型可以是值,但反过来并不总是成立。

Type

Type 结构体与 Value 等价,只是使用了类型专属的类型:

pub const Type = extern union {
    tag_if_small_enough: Tag,
    ptr_otherwise: *Payload,
}

这里没有太多要补充的,因为它的行为与 Value 几乎完全相同。我们来看 [4]bool 是如何表示的,这样至少有一个具体的例子:

Type{
    .ptr_otherwise = &Payload.Array{
        .tag = .array,
        .data = .{
            .len = 4,
            .elem_type = Type{.tag_if_small_enough = .bool},
        },
    }
}

TypedValue

TypedValue 就是把 TypeValue 放在一起,使值与精确的类型信息配对。只有把两者结合在一起,值的精确类型才是已知的。

pub const TypedValue = struct {
    ty: Type,
    val: Value,
};

Sema 的结构剖析

Zig 编译器在“AstGen”之后的下一个阶段通常被称为“Sema”。Sema 也是主要负责这一阶段的结构体。Sema 的源码位于 src/Sema.zig。它是一个非常庞大的文件(在撰写本文时超过 18,000 行),并且自称是“Zig 编译器的心脏”。

Sema 有许多公开 API,其中最重要的是 analyzeBodySema 结构体有许多用于内部状态的字段。我们不会逐一介绍,但下面展示了其中一些主要的字段。为了便于解释相近的字段,这些字段并未按源码中的顺序展示。

pub const Sema = struct {
    mod: *Module,
    gpa: Allocator,
    arena: Allocator,
    perm_arena: Allocator,

    code: Zir,
    owner_decl: *Decl,
    func: ?*Module.Fn,
    fn_ret_ty: Type,

    air_instructions: std.MultiArrayList(Air.Inst) = .{},
    air_extra: std.ArrayListUnmanaged(u32) = .{},
    air_values: std.ArrayListUnmanaged(Value) = .{},
    inst_map: InstMap = .{},

    // other fields...
};

第一组是 Sema 过程的主要必需输入。gpa 用于分配生命周期超出 Sema 过程和声明生命周期的数据。arena 用于分配在 Sema 结束后即被释放的临时数据。perm_arena 用于分配与正在进行语义分析的声明生命周期绑定的数据。

mod 是正在被分析的模块。本页不涉及模块,但模块封装了单个程序中的所有 Zig 代码。本页将忽略所有模块 API。

第二组是描述 Sema 正在对什么进行语义分析的输入。code 是包含被分析 decl 的文件的 ZIR。owner_decl 通常是当前正在被分析的声明,例如函数、comptime 块、test 等。funcfn_ret_ty 是在分析函数时的额外信息。

第三组是 Sema 过程的输出。你可能注意到,这些大多是构成 Air 结构的字段。它们在 Sema 过程中被填充,并用于构建最终的 Air 结果。

inst_map 字段尤为重要,并在整个 Sema 中被使用。这是一个从 ZIR 到 AIR 的映射。并非所有 ZIR 指令都会产生 AIR 指令,但这个映射被频繁使用,使 AIR 指令能够引用稍后才被解析的特定 ZIR 指令(例如加法操作数、函数参数等)。

分析函数体

Sema 中的核心函数是 analyzeBody。它用于分析一个“body”的 ZIR——函数体、循环体、块体等——并为该 body 生成 AIR。最简单的 body 是函数体。首先,我们来看一个非常简单的函数及其生成的 AIR:

export fn add() u32 {
    return 40 + 2;
}
# Begin Function AIR: add:
  %1 = constant(comptime_int, 40)
  %2 = constant(comptime_int, 2)
  %3 = constant(comptime_int, 42)
  %4 = constant(u32, 42)

  %0!= dbg_stmt(2:5)
  %5!= ret(%4!)
# End Function AIR: add

提醒:我们可以通过导出函数并执行 zig build-obj --verbose-air example.zig 来转储函数的 AIR。

即使不了解 AIR 指令的具体细节,你也应该能弄清楚发生了什么。大致的步骤流程如下:

  1. 我们看到无类型的常量整数 40(指令 %1)
  2. 我们看到无类型的常量整数 2(指令 %2)
  3. 我们在 comptime 中执行加法,得知结果是无类型的常量 42(指令 %3)。
  4. 常量 42 与类型 u32 配对(指令 %4)。由于值 42 能自动装进类型 u32,无需任何转换。这个类型标注是为了匹配返回类型 u32 所必需的。
  5. 我们返回在 %4 中产生的值(指令 %5)。

很巧妙!这里有一些冗余,但可以清楚地看到它如何实现我们的 add 函数。此外,你可以看到 comptime 求值已经完成,加法也在这一阶段完成了,因此结果已经预先得知。当这些代码最终被翻译成机器代码时,实际的加法结果早已预先计算好了。

接下来,让我们一步步看看这段 AIR 是如何生成的。

逐步走查函数

AIR 生成的主循环位于 analyzeBodyInner(由 analyzeBody 调用)。它按顺序遍历 ZIR 指令,并为每条 ZIR 指令生成零条或多条 AIR 指令。

const result = while (true) {
    const inst = body[i];
    const air_inst: Air.Inst.Ref = switch (tags[inst]) {
        .alloc                        => try sema.zirAlloc(block, inst),
        .alloc_inferred               => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_const)),
        .alloc_inferred_mut           => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_mut)),
        .alloc_inferred_comptime      => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_const)),
        .alloc_inferred_comptime_mut  => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_mut)),
        .alloc_mut                    => try sema.zirAllocMut(block, inst),
        // hundreds more...
    };

    try sema.inst_map.put(sema.gpa, inst, air_inst);
    i += 1;
}

这使得理解 AIR 如何从给定的 ZIR 生成变得非常容易。你可以转储 ZIR,然后一条指令一条指令地查看 AIR 是如何生成的。我们上面示例 add 函数的 ZIR 如下:

%0 = extended(struct_decl(parent, Auto, {
  [25] export add line(6) hash(4ca8d4e33898374bdeee80480f698dad): %1 = block_inline({
    %10 = func(ret_ty={
      %2 = break_inline(%10, @Ref.u32_type)
    }, body={
      %3 = dbg_stmt(2, 5)
      %4 = extended(ret_type()) node_offset:8:5
      %5 = int(40)
      %6 = int(2)
      %7 = add(%5, %6) node_offset:8:15
      %8 = as_node(%4, %7) node_offset:8:15
      %9 = ret_node(%8) node_offset:8:5
    }) (lbrace=1:21,rbrace=3:1) node_offset:7:8
    %11 = break_inline(%1, %10)
  }) node_offset:7:8
}, {}, {})

函数从 ZIR 指令 %3 开始,到 %9 结束。%3analyzeBodyInner 在分析 add 函数体时看到的第一条指令。更早(以及更晚)的 ZIR 指令,如 %2%10,是在更早的时候分析的;那个过程稍后会讨论。

%3: dbg_stmt

第一条指令是 .dbg_stmt。在主循环中,我们可以看到它导向 zirDbgStmt。下面以略微简化的形式复现了该函数:

fn zirDbgStmt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!void {
    const inst_data = sema.code.instructions.items(.data)[inst].dbg_stmt;
    _ = try block.addInst(.{
        .tag = .dbg_stmt,
        .data = .{ .dbg_stmt = .{
            .line = inst_data.line,
            .column = inst_data.column,
        } },
    });
}

这是一个从 ZIR 翻译出 AIR 指令的简单而典型的例子。没有比这更简单的了。在这个例子中,dbg_stmt ZIR 几乎被原封不动地翻译为一条 dbg_stmt AIR 指令。这就是前面展示的 %0 AIR 指令:

%0!= dbg_stmt(2:5)

%4: extended(ret_type())

.extended ZIR 指令调用 zirExtended,它遍历子操作码,将 .ret_type 映射到 zirRetType。下面复现了该函数:

fn zirRetType(
    sema: *Sema,
    block: *Block,
    extended: Zir.Inst.Extended.InstData,
) CompileError!Air.Inst.Ref {
    const src: LazySrcLoc = .{ .node_offset = @bitCast(i32, extended.operand) };
    try sema.requireFunctionBlock(block, src);
    return sema.addType(sema.fn_ret_ty);
}

被分析函数的返回类型可以在 Semafn_ret_ty 字段中获得。addType 函数会为类型定义添加一条指令。对我们的函数来说,结果类型是 u32,这是一个众所周知(well-known)的类型,不会生成额外的指令。

作为实验,如果你把返回值改为 u9——一个非 well-known 类型——AIR 将生成如下指令:

%5 = const_ty(u9)

%5: int(40)

接下来是常量 40.int 指令。它在主分析体循环中导向 zirInt 函数调用。zirInt 函数读取整数值并调用 addConstant。两个函数如下所示。

fn zirInt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.Inst.Ref {
    const int = sema.code.instructions.items(.data)[inst].int;
    return sema.addConstant(ty, try Value.Tag.int_u64.create(sema.arena, int));
}
pub fn addConstant(sema: *Sema, ty: Type, val: Value) SemaError!Air.Inst.Ref {
    const gpa = sema.gpa;
    const ty_inst = try sema.addType(ty);
    try sema.air_values.append(gpa, val);
    try sema.air_instructions.append(gpa, .{
        .tag = .constant,
        .data = .{ .ty_pl = .{
            .ty = ty_inst,
            .payload = @intCast(u32, sema.air_values.items.len - 1),
        } },
    });
    return Air.indexToRef(@intCast(u32, sema.air_instructions.len - 1));
}

addConstant 函数在 Sema 的许多地方被调用,用于添加一个 comptime 已知的值。它首先使用 addType 添加类型,也就是我们在上一条指令中看到的。在这里,类型是 comptime_int,这是一个 well-known 类型,不会产生新指令。接下来,值被添加到 air_values 中,最后 constant AIR 指令被添加到 air_instructions 中。这就产生了如下 AIR 指令:

%1 = constant(comptime_int, 40)

需要注意的一点是,.constant 指令的 payload 引用的是 air_values 切片中的索引。所有 comptime 已知的值都存储在 air_values 中,而指令中的任何引用都以指向 air_values 切片的索引形式存储。

我们跳过 %6,因为它与 %5 完全相同,只是常量为 2

%7: add(%5, %6)

接下来,我们遇到第一个真正的逻辑运算:加法。.add 指令导向 zirArithmetic 函数。这个函数用于许多二元数学运算。函数如下所示:

fn zirArithmetic(
    sema: *Sema,
    block: *Block,
    inst: Zir.Inst.Index,
    zir_tag: Zir.Inst.Tag,
) CompileError!Air.Inst.Ref {
    const inst_data = sema.code.instructions.items(.data)[inst].pl_node;
    sema.src = .{ .node_offset_bin_op = inst_data.src_node };
    const lhs_src: LazySrcLoc = .{ .node_offset_bin_lhs = inst_data.src_node };
    const rhs_src: LazySrcLoc = .{ .node_offset_bin_rhs = inst_data.src_node };
    const extra = sema.code.extraData(Zir.Inst.Bin, inst_data.payload_index).data;
    const lhs = sema.resolveInst(extra.lhs);
    const rhs = sema.resolveInst(extra.rhs);

    return sema.analyzeArithmetic(block, zir_tag, lhs, rhs, sema.src, lhs_src, rhs_src);
}

顶部所有的常量变量赋值都是在解码 ZIR 指令。其中特别重要的是 lhsrhsresolveInst 函数用于查找给定 ZIR 指令对应的 AIR 指令索引。因此,给定 %5%6 这两条 ZIR 指令,lhsrhs 就被分别设置为它们对应的 AIR 索引。这些 AIR 指令是在之前的循环迭代中创建 .constant 指令时设置的。

ZIR ⇒ AIR 的映射保存在哪里?ZIR 到 AIR 的映射由 analyzeBodyInner 循环维护在 inst_map 字段中。还有其他几处可以更新它,但 body 循环是主要位置。

接下来进入 analyzeArithmetic。这是一个很长的函数,几乎所有的行都在判断它能否对这个算术运算进行 comptime 分析。关键行如下:

const maybe_lhs_val = try sema.resolveMaybeUndefVal(block, lhs_src, casted_lhs);
const maybe_rhs_val = try sema.resolveMaybeUndefVal(block, rhs_src, casted_rhs);

if (maybe_lhs_val) |lhs_val| {
    if (maybe_rhs_val) |rhs_val| {
        if (is_int) {
            return sema.addConstant(
                scalar_type,
                try lhs_val.intAdd(rhs_val, sema.arena),
            );
        }
    }
}

return block.addBinOp(.add, casted_lhs, casted_rhs);

它首先调用 resolveMaybeUndefVal。该函数接收一个 AIR 指令索引,并尝试加载其 comptime 已知的值。它返回一个可选值(optional),因为如果该值无法在 comptime 中得知,返回值将为 null

接下来,我们尝试解包这些可选值。如果我们能够为 lhsrhs 都找到 comptime 已知的值,并且它们都是整数类型,那么我们就可以进行 comptime 加法来产生最终常量。我们的程序正是走了这条路径,产生了结果为 42.constant AIR 指令:

%3 = constant(comptime_int, 42)

我保留了 fallthrough 分支,以防这些值不是 comptime 已知的:block.addBinOp。这会为运行时计算(而非 comptime)添加一条 .add AIR 指令。可以实验一下:把常量 2 改成变量,例如 var b: u32 = 2,并在加法中使用它。这将产生一个 .add 操作,因为变量无法在 comptime 中参与运算。

%8: as_node(%4, %7)

下一条指令实现安全的类型强转。从 ZIR 可以看出,这是把加法结果转换为返回类型。具体到我们已经知道的内容:必须把 comptime int 42 转换为 u32

.as_node 指令调用 zirAsNode,最终进入 coerce 中的核心逻辑。coerce 函数在整个 Sema 中被用于执行从一种类型到另一种类型的安全类型强转。

研究这个函数就留给读者了。其逻辑相当直白,但很冗长,因为它必须处理许多类型强转的情况。对于 comptime_intu32,它判定该值能装进 u32,于是原样返回该值。类型转换不需要额外的处理。

%9: ret_node(%8)

最后,return 指令在 ZIR 中被编码为 ret_node。它导向 zirRetNode,后者创建一条带有结果的 .ret AIR 指令。这条指令的创建没有任何新东西。

zirRetNode 函数返回 always_noreturn。这个值会强制 analyzeBodyInner 循环退出,完成函数体的 AIR 生成。

return 总是完成函数体的 AIR 生成?那多个 return 语句呢?一个 return 总是完成当前块的 AIR 生成。不可达代码是非法的,会在 AstGen 阶段被捕获,这意味着多个 return 语句只可能出现在不同的块中。因此,多个 return 语句在函数上下文中是没问题的,因为它会递归进入多个 analyzeBodyInner 调用。

Comptime 不可用的情况

我们看的第一个例子有点无聊,因为所有逻辑都是 comptime 可行的。Comptime 只对 const 值可用,对 var 值不可用,所以我们可以用一个 var 来看看运行时加法的 AIR:

export fn add() u32 {
    var b: u32 = 2;
    return 40 + b;
}
# Begin Function AIR: add:
  %2 = constant(comptime_int, 2)
  %3 = constant(u32, 2)
  %6 = constant(comptime_int, 40)
  %8 = constant(u32, 40)

  %0!= dbg_stmt(2:5)
  %1 = alloc(*u32)
  %4!= store(%1, %3!)
  %5!= dbg_stmt(3:5)
  %7 = load(u32, %1!)
  %9 = add(%8!, %7!)
  %10!= ret(%9!)
# End Function AIR: add

把常量 2 改成赋值为 2 的变量,会产生多得多的 AIR!我们现在有了用 .alloc 进行的分配、用 .store 进行的值存储,还能看到一个运行时的 .add 操作。

这是一个很好的学习和跟踪 AIR 生成过程的程序。我把它作为练习留给读者,因为它遵循的代码路径与我们之前的例子大体相同。

Comptime 目标平台模拟

对于 comptime 求值的代码,Zig 编译器在必要时会模拟目标平台的特性。这是一个关键特性,使 comptime 求值在 Zig 中安全且可行。

@floatCast 的实现中可以看到这样一个例子:

const target = sema.mod.getTarget();
const src_bits = operand_ty.floatBits(target);
const dst_bits = dest_ty.floatBits(target);
if (dst_bits >= src_bits) {
    return sema.coerce(block, dest_ty, operand, operand_src);
}

return block.addTyOp(.fptrunc, dest_ty, operand);

该函数通过 getTarget 获取目标平台的信息,然后确定浮点数在目标平台上支持的位数。如果目标类型的位数多于源类型,就可以安全地进行强转。否则,浮点数将被截断。

完成 Sema 过程

Sema 按函数调用一次。这是第一个按函数而非按文件运行的过程。Sema 并不会遍历每一个声明,而是只对每个被引用的声明调用一次。为了确定所有被引用的声明,Sema 从进程入口函数开始。

这实现了 Zig 的“惰性分析(lazy analysis)”。这意味着某些错误只有在声明被引用时才会产生编译错误。这使得编译过程极其快速,并且由于只编译被引用的代码,生成的 codegen 也更小,但它有时会导致令人困惑的行为。

掌握了本页讲解的基础知识后,你应该能够跟踪任何 Zig 程序,弄清它是如何被翻译为 AIR 的。请记得经常使用 zig ast-checkzig build-obj --verbose-air 命令来查看 Zig 编译器正在生成什么。

之后,AIR 会被交给“codegen”过程,将其降低为最终格式。Codegen 是共享的编译器“前端”与多个“后端”之间的边界。后端可以是 LLVM,也可以是诸如 WASM 之类的原生后端。

原文由 Mitchell Hashimoto 发布

本文章由 stealth/ox-alpha 进行翻译