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 接著就能直接被降階(lower)為機器碼。

如 AstGen 那一頁所述,ZIR 之所以無型別的原因之一,是因為要完整為 Zig 程式加上型別,就必須進行 comptime 求值,才能讓泛型型別(以及其他東西)完全具體化。因此,Sema 也負責執行 Zig 程式中所有的 comptime 求值。魔法就在這裡發生!

AIR 是以每個函式為單位產生,而不是像 ZIR 或 AST 那樣以每個檔案為單位。本文將聚焦於如何把函式本體從 ZIR 轉換為 AIR。之後的文章會再談整個編譯流程是如何呼叫 Sema 的。

注意: 有一部分的 AIR 是在檔案作用域(file-scope)產生的,所以說 AIR 是按函式產生並不完全精確。不過,要理解檔案作用域的 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 又被 return 指令使用。但除錯陳述式 %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 完全相同;如果你到目前為止對它們還沒有直觀的理解,我建議回去複習那些結構(特別是 AST 的結構,那裡深入說明了 extra 資料是如何填入的)。

注意: 這裡我也省略了對 MultiArrayList 的說明,因為這是 ZIR 與 AST 中使用的完全相同模式,已經在解析器(parser)探索那篇中深入解釋過了。

AIR 中新出現的欄位是 values 切片。它存放了來自 ZIR 的 comptime 執行結果中已知的數值。指令可能會參照這些 comptime 已知的數值。舉例來說,如果你的檔案根層級有 const a = 42;,那麼數值 42 就會因為在 comptime 已知而被存放在 values 串列中。稍後我們會看到更多 comptime 數值的範例。

請注意,像字串常數用的字串表這類欄位並不存在。建構 AIR 的過程以及後續使用 AIR 的 Codegen 過程,仍然可以存取 ZIR,因此能在 ZIR 的字串表中查詢資料。

單一 AIR 指令的結構

單一 AIR 指令的結構是 Inst 結構。

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

這個結構在結構上與 ZIR 的 Inst 結構完全相同。有一個作為列舉的 tag,以及依 tag 而異的 data,它是一個包含各種可能資料型別的 union。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

數值、型別、TypedValue

在 Sema 中有三種非常頻繁使用的型別:ValueTypeTypedValueValue 代表一個 comptime 已知的數值,例如整數、struct 等。Type 是一個 comptime 已知的型別,例如 u8(注意:所有型別都是 comptime 已知的)。而 TypedValue 則是帶有精確已知型別的數值:也就是把數值 42 與型別 u16 配對在一起。

有件事可能會讓人有點困惑,那就是在 Zig 中型別本身也是合法的數值。一個型別可以是一個型別為「type」的值。舉例來說,在 Zig 中你可以寫 const c = u8c 的型別是「type」,其值為「u8」。下面我們會展示許多這類情境的範例,讓這個概念變得更直觀。

數值

Value 的結構如下所示:

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

一個數值有一個描述其種類的 tag。我在這裡刻意用「種類(kind)」而非「型別(type)」,因為 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 結構。不過,上面的寫法更能清楚表達意圖,因此我在全文都會採用這種格式。

如我們所見,數值 42 是以 int_u64 旗標來表示。這是因為 int_u64 用來表示所有能塞進 u64 的值。這並不代表 42 就是 u64 型別——它仍可能是 u8u16 等。沒有 TypeValue 是無型別的。或者,如果你覺得我們好像還是有某種型別而感到困擾,你可以把 value 想成是「並非精確具型別的」。

型別數值(而非 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 來代表所有整數型別,那麼這個值可能是 inttypetype 都算合法)。

來看一個更複雜的型別數值: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 永遠可以被轉換為一個 value,因為型別可以是一個值,但反之則不一定成立。

型別

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 同時也是主要負責此階段的結構。其原始碼位於 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 過程與宣告(declaration)生命週期之外仍需存活的資料。arena 用於分配在 Sema 結束後就會被釋放的暫時性資料。而 perm_arena 則用於分配與正在進行語意分析的宣告生命週期綁定的資料。

mod 是正在被分析的模組。模組不會在本文中詳述,但它封裝了單一程式中的所有 Zig 程式碼。在本文中我們會忽略所有模組相關的 API。

第二組是用來描述 Sema 正在進行語意分析對象的輸入。code 是包含正被分析的宣告所在檔案的 ZIR。owner_decl 通常是目前正在分析的宣告,例如函式、comptime 區塊、測試等。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 來傾印(dump)某個函式的 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;
}

這讓我們很容易理解給定的一組 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 函式本體時看到的第一條指令。而像 %2%10 這類更早(以及更晚)的 ZIR 指令則會更早被分析;該過程稍後會再討論。

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

這是一個很好的、簡單的範例,展示了 AIR 指令如何從 ZIR 轉譯而來。再也沒有比這更簡單的了。在這個例子中,dbg_stmt 的 ZIR 幾乎被一對一地轉譯為 dbg_stmt 的 AIR 指令。這就產生了先前所示的 %0 AIR 指令:

%0!= dbg_stmt(2:5)

%4: extended(ret_type())

.extended 的 ZIR 指令會呼叫 zirExtended,它會遍歷子操作碼(child opcode)以將 .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 中的許多地方被呼叫,用於新增一個 comptime 已知的數值。它會先使用 addType 加入型別,這點我們在上一條指令中已經看過。在這個例子中,型別是 comptime_int,這是一個廣為人知的型別,不會產生新的指令。接著,數值會被加入到 air_values,最後 constant 的 AIR 指令會被加入到 air_instructions。這產生了以下的 AIR 指令:

%1 = constant(comptime_int, 40)

需要注意的一點是,.constant 指令的 payload 參照的是 air_values 切片中的索引。所有 comptime 已知的數值都儲存在 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 指令。特別重要的是 lhsrhs 的賦值。resolveInst 函式用於找出給定 ZIR 指令對應的 AIR 指令索引。因此,給定 ZIR 指令 %5%6lhsrhs 會分別被設為它們對應的 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

接著,我們嘗試解開 optional。如果我們能為 lhsrhs 都找到 comptime 已知的數值,且兩者都是整數型別,那麼我們就可以在 comptime 執行加法來產生最終的常數。這正是我們的程式所發生的情況,進而產生了帶有結果 42.constant AIR 指令:

%3 = constant(comptime_int, 42)

我也包含了當數值並非 comptime 已知時的退路(fallthrough):block.addBinOp。這會新增一個 .add 的 AIR 指令以供執行期計算(而非 comptime)。做個實驗:把常數 2 改成變數,例如 var b: u32 = 2,並用它來做加法。這會產生一個 .add 運算,因為變數無法在 comptime 中進行運算。

%8: as_node(%4, %7)

下一條指令實作了安全的型別強制轉換(coercion)。從 ZIR 來看,這是把加法結果轉換為回傳型別。更具體地說,就我們已知的情況:我們必須把 comptime 整數 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 編譯器會在必要時模擬目標平台的特性。這是一項關鍵功能,讓 Zig 中的 comptime 求值既安全又可行。

這可以從 @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 這樣的原生後端。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言