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 的一個語言定義性特色是將型別作為一級值(first-class values),並以 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 求值所產生的泛型型別。ZIR 無法指向 result 的某個預定義型別,因為它要到 comptime 求值完成後才會確定。
這就是為什麼 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」?「arena allocator(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是用於識別符、字串字面量、文件註解等的字串駐留池(string interning pool)。所有靜態字串都會在 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。
每條指令都有與之關聯的資料。資料聯合(union)中實際生效的欄位取決於 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 指令都包含對值的參照。舉例來說,一元 ! 運算子(布林 NOT)可以接在任何運算式之前,例如靜態值 !true、變數 !myVar、函式呼叫 !myFunc() 等。理解 ZIR 如何編碼參照非常重要,因為它們非常常見。
ZIR 參照擁有專屬的型別 Zir.Inst.Ref。這是一個非窮舉列舉(non-exhaustive enum)。已定義的標籤用於表示原始型別或非常常見的值。否則,其值就是對另一個 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 欄位的參照,該欄位有時也被稱為「尾隨(trailing)」資料。這與 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 引入了作用域意識,使其能夠參照「父」作用域中的識別符、在使用未定義的識別符時記錄錯誤,或偵測識別符遮蔽(shadowing)的情況。
作用域是使用位於 AstGen.zig 中的 Scope 結構體來定義的。作用域是多型的,可以是多種子型別之一。這在 Zig 中是使用 @fieldParentPtr 的常見模式來實作的。由於這是 Zig 中常見的模式,因此本頁不會對此模式多加說明。
在撰寫本文時,共有七種作用域「型別」:
Scope.Top- 代表檔案的最外層作用域。這永遠是最上層的父作用域,沒有父作用域。此作用域不追蹤任何額外資料。GenZir- 此結構體大致代表 Zig 中的一個「區塊」,但也被用於跨 AST 節點追蹤許多額外狀態。它會追蹤目前的區塊標籤(若有的話)、指令列表、是否位於 comptime 位置等。Scope.Namespace- 此作用域包含一組無序的宣告,可被參照。「無序」是關鍵字,此作用域通常是區塊內用於 struct、union 等的子作用域。舉例來說,struct 變數可以參照檔案中稍後定義的變數,而函式主體則不行。前者是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 是一個帶標籤的聯合(tagged union),擁有多種可能的結果型別。它有完善的註解,因此建議閱讀原始碼。此處將說明其中幾個範例標籤:
.discard- 表示指派會被丟棄,因為它是丟棄識別符_右手邊的值。在這種情況下,AstGen 知道不需要產生任何儲存指令,我們可以直接將值丟棄。.ty- 指派是針對某個具型別的值。這會產生一個as_node來在回傳結果值之前對其進行強制轉型。.ptr- 指派正被寫入某個記憶體位置。這會產生一個store指令,使結果被寫入該記憶體位置。
產生 ZIR
現在讓我們來了解 AstGen 如何將 AST 轉為 ZIR。抽象來說,AST 是透過遍歷樹並為每個節點發出指令來轉換為 ZIR 的。AstGen 會急切地(eagerly)將整個 AST 轉換為 ZIR(它不會惰性求值——這種模式將在後續的編譯器階段中出現)。
重要的是,AstGen 引入了我們第一層重要的語意驗證。例如,AstGen 會驗證識別符是否已定義、是否遮蔽了外層作用域,並知道如何遍歷父作用域。回想一下,先前的 AST 建構引入了結構性驗證:它確保了諸如 x pub == 7 這類 token 序列會引發語法錯誤,但它並未驗證識別符 x 是否已定義。而 tokenizer 本身則僅強制執行逐一 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 parts」所指的其他路徑,那裡處理了儲存「大整數(big ints)」的複雜邏輯,我們在此不會深入探討。在此範例中,我們假設所有整數常數都小於或等於 18,446,744,073,709,551,615(無號 64 位元整數的最大值)。
無號?那負數呢?負數前方的 - 前綴會被儲存為單獨的 AST 節點,並在 ZIR 中另外產生一個一元運算。整數文字永遠是正數。
在我們的範例中,解析數字會成功,因為 42 會被解析為合法的 u64。接著,我們處理值為 0 或 1 的特殊情況,因為它們擁有特殊的帶標籤參照。否則,我們會產生一個 .int ZIR 指令,並將指令索引儲存在 result 中。
最後,我們呼叫 rvalue,它會對該值套用 ResultLoc(稍早已介紹)的語意,以決定我們是否需要按原樣回傳它、將其轉換為已知型別、將其儲存到記憶體位置等。在許多情況下,這會是一個無操作(no-op),僅僅回傳 .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 中變數指派只能在容器(container,即 struct)或區塊(也就是函式主體)內進行。你會在 containerMembers 或 blockExprStmts 中找到相關邏輯。前者用於 struct 主體,後者用於函式主體。我認為先看 blockExprStmts 會比較容易,雖然 containerMembers 也是有效的下一個學習步驟,但兩者都是。
讓我們來看看 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 流程。
隨機一篇部落格
留言
登入後參與討論