Zig Build System Internals

Mitchell Hashimoto

Zig 建置系統內部原理

Zig 內建了一套用於建置專案的建置系統。它能在 Zig 支援的所有平台上執行,能夠建置從簡單的可執行檔與函式庫,到複雜的多產物、多步驟專案等各種內容。本頁將深入探討 Zig 建置系統的內部運作方式。

建置系統是任何軟體專案中極為重要的一環。當它們正常運作時,感覺就像魔法一樣:你執行一個指令,經過一系列可能相當複雜的步驟後,就產生了一個可運作的二進位檔(或其他產物)!當它們無法運作時,卻又像是令人困惑、不透明的絆腳石,讓人巴不得它們不存在。這正是任何強大工具的典型寫照:運作順利時是魔法,不順時則是令人頭痛的麻煩,取決於當下的日期與任務。

了解建置系統的內部原理,是消除那種「魔法」感、讓工作變得更輕鬆的一種方式。本頁旨在深入一層,讓 Zig 使用者能夠理解 zig build 的運作方式,進而在日常工作中更有效率。

注意:本頁並非 Zig 建置系統本身的入門介紹,而是介紹建置系統運作方式的內部原理。若想了解建置系統的入門內容,請參閱官方文件這篇關於 Zig 建置系統的優質部落格系列文章,或這篇介紹既有 C/C++ 專案如何運用該建置系統的部落格文章

高階運作

在深入細節之前,我們先從宏觀的角度描述 Zig 建置系統。

Zig 建置系統使用 build.zig 檔案進行設定,並透過 zig build CLI 來執行。在 build.zig 檔案中,開發者必須實作一個 build 函式(類似 fn build() void)。該 build 函式可存取用於定義步驟、相依性、產物等的 builder API(std.build.Builder)。這是一套大致上屬於宣告式的 API,建置系統會利用它來決定要執行哪些內容以及執行的順序。

如果你曾使用 zig build 來建置 Zig 專案,可能已經注意到它有時似乎會編譯兩次;你會看到語意分析與程式碼生成的輸出出現兩次。這是因為在尚未快取的專案中,它的確會編譯兩次:它會先編譯一個建置執行檔,然後由該建置執行檔接著編譯你的專案程式碼。

如前所述,zig build CLI 首先會將 build.zig 檔案編譯成適用於當前系統的建置執行檔,然後以子處理程序的方式執行該執行檔來進行實際的建置。建置執行檔受惠於 Zig 的快取系統,因此除非 build.zig(或其引入的任何檔案)被修改,後續的 zig build 指令就不需要重新編譯的成本。

接著會執行該建置執行檔,由它來決定並執行建置專案所需的各個步驟。建置執行檔並未內嵌 Zig 編譯器;它會視需要進一步以子處理程序呼叫 Zig 編譯器來建置個別的產物。

高階步驟的示意圖如下所示:

                                   ┌────────────────────┐
                                   │                    │
                                   │     zig build      │
                                   │                    │
                                   └────────────────────┘
                                              │
        ┌────────────────────┐                │
        │                    │                ▼
        │     build.zig      │──┐  ┌────────────────────┐
        │                    │  │  │                    │
        └────────────────────┘  ├─▶│    build binary    │
        ┌────────────────────┐  │  │                    │
        │  lib/std/special/  │  │  └────────────────────┘
        │  build_runner.zig  │──┘             │
        │                    │                │
        └────────────────────┘                ▼
        ┌────────────────────┐     ┌────────────────────┐┌───────┐
        │                    │     │                    ││       │
        │    source files    │────▶│   zig build-exe    ││  ...  │
        │                    │     │                    ││       │
        └────────────────────┘     └────────────────────┘└───────┘
                                              │
                                              │
                                              ▼
                                   ┌────────────────────┐
                                   │    artifact(s)     │
                                   │ (exe, libs, etc.)  │
                                   │                    │
                                   └────────────────────┘

建置建構器

zig build 流程只做兩件事:(1)建置建置執行檔,以及(2)以子處理程序執行該建置執行檔。第一個步驟——建置建置執行檔——會以 build.zig 檔案作為輸入,但尚未執行 build 函式。

zig build 的原始碼位於 src/main.zig,並在 cmdBuild 函式中實作。這個函式並不會過於複雜,我建議完整閱讀一遍。其中一開始的區塊負責建置建置執行檔,接著的邏輯則是以子處理程序呼叫它。

建置執行檔使用 lib/std/special/build_runner.zig 作為建置的主要進入點。如果你查看這個檔案,會看到一個 pub fn main。這就是實際建置執行檔的主要進入點。

該進入點檔案會引入 @build,這是一個由 cmdBuild 定義、用來指向你的 build.zig 檔案的特殊套件。建置執行器(build runner)就是透過這種方式最終執行你 build.zig 檔案中的 build 函式。

最終的建置執行檔不會被安裝到任何位置。它儲存在 Zig 快取目錄中(通常是相對於你的 build.zig 檔案的 zig-cache)。你可以透過尋找它來證明建置執行檔確實存在:

$ find ./zig-cache -type f -name 'build'
./zig-cache/o/c4c75a71df444bff10945728759e174c/build

尋找「build_runner.zig」

zig build 如何知道 lib/std/special/build_runner.zig 在你系統上的位置?

有一個 src/introspect.zig 檔案,其中包含用於尋找 Zig 安裝位置的 API。它的運作方式是從目前可執行檔所在的目錄開始,向上逐層遍歷,直到找到 lib/zig/std/std.ziglib/std/std.zig 為止。當你執行 zig build 時,它會從 zig 二進位檔所在的目錄開始尋找這些檔案。

注意:這也是專案中解析 @import("std") 的方式。具體來說,std 套件會在建置過程中,透過來自 zig CLI 的 introspection API 預先定義為指向 std.zig

尋找標準函式庫的函式實作如下所示。從這個目錄出發,你可以取得標準 Zig 安裝中的任何檔案。

fn testZigInstallPrefix(base_dir: fs.Dir) ?Compilation.Directory {
    const test_index_file = "std" ++ fs.path.sep_str ++ "std.zig";

    zig_dir: {
        // Try lib/zig/std/std.zig
        const lib_zig = "lib" ++ fs.path.sep_str ++ "zig";
        var test_zig_dir = base_dir.openDir(lib_zig, .{}) catch break :zig_dir;
        const file = test_zig_dir.openFile(test_index_file, .{}) catch {
            test_zig_dir.close();
            break :zig_dir;
        };
        file.close();
        return Compilation.Directory{ .handle = test_zig_dir, .path = lib_zig };
    }

    // Try lib/std/std.zig
    var test_zig_dir = base_dir.openDir("lib", .{}) catch return null;
    const file = test_zig_dir.openFile(test_index_file, .{}) catch {
        test_zig_dir.close();
        return null;
    };
    file.close();
    return Compilation.Directory{ .handle = test_zig_dir, .path = "lib" };
}

手動建置建置執行檔

為了進一步消除任何「魔法」感,讓我們手動建置建置執行檔。在本頁稍後,我們會手動執行這個執行檔,但現在先從建置它開始。

在本範例中,我們將為 Zig 編譯器本身建置 build.zig 檔案。請複製 Zig 原始碼並安裝好 zig(理想上是從你取得的原始碼編譯而成的版本,但任何近期版本應該都能運作)。接著,在取出的目錄中,我們可以建置建置執行檔:

$ zig build-exe \
    --pkg-begin '@build' build.zig \
    --pkg-end \
    -femit-bin=custom-builder \
    lib/std/special/build_runner.zig

這樣就完成了!執行該指令後,應該會產生 custom-builder 可執行檔。這與呼叫 zig build 時所建立的可執行檔完全相同。

這個命令列呼叫清楚地顯示,我們的主套件是來自標準函式庫的 build_runner.zig 檔案,而我們的 build.zig 檔案則以 @build 套件的形式被公開。

這就是 zig build 在底層所做之事的一半。

執行建置執行檔

建置執行檔建置完成後,zig build 會產生一個子處理程序並立即執行它。

該執行檔預期依照下列順序接收四個位置參數:

  1. Zig 編譯器的路徑
  2. build.zig 所在的建置根目錄路徑——雖然重要的是,它不再需要存取 build.zig 檔案本身。它僅使用此路徑來解析已編譯進執行檔中的 build.zig 程式碼裡與建置相關的相對路徑。
  3. 本機快取目錄的路徑(不必事先存在)。
  4. 全域快取目錄的路徑(不必事先存在)。

zig build 的實作會自動填入這些參數,然後將任何額外的參數轉發給建置執行檔並加以執行。這個執行檔會執行實際的專案建置。

提醒一下,建置執行檔的 main 進入點函式定義於 lib/std/special/build_runner.zig。它並不複雜,我建議你閱讀該檔案。實際的 builder API(std.build.Builder)則較為複雜,因此我建議先從閱讀 build_runner.zig 檔案開始,以了解高階的控制流程。

手動呼叫建置執行檔

如果你已依照上一節的步驟建立了 custom-builder 執行檔,現在可以手動呼叫它來完整建置 Zig 編譯器。

提醒一下,在實務上你永遠不需要這麼做,因為 zig build 會幫你完成。我們只是為了說明 zig build 的內部運作方式,才手動執行建置執行檔。

$ ./custom-builder $(which zig) . ./cache ./global-cache

我們以 Zig 編譯器作為範例,但同樣的模式適用於任何使用 Zig 建置系統的專案。

支援的參數與旗標

建置執行檔支援 zig build 所支援的幾乎所有旗標。事實上,除了少數直接控制建置執行檔建置過程的參數外,zig build 會將所有參數原封不動地直接複製給建置執行檔的子處理程序。

你可以透過使用 --help 旗標(連同四個必要的位置參數)執行建置執行檔來實際觀察這一點。如果你已依照上一節手動建置了建置執行檔,現在就可以試試看:

$ ./custom-builder $(which zig) . ./cache ./global-cache --help

這四個位置參數假設 zig 位於你的 PATH 上,且你目前的工作目錄就是存在 build.zig 檔案的建置根目錄。

說明內容看起來會與 zig build --help 幾乎完全相同,因為本來就是如此!zig build --help 會先建置建置執行檔,然後將 --help 旗標轉發給子處理程序。輸出結果應該會完全一致。

呼叫 Build 函式

我覺得非常有趣的一點是,build runner 如何呼叫 build.zig 檔案中的 build 函式。build runner 利用 Zig 的編譯期(comptime)能力來內省 build 函式的函式簽章,以支援多種函式簽章。

下面重現來自 build_runner.zigrunBuild 函式,以展示其實際運作:

fn runBuild(builder: *Builder) anyerror!void {
    switch (@typeInfo(@typeInfo(@TypeOf(root.build)).Fn.return_type.?)) {
        .Void => root.build(builder),
        .ErrorUnion => try root.build(builder),
        else => @compileError("expected return type of build to be 'void' or '!void'"),
    }
}

這讓 build 函式的簽章可以是以下兩者之一:

  • fn build(*std.build.Builder) void
  • fn build(*std.build.Builder) !void(請注意 !void

更具體地說,錯誤聯集(error union)的情況(第二種情況)可以是任何錯誤聯集。它可以是像上列那樣的推斷錯誤聯集,也可以是明確定義的錯誤聯集。

就理解建置系統的運作方式而言,這並不是一個非常重要的細節,但這是一個透過研究工具實作可以發現的有趣內部運作範例。這也向我展示了一個很酷的 comptime 使用案例。

建置步驟

我們已經了解 zig build 如何使用 build.zig 檔案建立專屬的建置執行檔,以及該建置執行檔如何被執行來建置你的專案,但建置執行檔實際上在做什麼,又與 build.zig 檔案有何關聯?

建置執行檔會呼叫 build.zig 檔案中使用者定義的 build 函式。這個 build 函式會接收一個指向 std.build.Builder 的指標作為參數,用於以宣告式的方式定義建置中可用的旗標、目標、目標相依性等。最後,build runner(build_runner.zig)會依照相依性順序為給定的目標執行各個步驟。

定義步驟

Builder 參數擁有許多功能,但其核心目標是建立一組步驟。

「top level step」是對可透過名稱呼叫的步驟所做的特殊區分,例如 zig build <name>。有兩個預先定義的 top level step:「install」與「uninstall」。額外的 top level step 可透過 step 函式建立。除了擁有指定的可呼叫名稱外,top level step 在功能上與任何其他步驟等價。BuilderArrayList 維護 top level step 的集合。

所有步驟都可以透過在 Step 上呼叫 dependOn 函式來指定零個或多個相依性。這會以簡單的 ArrayList 來維護相依性。

呼叫頂層步驟

一個或多個頂層步驟是透過在 Builder 上呼叫 make 函式來呼叫的。這會在內部呼叫 makeOneStep 來建置單一的頂層步驟。makeOneStep 非常簡單,其完整實作如下所示:

fn makeOneStep(self: *Builder, s: *Step) anyerror!void {
    if (s.loop_flag) {
        warn("Dependency loop detected:\n  {s}\n", .{s.name});
        return error.DependencyLoopDetected;
    }
    s.loop_flag = true;

    for (s.dependencies.items) |dep| {
        self.makeOneStep(dep) catch |err| {
            if (err == error.DependencyLoopDetected) {
                warn("  {s}\n", .{s.name});
            }
            return err;
        };
    }

    s.loop_flag = false;

    try s.make();
}

makeOneStep 會依照加入的順序一次處理一個相依性,並遞迴地呼叫 makeOneStep。每個步驟上的 loop_flag 用於偵測循環(而非建立圖結構或更複雜的機制)。最後,再透過 make 呼叫步驟本身。

步驟的結構

Step 結構是一個相對簡單、類似介面的結構,並附加了一些狀態。步驟的獨特邏輯被封裝在 makeFn 函式指標中,而其餘欄位則為共享狀態。

pub const Step = struct {
    id: Id,
    name: []const u8,
    makeFn: fn (self: *Step) anyerror!void,
    dependencies: ArrayList(*Step),
    loop_flag: bool,
    done_flag: bool,

    // ...
};

name 僅用於除錯目的,除非這是一個頂層步驟。loop_flagdependencies 先前已介紹過。done_flag 確保步驟恰好只執行一次。後續的執行則不會有任何作用(noop)。

掌握這些知識後,應該就能大致清楚如何著手建立自訂步驟。內建的步驟已提供了建置大多數專案所需的所有功能。

宣告式 vs. 指令式

build 函式定義了步驟集合及其相依性,但並不會執行它們。一種描述方式是,build.zig 檔案以宣告式的方式定義建置步驟。這是一個重要的概念,理解它可以避免我在一般建置系統(包括 Zig)中常見的一些陷阱。

因為步驟是以宣告式定義的,你必須仔細思考邏輯實際上在何時、何地發生。如果你有一個會讀取由前一個步驟產生的檔案的步驟,那麼就必須使用帶有 makeFn 的自訂步驟來完成。你不能在 build 函式中建立步驟後再讀取該檔案,因為它尚未執行。

另一方面,如果你試圖為一組在任何建置步驟執行前就已存在的檔案,以程式化的方式建立一系列步驟,你可以(而且很可能應該)直接在 build 函式中完成,讓步驟被完整定義。你無法在建置執行時動態定義步驟。

進階註記:你無法在執行時動態地將步驟定義到現有的步驟圖中。但是,你可以建立一個會動態建立其他步驟並直接執行它們的自訂步驟。

編譯步驟

現在已經了解 build.zig 如何定義步驟、這些步驟的結構以及它們如何被呼叫。Zig 的 Builder 結構提供了用於建置可執行檔、目的檔等的高階輔助函式。讓我們更深入地看看其運作方式。

所有與編譯相關的功能都共享同一個實作,即 LibExeObjStep。可執行檔、函式庫或其他目的檔類型的建置,取決於該步驟結構中的欄位值。這些細節通常被隱藏在諸如 addExecutableaddSharedLibrary 等輔助函式之後。

LibExeObjStep 的實作可在 lib/std/build.zig 中找到。該步驟擁有豐富的功能,且是建置過程中如此重要的一環,因此我強烈建議研讀其完整的步驟實作,儘管其程式碼行數相對較多。

該步驟的實作是透過以子處理程序再次呼叫 zig 編譯器來運作。make 的實作會取得步驟上的設定,建立一組命令列參數,然後呼叫 zig build-exezig build-obj 或其他 Zig 指令。回想一下,Zig CLI 的路徑是建置執行檔所需的第一個位置參數;這就是該參數的主要用途。

重要的是,這意味著建置執行檔並未內嵌完整的 Zig 編譯器。此外,如果你願意,建置執行檔甚至可以指向不同版本的 Zig!

結論

我研究 Zig 建置系統內部原理後得到的主要收穫是,它就只是 Zig。你可以在 build.zig 檔案中做任何你想做的事,因為它會被編譯成適用於你當前系統的完整可執行檔。Zig 為你提供了一個具有特定主張的結構與一組內建步驟,但你同時擁有 Zig 的完整能力來建置你的專案。

我深信,了解我們日常使用工具底層的那一層,會讓我們成為更出色的工具使用者。它透過揭露機器的內部運作來消除所有神秘感,而我往往發現內部運作總是比我預期的還要簡單。下次當你思考是否能用建置系統做某件事,或為何建置系統沒有如你預期般運作時,希望這些更深入的知識能幫助你更快找到答案。

原文由 Mitchell Hashimoto 發布

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