Zig Build System Internals

Mitchell Hashimoto

Zig 建構系統內部機制

原文由 Mitchell Hashimoto 發布,訂閱此部落格

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

建構系統是任何軟體專案中極為重要的一環。當它正常運作時,感覺就像魔法一樣:你執行一道指令,經過一連串可能相當複雜的步驟後,就產生出可執行的二進位檔(或其他產物)!當它出問題時,卻又像一道令人困惑、毫不透明的阻礙,讓人恨不得它根本不存在。這就是任何強大工具的典型寫照:運作順利時是魔法,卡關時則是惡夢,完全取決於當天與當下的任務。

理解建構系統的內部運作,正是去除這種「魔法感」、讓工作變得更輕鬆的方法。本文希望能再深入一層,讓 Zig 使用者了解 zig build 的運作方式,進而在日常工作中更有效率。

注意:本頁不是 Zig 建構系統本身的入門介紹,而是對其內部運作的介紹。若想了解建構系統的入門用法,請參閱官方文件這個關於 Zig 建構系統的優質部落格系列,或這篇關於如何讓現有 C/C++ 專案運用建構系統的部落格文章

高層級運作

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

Zig 建構系統使用 build.zig 檔案進行設定,並透過 zig build 指令來執行。在 build.zig 檔案中,開發者必須實作一個建構函式(類似 fn build() void 這樣的形式)。這個建構函式可以存取建構器 API(std.build.Builder),用來定義步驟、依賴、產物等。這是一個大致上屬於宣告式的 API,建構系統會據此決定要執行什麼、以及執行的順序。

如果你曾經用 zig build 建置過 Zig 專案,可能會注意到它有時看起來會編譯兩次;你會看到語意分析與程式碼生成的輸出出現兩次。這是因為在沒有快取的專案中,它真的會編譯兩次:它會先編譯出一個建構執行檔,然後再由這個建構執行檔來編譯你的專案程式碼。

如剛才所述,zig build 指令一開始會將 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.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。它並不複雜,建議你閱讀該檔案。實際的建構器 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.zig 檔案中 build 函式的方式。建構執行器利用 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 函式。這個建構函式會接收一個指向 std.build.Builder 的指標作為參數,並用它以宣告式的方式定義可用的旗標、目標、目標依賴等。最後,建構執行器(build_runner.zig)會依照依賴順序,為給定的目標執行這些步驟。

定義步驟

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

「頂層步驟」是一種特殊的區分,指的是可以透過名稱呼叫的步驟,也就是 zig build <name>。有兩個預先定義的頂層步驟:「install」與「uninstall」。額外的頂層步驟可以透過 step 函式來建立。除了擁有一個可呼叫的指定名稱外,頂層步驟在功能上與任何其他步驟是等價的。Builder 會在一個 ArrayList 中維護這組頂層步驟。

所有步驟都可以透過在 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 則確保一個步驟只會執行一次,後續的執行將不會有任何作用。

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

宣告式 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 的全部能力來建置你的專案。

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

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

留言