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.zig 或 lib/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 會產生一個子處理程序並立即執行它。
該執行檔預期依照下列順序接收四個位置參數:
- Zig 編譯器的路徑
build.zig所在的建置根目錄路徑——雖然重要的是,它不再需要存取build.zig檔案本身。它僅使用此路徑來解析已編譯進執行檔中的build.zig程式碼裡與建置相關的相對路徑。- 本機快取目錄的路徑(不必事先存在)。
- 全域快取目錄的路徑(不必事先存在)。
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.zig 的 runBuild 函式,以展示其實際運作:
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) voidfn 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 在功能上與任何其他步驟等價。Builder 以 ArrayList 維護 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_flag 與 dependencies 先前已介紹過。done_flag 確保步驟恰好只執行一次。後續的執行則不會有任何作用(noop)。
掌握這些知識後,應該就能大致清楚如何著手建立自訂步驟。內建的步驟已提供了建置大多數專案所需的所有功能。
宣告式 vs. 指令式
build 函式定義了步驟集合及其相依性,但並不會執行它們。一種描述方式是,build.zig 檔案以宣告式的方式定義建置步驟。這是一個重要的概念,理解它可以避免我在一般建置系統(包括 Zig)中常見的一些陷阱。
因為步驟是以宣告式定義的,你必須仔細思考邏輯實際上在何時、何地發生。如果你有一個會讀取由前一個步驟產生的檔案的步驟,那麼就必須使用帶有 makeFn 的自訂步驟來完成。你不能在 build 函式中建立步驟後再讀取該檔案,因為它尚未執行。
另一方面,如果你試圖為一組在任何建置步驟執行前就已存在的檔案,以程式化的方式建立一系列步驟,你可以(而且很可能應該)直接在 build 函式中完成,讓步驟被完整定義。你無法在建置執行時動態定義步驟。
進階註記:你無法在執行時動態地將步驟定義到現有的步驟圖中。但是,你可以建立一個會動態建立其他步驟並直接執行它們的自訂步驟。
編譯步驟
現在已經了解 build.zig 如何定義步驟、這些步驟的結構以及它們如何被呼叫。Zig 的 Builder 結構提供了用於建置可執行檔、目的檔等的高階輔助函式。讓我們更深入地看看其運作方式。
所有與編譯相關的功能都共享同一個實作,即 LibExeObjStep。可執行檔、函式庫或其他目的檔類型的建置,取決於該步驟結構中的欄位值。這些細節通常被隱藏在諸如 addExecutable 或 addSharedLibrary 等輔助函式之後。
LibExeObjStep 的實作可在 lib/std/build.zig 中找到。該步驟擁有豐富的功能,且是建置過程中如此重要的一環,因此我強烈建議研讀其完整的步驟實作,儘管其程式碼行數相對較多。
該步驟的實作是透過以子處理程序再次呼叫 zig 編譯器來運作。make 的實作會取得步驟上的設定,建立一組命令列參數,然後呼叫 zig build-exe 或 zig build-obj 或其他 Zig 指令。回想一下,Zig CLI 的路徑是建置執行檔所需的第一個位置參數;這就是該參數的主要用途。
重要的是,這意味著建置執行檔並未內嵌完整的 Zig 編譯器。此外,如果你願意,建置執行檔甚至可以指向不同版本的 Zig!
結論
我研究 Zig 建置系統內部原理後得到的主要收穫是,它就只是 Zig。你可以在 build.zig 檔案中做任何你想做的事,因為它會被編譯成適用於你當前系統的完整可執行檔。Zig 為你提供了一個具有特定主張的結構與一組內建步驟,但你同時擁有 Zig 的完整能力來建置你的專案。
我深信,了解我們日常使用工具底層的那一層,會讓我們成為更出色的工具使用者。它透過揭露機器的內部運作來消除所有神秘感,而我往往發現內部運作總是比我預期的還要簡單。下次當你思考是否能用建置系統做某件事,或為何建置系統沒有如你預期般運作時,希望這些更深入的知識能幫助你更快找到答案。
隨機一篇部落格