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 命令行的内省 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 的编译期能力来内省 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.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();
}

步骤的结构

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 则保证一个步骤只会执行一次,后续执行将不做任何操作。

掌握了这些知识,应该就能大致清楚如何创建自定义步骤了。内置步骤已为构建大多数项目提供了所需的所有功能。

声明式与命令式

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 命令行的路径正是构建可执行文件的第一个必需位置参数;这就是该参数的主要用途。

重要的是,这意味着构建可执行文件并未内嵌完整的 Zig 编译器。此外,如果你愿意,构建可执行文件甚至可以指向不同版本的 Zig!

结论

研究 Zig 构建系统内部原理后,我最大的收获是:它就是 Zig。你可以在 build.zig 文件中做任何想做的事,因为它会被编译成适用于当前系统的完整可执行文件。Zig 为你提供了一套有主见的结构和一组内置步骤,但你依然拥有 Zig 的全部能力来构建项目。

我坚信,理解日常工具底层的原理能让我们成为更好的工具使用者。它通过揭示机器的内部运作消除了所有神秘感,而且我常常发现,内部运作往往比我预想的要简单得多。下次当你疑惑构建系统能否实现某个功能,或是为何没有按预期工作时,希望这些更深入的知识能帮助你更快地找到答案。

本文章由 muse-spark-1.2-contributor 进行翻译

评论