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 文件中,开发者必须实现一个构建函数(类似 fn build() void)。该构建函数可以使用一个 builder API(std.build.Builder)来定义步骤、依赖、产物等。这基本上是一个声明式 API,构建系统借助它来确定要执行什么以及以何种顺序执行。

如果你以前用 zig build 构建过 Zig 项目,你可能注意到它有时似乎会编译两次;你会看到语义分析和代码生成的输出出现两次。这是因为对于一个未缓存的项目,它确实会编译两次:它先编译出一个构建二进制文件(build binary),然后这个构建二进制文件再去编译你的项目代码。

正如刚才所述,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.)  │
                                   │                    │
                                   └────────────────────┘

构建 Builder

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 包会被预先定义好指向 std.zig,这一步是在构建过程中由 zig CLI 使用内省 API 完成的。

下面展示了查找标准库的那个函数的实现。从这个目录出发,你可以获取标准 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 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) void
  • fn build(*std.build.Builder) !void(注意其中的 !void

更具体地说,错误联合类型的情况(第二种情况)可以是任何错误联合类型。它可以像上面列表中那样是一个推断的错误联合类型,也可以是一个显式定义的错误联合类型。

就理解构建系统的工作原理而言,这并不是一个特别重要的细节,但它是通过研究工具的实现所能发现的一个很酷的内部机制范例。它也向我展示了一个很酷的 comptime 用例。

构建步骤

我们已经了解了 zig build 如何利用 build.zig 文件创建一个专用的构建二进制文件,以及如何执行这个构建二进制文件来构建你的项目,但这个构建二进制文件究竟在做什么?它又与 build.zig 文件有什么关系?

构建二进制文件会调用来自 build.zig 文件的、用户自定义的 build 函数。这个构建函数会收到一个指向 std.build.Builder 的指针作为参数,用于以声明式方式定义构建的可用标志、目标平台、目标依赖等等。最后,build runner(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 的全部能力来构建你的项目。

我深信,理解我们日常所用工具之下的那一层,能让我们成为更好的工具使用者。它通过揭示机器的内部运作消除了神秘感,而且我往往发现,内部运作总是比我预期的要简单。下次当你疑惑能否用构建系统做某件事,或者为什么构建系统没有按你期望的方式工作时,希望这些更深层的知识能帮助你更快找到答案。