Zig Sema: ZIR => AIR

Mitchell Hashimoto

Zig Sema:ZIR => AIR

原文由 Mitchell Hashimoto 发布,订阅该博客

本文是Zig 编译器内部原理系列的一部分。

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

正如在 AstGen 一文中提到的,ZIR 无类型的原因之一在于,要完整地为 Zig 程序确定类型,需要进行编译期求值,以便实例化泛型等。因此,Sema 同时也负责执行 Zig 程序的所有编译期求值。这就是奇迹发生的地方!

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 是在文件作用域而非函数作用域生成的。例如,文件作用域的变量初始化、编译期块等。目前还无法渲染这些 AIR。不过文件作用域的 AIR 通常只是一系列 constant 指令,因为它们始终在编译期求值。

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 完全一致;如果你到现在还没有直观理解它们,建议回头复习一下相关结构(尤其是 AST 结构,那里深入讲解了 extra 数据是如何填充的)。

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

AIR 中新增的字段是 values 切片。它包含了对 ZIR 进行编译期执行后得到的已知值。指令可以引用编译期已知的值。例如,如果文件顶层有 const a = 42;,那么值 42 就会被存储在 values 列表中,因为它是编译期已知的。后面我们会看到更多编译期值的例子。

注意,这里并没有出现用于字符串常量的字符串表等字段。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 数组中编译期已知值的索引。

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 表示编译期已知的值,例如整数、结构体等。Type 是编译期已知的类型,例如 u8(注意:所有类型都是编译期已知的)。而 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。这里刻意使用“种类”而不是“类型”,因为 Value 本身是无类型的,尽管在某些情况下其类型可以被轻易推断出来。有些 tag 值没有 payload,而另一些则需要 payload。ptr_otherwise 字段是指向包含获取该值所需更多信息的 payload 的指针。

Payload 类型是指向更具体 payload 类型的 Payload 类型字段的指针。这是 Zig 中实现多态类型的一种方式。随后会使用 @fieldParentPtr 内建函数来确定完整类型。这是 Zig 中常见的模式,在此不再进一步展开。请自行搜索关于 @fieldParentPtr 的指南来了解其工作原理。

整数

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

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

注意:这个值并不完全准确。ptr_otherwise 字段指向的是 Payload,而不是完整的 Payload.U64 结构体。但是,上面的写法更能清楚地表达意图,所以下文中我都会采用这种形式。

如你所见,值 42int_u64 标志来表示。这是因为 int_u64 用于表示所有能放入 u64 范围内的值。它并不意味着 42 就是 u64 类型——它仍然可能是 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 的结构与 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 是包含正在被分析的声明的文件的 ZIR。owner_decl 通常是当前正在分析的声明,例如函数、编译期块、测试等。funcfn_ret_ty 是分析函数时的额外信息。

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

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

分析函数体

Sema 中的核心函数是 analyzeBody。它用于分析某个“体”(body)——函数体、循环体、块体等——的 ZIR,并为该体生成 AIR。最简单的体是函数体。首先,我们来看一个非常简单的函数及其生成的 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. 我们在编译期执行加法,得知结果是无类型的常量整数 42(指令 %3)。
  4. 常量 42 与类型 u32 配对(指令 %4)。由于值 42 可以自动放入 u32 类型,无需进行转换。这一步定类型是为了匹配返回类型 u32
  5. 我们返回在 %4 中产生的值(指令 %5)。

不错!这里有些冗余,但可以清楚地看到它是如何实现 add 函数的。另外,你也可以看到编译期求值已经完成,加法结果已被预先计算出来。当它最终被翻译为机器码时,实际的加法结果已经是预先算好的。

接下来,我们一步步看看这段 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;
}

这使得理解给定 ZIR 如何产生 AIR 变得非常容易。你可以转储 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 指令。这产生了前面展示的 AIR 指令 %0

%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);
}

正在分析的函数的返回类型可通过 Sema 上的 fn_ret_ty 字段获得。addType 函数会为类型定义添加一条指令。对于我们的函数,结果类型是 u32,这是一个广为人知的类型,因此不会生成额外的指令。

为了做实验,如果你将返回值改为 u9——一个非广为人知的类型——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 的许多地方被调用,用于添加编译期已知的值。它首先使用 addType 添加类型,这一点我们在上一条指令中已经见过。在这种情况下,类型是 comptime_int,这是一个广为人知的类型,不会产生新指令。接着,值被添加到 air_values 中,最后 constant 这一 AIR 指令被添加到 air_instructions 中。这产生了如下 AIR 指令:

%1 = constant(comptime_int, 40)

需要注意的是,.constant 指令的 payload 引用的是 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 指令索引。因此,给定 ZIR 指令 %5%6lhsrhs 分别被设置为它们对应的 AIR 索引。这些 AIR 指令是在之前的循环迭代中作为参数的 .constant 指令而创建的。

ZIR ⇒ AIR 的映射维护在哪里? ZIR 到 AIR 的映射由 analyzeBodyInner 循环在 inst_map 字段中维护。还有另外几处可能会更新它,但主循环是主要位置。

接下来会进入 analyzeArithmetic。这是一个很长的函数,几乎所有代码都在判断是否可以对该算术运算进行编译期分析。关键代码如下所示:

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 指令索引并尝试加载其编译期已知的值。它返回一个可选值,因为如果该值无法在编译期获知,返回值将为 null

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

%3 = constant(comptime_int, 42)

我也包含了当值在编译期未知时的回退路径:block.addBinOp。这会添加一条用于运行时计算的 .add AIR 指令(而非编译期)。要做实验:可以将常量 2 改为变量,例如 var b: u32 = 2 并用它进行加法。这将产生一个 .add 操作,因为变量无法在编译期进行运算。

%8: as_node(%4, %7)

下一条指令实现了安全的类型强制转换。观察 ZIR,这是在将加法结果转换为返回类型。更具体地说,就我们已知的而言:我们必须将编译期整数 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 语句的唯一可能是它们位于不同的块中。因此,在函数的上下文中,多个返回语句是完全正常的,因为它会递归进入多次 analyzeBodyInner 调用。

无法在编译期求值

我们之前看的第一个例子有点无趣,因为所有逻辑都可以在编译期完成。编译期求值仅对 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 如何生成的好程序。我把它作为留给读者的练习,因为它遵循了与我们之前例子中许多相同的代码路径。

编译期目标平台仿真

对于编译期求值的代码,Zig 编译器会在必要时仿真目标平台的特性。这是一项关键功能,使得 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 的“惰性分析”。这意味着,除非声明被引用,否则某些错误不会产生编译器错误。这使得编译过程极快,生成的代码也更小,因为只有被引用的代码才会被编译,但它有时也会导致一些令人困惑的行为。

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

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

本文章由 muse-spark-1.2-contributor 进行翻译

评论