Zig AstGen:AST => ZIR
原文由 Mitchell Hashimoto 于 发布,订阅该博客
本文是Zig 编译器内部原理系列的一部分。
构建完抽象语法树(AST)后,许多编译器的下一步是生成中间表示(IR)。AST 是树状结构,而 IR 通常会开始为程序的各个代码块(文件、函数等)生成一串指令。这种指令序列的格式更容易进行分析、优化并转换为可执行机器码。
Zig 编译器有多种中间表示。最先创建的第一种 IR 形态被称为 Zig 中间表示(ZIR)。AST 通过一个内部称为“AstGen”的阶段直接转换为 ZIR。ZIR 是一种无类型的 IR 形态,会为每个 Zig 文件生成一份。
ZIR 长什么样?
在深入了解 ZIR 的内部结构及其生成过程之前,我们先快速看一个 ZIR 的例子。现在还不需要理解其中的任何内容。
下面是一个非常小的 Zig 文件:
const result = 42;export fn hello() u8 { return result;}该文件生成的 ZIR 如下:
%0 = extended(struct_decl(parent, Auto, { [24] result line(0) hash(193c9ec04fd3f33e5e849a85f0c3b7fd): %1 = block_inline({ %2 = int(42) %3 = break_inline(%1, %2) }) node_offset:1:1 [32] export hello line(2) hash(322ad1340dd6faf97c80f152e26dfdd4): %4 = block_inline({ %11 = func(ret_ty={ %5 = break_inline(%11, @Ref.u8_type) }, body={ %6 = dbg_stmt(2, 5) %7 = extended(ret_type()) node_offset:4:5 %8 = decl_val("result") token_offset:4:12 %9 = as_node(%7, %8) node_offset:4:12 %10 = ret_node(%9) node_offset:4:5 }) (lbrace=1:22,rbrace=3:1) node_offset:3:8 %12 = break_inline(%4, %11) }) node_offset:3:8}, {}, {})注意:ZIR 并非稳定的格式,因此上面的输出可能与当今 Zig 编译器的实际输出不完全一致。不过,作为初次了解 ZIR 的示例,已经足够接近。
关于 ZIR,最主要的一点是程序的实际逻辑开始被拆解为更细粒度的指令。ZIR 指令可以通过其索引来引用,在上面的输出中显示为 % 符号后的数字。例如,指令 9 将对 result 的引用转换为 hello 函数的返回类型。
想看更多 ZIR?如果你从源码编译了 Zig,可以使用 zig ast-check -t <file> 命令来导出任意 Zig 文件的 ZIR。编写简单的 Zig 代码并导出其 ZIR,是学习 AstGen 阶段的好方法。本页不会介绍从源码编译 Zig 的步骤。
为什么 ZIR 是无类型的?
ZIR 是一种无类型的中间格式。这并不是说 ZIR 对类型一无所知,而是指类型尚未被完全求值。Zig 的一个语言特性是将类型作为一等值,并通过编译期求值(comptime 求值)来实现泛型。因此,类型在 AST 和 ZIR 阶段仍可能是未知的。
通过示例 ZIR 输出最容易理解这一点。首先来看一个基本有类型的例子:
const result: u32 = 42;%0 = extended(struct_decl(parent, Auto, { [10] a line(0) hash(63c8310741c228c128abf6692d07292d): %1 = block_inline({ %2 = int(42) %3 = as_node(@Ref.u32_type, %2) node_offset:1:16 %4 = break_inline(%1, %3) }) node_offset:1:1}, {}, {})指令 %3 将无类型的 42 值转换为 u32。在这个小例子中,ZIR 看似已完全有类型,但实际上仍存在无类型的指令。指令 %2 是一个“无类型”的常量 42。说它无类型,是因为常量 42 仍可被强制转换为 u8、u16、u32 等。只有当代码将这个无类型常量赋值给一个有类型的常量时,ZIR 才会发出类型强制转换指令 as_node。
接下来,再看一个明显无类型的例子:
const t = bool;const result: t = 42;%0 = extended(struct_decl(parent, Auto, { [16] t line(0) hash(243086356f9a6b0669ba4a7bb4a990e4): %1 = block_inline({ %2 = break_inline(%1, @Ref.bool_type) }) node_offset:1:1 [24] result line(1) hash(8c4cc7d2e9f1310630b3af7c4e9162bd): %3 = block_inline({ %4 = decl_val("t") token_offset:2:10 %5 = as_node(@Ref.type_type, %4) node_offset:2:10 %6 = int(42) %7 = as_node(%5, %6) node_offset:2:14 %8 = break_inline(%3, %7) }) node_offset:2:1}, {}, {})在这个场景中,result 的类型是常量 t。在这个例子里,你可能会说常量 t 的值是显而易见的,但 Zig 允许 t 的赋值是任意 comptime 表达式(只要所有值在编译期已知,几乎整个语言都可用作 comptime 表达式)。这是 Zig 一个非常强大的特性。
考虑到这种动态可能性,可以看到为 result 赋值生成的 ZIR 要复杂得多。指令 %4 和 %5 加载由 t 标识的值,并将其强制转换为 type 类型(一种表示类型而非值的类型)。随后指令 %7 仍是熟悉的 as_node 强制转换,但此时的类型操作数引用的是一条 ZIR 指令 %5 的结果,而非静态值。
作为最后,一个更极端的例子:
const std = @import("std");const result: std.ArrayListUnmanaged(u8) = .{};这里不再展示它的 ZIR,因为内容非常庞大。result 的类型是通过对函数 ArrayListUnmanaged 进行 comptime 求值而生成的泛型类型。在 comptime 求值完成之前,ZIR 无法指向 result 的某个预定义类型,因为它根本还未知。
这正是 ZIR 无类型的原因。ZIR 是为 comptime 求值和后续语义分析准备的中间形态。在 comptime 阶段之后,所有类型都将确定,进而可以形成完全有类型的中间表示(这被称为 AIR,是 AstGen 之后阶段的产物)。
AstGen 的结构
“AstGen”是将 AST 转换为 ZIR 的阶段。AstGen 的源码位于 src/AstGen.zig。AstGen 并非公开导出的结构体,该结构体仅用于管理 ZIR 生成过程中的内部状态。外部调用者调用 generate 函数,该函数接收一棵 AST 并返回整棵树的 ZIR。
AstGen 结构体有许多用于内部状态的字段。我们不会逐一介绍,但下面展示了一些重要的字段。下列结构体的字段展示顺序与源码中的顺序并不一致:
const Astgen = struct { gpa: Allocator, arena: Allocator, tree: *const Ast, instructions: std.MultiArrayList(Zir.Inst) = .{}, extra: ArrayListUnmanaged(u32) = .{}, string_bytes: ArrayListUnmanaged(u8) = .{}, // other fields, not covered...};第一组 gpa、arena 和 tree 是 AstGen 过程的输入。gpa 用于分配在生成过程结束后仍需存活的数据。arena 用于分配仅在 ZIR 生成期间使用的临时数据,这些数据会在将 ZIR 返回给调用者之前被释放。而 tree 则是正被转换为 ZIR 的 AST。
为什么叫“arena”(竞技场)?“arena 分配器”是一类内存分配器,它会一次性分配和释放整块内存区域,而不是逐个跟踪和释放单个对象。它常用于生命周期相同的一组分配,因为一次性释放整块内存比逐个跟踪要简单得多、性能也更高。关于分配器和 Zig 的更多基础知识,可观看演讲“What’s a Memory Allocator Anyways?”
第二组 instructions、extra 和 string_bytes 是 AstGen 过程的输出。这一组非常重要,因为它是生成结果 ZIR 的核心结构:
instructions是指令列表。列表中的每一项对应一条指令。例如,回顾本页前面的 ZIR 输出,列表中的第 9 项就是as_node(%7, %8)指令。extra是 ZIR 指令可能需要存储的额外数据。这与 AST 节点及其extra_data字段所遵循的模式相同。如果你还不了解 AST 的extra_data,现在正是回顾的好时机,因为该模式在 AstGen 中无处不在!string_bytes是用于标识符、字符串字面量、文档注释等的字符串驻留池。所有静态字符串都会在 AstGen 过程中被驻留,ZIR 指令通过引用该字符串字节列表中的偏移量来指向字符串,而不是自行存储字符串的副本。
ZIR 指令的结构
在深入讲解将 AST 转换为 ZIR 的具体操作之前,我会花相当大的篇幅来描述单条 ZIR 指令的格式。建议仔细阅读并理解本节,因为这是理解 ZIR 构建方式的基础构件。
ZIR 本质上就是一个指令列表。指令的格式与 AST 节点所使用的模式有很强的相似性,因此本页可能会略过一些结构细节。在这些情况下,我会特别指出其中的相似之处。
pub const Inst = struct { tag: Tag, data: Data, pub const Tag = enum(u8) { add, addwrap, // many more... }; pub const Data = union { // various fields that can be set depending on tag };};这与 AST 节点非常相似。“tag”模式与 AST 节点相同,但标签集合完全不同。例如,整数 字面量在 AST 中无论大小都是 .integer_literal,而在 ZIR 中则会根据整数是否能放入 u64 转换为 .int 或 .int_big。
每条指令都关联有数据。数据联合体中生效的字段取决于 tag 的值。数据可以直接存储在 data 字段内(如整数 字面量的值),也可能是对存储在别处信息的引用。
与 AST 节点一样,ZIR 也有一个 extra 字段。其行为与 AST 节点的 extra_data 字段基本相同:某些数据项可能指向 extra 字段中的一个条目,从该位置开始的若干字段将包含与该 ZIR 指令相关的特定值。更多细节,除了下面的示例外,还可参阅 Zig Parser 相关页面。
静态值
最简单的 ZIR 指令是那些包含静态值的指令。我们来看一个简单静态整数值及其生成的 ZIR。
const x = 42;%0 = extended(struct_decl(parent, Auto, { [7] x line(0) hash(5b108956afe84d38689dcd3f2d652f04): %1 = block_inline({ %2 = int(42) %3 = break_inline(%1, %2) }) node_offset:1:1}, {}, {})相关的指令是 %2:int(42)。如你所见,静态值 42 被直接编码到指令本身中。在内部,其表示如下所示:
Zir.Inst{ .tag = .int, .data = .{ .int = 42, },}这是 ZIR 指令最简单的形态:数据直接内嵌在指令中。在此例中,数据的生效标签是 .int,它内嵌了静态整数值。
更常见的情况是,数据是通过引用存储的,或标签包含更复杂、无法直接放入 Inst 结构体的信息。接下来将展示这方面的例子。
引用
许多 ZIR 指令都包含对值的引用。例如,一元 ! 运算符(布尔非)可以前缀于任何表达式,如静态值 !true、变量 !myVar、函数调用 !myFunc() 等。由于引用极为常见,理解 ZIR 如何编码引用非常重要。
ZIR 引用有一个专门的类型 Zir.Inst.Ref。这是一个非穷尽枚举。已定义的标签用于表示原始类型或非常常见的值。除此之外,值则表示对另一条 ZIR 指令索引的引用。
带标签的引用
我们通过一个例子来看看其工作原理:
const x = !true;这会生成如下 ZIR:
%0 = extended(struct_decl(parent, Auto, { [7] result line(0) hash(0be0bb45d9cb29941abcc19d4176dde6): %1 = block_inline({ %2 = bool_not(@Ref.bool_true) node_offset:1:16 %3 = break_inline(%1, %2) }) node_offset:1:1}, {}, {})看指令 %2。它包含我们的 bool_not 指令(即 ! 运算符),参数为 @Ref.bool_true。bool_not 指令接受单个操作数。在内部,它是一个大致如下结构的 Inst(为示例省略了一些不重要的字段):
Zir.Inst{ .tag = .bool_not, .data = .{ .un_node = .{ .operand = Ref.bool_true }, },}Ref.bool_true 是 Ref 枚举中代表静态值 true 的标签之一。还有很多类似的标签。例如,每种内置类型都有一个对应的标签。虽然下面的例子没有实际意义,但它确实会生成合法的 ZIR(编译器稍后会对其报错):
const x = !u8; // ZIR: bool_not(@Ref.u8_type)对于广为人知的原始类型和值,会使用 Ref 的带标签值。
指向指令的引用
对于那些非常见的值,Ref 表示的就是指令索引。下面是另一个例子及其生成的对应 ZIR。
const input = true;const x = !input;%0 = extended(struct_decl(parent, Auto, { [13] input line(0) hash(1af5eb6ed7d8836d8d54ff390bb38c7d): %1 = block_inline({ %2 = break_inline(%1, @Ref.bool_true) }) node_offset:1:1 [21] result line(1) hash(4ba2a2df9e05a2d7963b6c1eb80fdcea): %3 = block_inline({ %4 = decl_val("input") token_offset:2:17 %5 = as_node(@Ref.bool_type, %4) node_offset:2:17 %6 = bool_not(%5) node_offset:2:16 %7 = break_inline(%3, %6) }) node_offset:2:1}, {}, {})观察引用的关键指令是 %6。我们再次看到熟悉的 bool_not 指令,但这次的操作数是对另一条指令的引用:%5。如果继续追踪指令链,你应该能直观地理解它是在以布尔值的形式读取 input 的值。
在内部,该指令看起来大致如下:
Zir.Inst{ .tag = .bool_not, .data = .{ .un_node = .{ .operand = Ref.typed_value_map.len + 5 }, },}或者更简单地说:即所有带标签值的总数加上 5。同样,要判断一个 Ref 值是标签还是索引,可以检查该值是否大于标签的数量。如果是,则该值减去标签数量即为指令索引。
这种操作非常常见,因此有两个公开函数来处理:indexToRef 和 refToIndex。在上面的例子中,我们将操作数设为 indexToRef(5),如果你调用 refToIndex(operand),就会得到 5。
const ref_start_index: u32 = Inst.Ref.typed_value_map.len;pub fn indexToRef(inst: Inst.Index) Inst.Ref { return @intToEnum(Inst.Ref, ref_start_index + inst);}pub fn refToIndex(inst: Inst.Ref) ?Inst.Index { const ref_int = @enumToInt(inst); if (ref_int >= ref_start_index) { return ref_int - ref_start_index; } else { return null; }}额外数据
一些 ZIR 指令包含对 extra 字段的引用,该字段有时也被称为“尾随”数据。这与 AST 节点中的 extra_data 字段非常相似,因此这里不再详细介绍其编解码方式。如果你对 AST 节点中的 extra_data 字段还不太了解,建议先回顾相关章节。
我们来看一个额外数据的例子:
const x = 1 + 2;%0 = extended(struct_decl(parent, Auto, { [10] x line(0) hash(48fa081b63af0a1c7f2d11a7bf9fbbc3): %1 = block_inline({ %2 = int(2) %3 = add(@Ref.one, %2) node_offset:1:13 %4 = break_inline(%1, %3) }) node_offset:1:1}, {}, {})这段输出的 ZIR 综合运用了我们上面学到的所有内容!其中有静态值(指令 %2)、带标签的引用(指令 %3 中的 @Ref.one),以及对另一条指令的引用(指令 %3 中的 %2 操作数)。
二元加法操作使用了 extra。在 ZIR 文本输出中这一点并不明显,因为 ZIR 渲染器能够理解 add 指令并会美化打印来自 extra 的数据(指令 %3)。在内部,它看起来像这样:
// InstructionZir.Inst{ .tag = .add, .data = .{ .pl_node = .{ .payload_index = 7 }, },}// Exra data[ ..., Ref.one, %2, ... ]该指令使用了 pl_node(即“payload node”)数据标签。其中包含一个字段 payload_index,它指向 extra 数组中存储附加数据的起始位置。查看 .add 标签的注释可知,存储在 extra 字段中的结构是 Zir.Inst.Bin,它包含 lhs 和 rhs。在此例中 lhs = Ref.one,rhs = %2。
与 AST 节点类似,AstGen 源码中包含一个辅助函数 addExtra,它以类型安全的方式编码结构化的额外数据。该模式在 Zig Parser 页面中关于 AST 节点的章节有更详细的描述。
额外数据本身可能包含静态值、其他 Zig.Inst.Ref 值等。要了解每个标签具体编码了哪些值,需要查看对应标签的源码。
更多数据类型
还有许多其他数据类型,但这里不会一一详尽介绍。我认为上面介绍的几种数据类型对于理解编码 ZIR 指令信息的通用模式尤为重要。
AstGen 的组成部分
AstGen 过程在构建 ZIR 时会使用一些通用、共享的组件。这些组件代表了通用或共享的逻辑,对于理解 AstGen 的完整行为至关重要。这些组件中的大多数都是 AstGen 中每个函数的参数。
这并非 AstGen 所有特性的完整清单,只是突出介绍几个关键项。
作用域
AstGen 引入了作用域感知,使得引用“父”作用域中的标识符、在使用未定义标识符时记录错误,或检测标识符遮蔽成为可能。
作用域通过 AstGen.zig 中的 Scope 结构体来定义。作用域是多态的,可以是多种子类型之一。这在 Zig 中通过 @fieldParentPtr 的常见模式实现。由于这是 Zig 中的通用模式,本文不再赘述。
在撰写本文时,共有七种作用域“类型”:
Scope.Top- 代表文件的最外层作用域。这始终是最顶层的父作用域,没有父作用域。该作用域不跟踪任何额外数据。GenZir- 该结构体通常表示 Zig 中的一个“块”,但也被用于在不同 AST 节点间跟踪大量额外状态。它跟踪当前块标签(如果有)、指令列表、是否处于 comptime 位置等。Scope.Namespace- 该作用域包含一组可被引用的无序声明。“无序”是这里的关键词,该作用域通常是块内的子作用域,用于结构体、联合体等。例如,结构体中的变量可以引用文件中稍后定义的变量,而函数体则不能。前者是Namespace,后者则不是。Scope.LocalVal和Scope.LocalPtr- 已定义的单个标识符(如变量或常量)。每当定义一个标识符,就会创建一个该类型的新子作用域,使其进入“作用域内”。这与Namespace不同,因为它只代表单一声明,而不是无序列表。Scope.Defer- 围绕defer或errdefer的作用域。用于跟踪 defer 的存在,以便在所有退出点生成相应指令。
作用域有一个 parent 字段,可用于向上遍历各级作用域。标识符解析正是通过这种方式实现的。
字符串驻留
字符串值(字节数组)不会直接存储在 ZIR 指令中。字符串会被驻留并存储在单一连续的 string_bytes 数组中。ZIR 指令中存储的是指向字符串起始位置的索引。这意味着共享的字符串只会被存储一次(如标识符)。
ZIR 是整个流水线中首个实际存储字符串的结构。AST 节点存储的是 token 索引,而 token 存储的是其在源代码中的起止偏移量。这要求 AST 和源码必须保持可用。在 AstGen 之后,AST 和源码即可被释放。这使得解析极大的 Zig 程序并仅在内存中保留所需内容成为可能。
结果位置
ResultLoc 结构体用于跟踪表达式树的最终结果应该写入何处。在遍历 AST 并生成 ZIR 的过程中,某个写入操作的值可能会深层嵌套,AstGen 需要知道最终值应写入哪里。
考虑以下示例:
const x = blk1: { switch (someUnion) { .someTag => break :blk1 42, else => break :blk1 0, }}在最外层作用域中,我们要写入常量 x。如果将 AST 树可视化,我们需要先进入一个块,再进入 switch,然后是 switch 分支,最后是带标签的 break 语句。在至少递归四层函数之后,ResultLoc 让 AstGen 知道带标签 break 的值应该写到哪里。
ResultLoc 是一个带标签的联合体,包含多种可能的结果类型。它的注释非常详尽,建议直接阅读源码。这里仅解释其中几个示例标签:
.discard- 表示赋值被丢弃,因为它是丢弃标识符_右侧的值。在这种情况下,AstGen 知道无需生成任何存储指令,可以直接丢弃该值。.ty- 表示赋值给某个有类型的值。这会在返回结果值之前生成一个as_node来进行强制转换。.ptr- 表示赋值要写入某个内存位置。这会生成一条store指令,将结果写入该内存位置。
生成 ZIR
现在我们来了解 AstGen 如何将 AST 转换为 ZIR。抽象地说,AST 通过遍历树并为每个节点发出指令来转换为 ZIR。AstGen 会一次性将整个 AST 急切地转换为 ZIR(而非惰性求值——惰性求值的模式会在编译器的后续阶段出现)。
重要的是,AstGen 引入了第一层重要的语义校验。例如,AstGen 会校验标识符是否已定义、是否遮蔽了外层作用域,并知道如何遍历父作用域。回想一下,之前的 AST 构建引入的是结构校验:它确保像 x pub == 7 这样的 token 序列会触发语法错误,但不会校验标识符 x 是否已定义。而词法分析器本身只执行按 token 校验:它会校验 72 是合法 token,而 72a 不是(标识符不能以数字开头)。
学习 ZIR 如何生成的最容易的切入点是查看 expr 函数。该函数为 Zig 语言中任何合法表达式生成 ZIR。我发现从语言最简单的组成部分入手,再逐步构建会最容易理解,因为 IR 生成是一个深度递归的过程。
整数 字面量
我们从一个简单的静态整数值开始:
42它会被解析为 AST 节点标签 .integer_literal,并通过巨大的 switch 从 expr 跳转到 integerLiteral 函数。integerLiteral 函数复现如下:
fn integerLiteral(gz: *GenZir, rl: ResultLoc, node: Ast.Node.Index) InnerError!Zir.Inst.Ref { const tree = astgen.tree; const main_tokens = tree.nodes.items(.main_token); const int_token = main_tokens[node]; const prefixed_bytes = tree.tokenSlice(int_token); if (std.fmt.parseInt(u64, prefixed_bytes, 0)) |small_int| { const result: Zir.Inst.Ref = switch (small_int) { 0 => .zero, 1 => .one, else => try gz.addInt(small_int), }; return rvalue(gz, rl, result, node); } else |err| switch (err) { error.InvalidCharacter => unreachable, // Caught by the parser. error.Overflow => {}, } // ... other paths}这里内容很多!这正是为什么我要从最简单的东西开始。如果一开始就讲函数声明之类,就会陷入过多细节,让人难以消化。即使是看整数 字面量,你可能仍会感到有些不知所措,但这已经是最简单的了,所以我们来深入看看。
首先,我们通过 AST 查找整数 字面量对应的 token。然后,利用 token 的起始位置,通过 tree.tokenSlice 获取与该 token 关联的字节。这会得到字符串 "42",更具体地说是两个字节 4 和 2。
接着,我们尝试将该字符数组解析为无符号 64 位整数。如果数字过大,就会落入“other paths”注释所指的其他路径,那里涉及存储“大整数”的复杂逻辑,本文不再探讨。在本例中,我们假设所有整数常量都小于或等于 18,446,744,073,709,551,615(无符号 64 位整数的最大值)。
无符号?那负数怎么办?负数前面的 - 前缀会作为独立的 AST 节点存储,并在 ZIR 中单独生成一个一元操作。整数 字面量始终为正数。
在我们的例子中,数字解析会成功,因为 42 可以解析为合法的 u64。接下来,我们处理值为 0 或 1 的特殊情况,因为它们有专门的带标签引用。否则,我们会生成一条 .int 的 ZIR 指令,并将指令索引存入 result。
最后,我们调用 rvalue,它会应用前面介绍过的 ResultLoc 语义来决定是按原样返回值、将其转换为已知类型、存入内存位置等。在许多情况下,这只是一个空操作,会直接返回 .int 这条 ZIR 指令。
加法
我们在静态整数值的基础上,来做一次加法:
42 + 1它被解析为 AST 节点标签 .add,并从 expr 跳转到 simpleBinOp。
fn simpleBinOp( gz: *GenZir, scope: *Scope, rl: ResultLoc, node: Ast.Node.Index, op_inst_tag: Zir.Inst.Tag,) InnerError!Zir.Inst.Ref { const astgen = gz.astgen; const tree = astgen.tree; const node_datas = tree.nodes.items(.data); const result = try gz.addPlNode(op_inst_tag, node, Zir.Inst.Bin{ .lhs = try reachableExpr(gz, scope, .none, node_datas[node].lhs, node), .rhs = try reachableExpr(gz, scope, .none, node_datas[node].rhs, node), }); return rvalue(gz, rl, result, node);}这是我们遇到的第一个递归例子。它构建了一条 .add 的 ZIR 指令,其 lhs 和 rhs 数据分别通过递归地为左右表达式构建 ZIR 来填充。左表达式是 42,右表达式是 1。通过对整数 字面量的分析可知,它们都会变成 .int 指令。
生成的 ZIR 看起来大致如下:
%1 = int(42)%2 = add(%1, @Ref.one)赋值
接下来,我们将这个加法结果赋给一个无类型的常量:
const x = 42 + 1;你不会在 expr 中找到处理这种情况的分支,因为这不是一个表达式,而是一条语句。你也不会找到 statement 函数,因为在 Zig 中变量赋值只能出现在容器(结构体)或块(即函数体)中。你会在 containerMembers 或 blockExprStmts 中找到相关逻辑。前者用于结构体主体,后者用于函数体。我认为先看 blockExprStmts 更容易一些,尽管后者也同样合理,但两者作为下一步的学习内容都是合适的。
我们来看 blockExprStmts,其中有一个 switch 会为变量声明(包括常量)跳转到 varDecl。varDecl 很大!下面不会贴出完整函数,只贴出最常见的代码路径:
const type_node = var_decl.ast.type_node;const result_loc: ResultLoc = if (type_node != 0) .{ .ty = try typeExpr(gz, scope, type_node),} else .none;const init_inst = try reachableExpr(gz, scope, result_loc, var_decl.ast.init_node, node);const sub_scope = try block_arena.create(Scope.LocalVal);sub_scope.* = .{ .parent = scope, .gen_zir = gz, .name = ident_name, .inst = init_inst, .token_src = name_token, .id_cat = .@"local constant",};return &sub_scope.base;我们从顶部开始。这一段只是递归地求值类型表达式(如果有的话)和初始化表达式。对于常量 const x: t = init,t 是类型表达式,init 是初始化表达式。在我们的例子中,没有类型表达式,初始化表达式就是我们已经知道如何构建的 .add 指令。
接下来,我们创建一个新的 LocalVal 作用域来表示这个值。该作用域包含标识符名称 x、值指令 init_inst,并指向传给 varDecl 函数的父作用域。该作用域是此函数的返回值,调用者 blockExprStmts 会从该语句开始将当前作用域替换为这个新作用域,以便后续语句和表达式可以引用此次赋值。
我们之前的例子返回的是 ZIR 指令索引,而这次返回的是一个作用域。注意,对 x 的命名赋值并不会生成 ZIR 指令。如果你查看生成的 ZIR,根本找不到 x:
%2 = int(42)%3 = add(%2, @Ref.one)x 存在于作用域中,作用域正在跟踪值指令 inst。如果 x 在某处被引用,我们就知道它的值来自那条指令,并可以相应地引用它。例如,我们来看一下在函数体中以下代码的 ZIR:
const x = 42 + 1;const y = x;%2 = int(42)%3 = add(%2, @Ref.one) // x assignment%4 = ensure_result_non_error(%3) // y assignment注意,y 的赋值(指令 %4)直接来自指令 %3。在 ZIR 中我们不需要显式的内存加载/存储,因为可以直接引用指令的结果。
请注意,当最终生成机器码时,后端可能会根据计算机架构判断需要进行加载/存储,但中间表示无需做出这一决策。
留给读者的练习:作为下一步,建议去研究语句 const y = x,了解标识符引用是如何工作的。这将解释上面 ZIR 是如何生成的,也是迈向更复杂内容的一个很好的垫脚石。
完成 AstGen 过程
AstGen 过程会针对每个文件运行一次,并递归地构建整个文件的 ZIR。在函数结束时,调用者会收到一个 Zir 值。
掌握本页的细节后,你应该能够跟踪任意 Zig 语言结构并了解其 ZIR 是如何生成的。请记住,要经常使用 zig ast-check 命令来查看 Zig 编译器实际生成的内容,并以此作为研究相关函数的指南。
下一步是Sema 过程。
随机一篇博客
评论
登录后参与讨论