Zig 构建系统(上):设计理念与最佳实践

发布: 2026-10-01   上次更新: 2026-10-02   分类: 编程语言   标签: zig

文章目录

C/C++ 生态通常使用 CMake 等专用构建工具配合平台脚本;Rust 的 Cargo 统一了包管理,但处理跨语言 C/C++ 依赖混合编译和代码生成时,仍需要编写 build.rs 桥接外部工具。

Zig 没有引入专用的构建 DSL,而是直接使用普通 Zig 源码编写构建脚本(build.zig),并将编译器驱动与 C/C++ 工具链整合在统一的接口下。

本文梳理 Zig 构建系统的核心抽象与用法:Step 有向无环图、Module 与 Artifact 的解耦、惰性路径(LazyPath),以及构建与导出 C 库的常见 API。关于 build.zig 底层如何被编译运行、内嵌 Clang 与目标文件如何链接,可参阅下篇 《Zig 构建系统(下):编译管线与底层运行机制》。

本文在 Antigravity CLI (agy) 协助下完成,源码分析基于 Zig 0.16.0 源码。


1. 核心设计哲学

build.zig 本质上是一个声明式的依赖计算图生成器。

1.1 构建图与 Step 核心抽象

构建系统的本质是任务编排。在 Zig Build 中,整个构建管线被建模为一张有向无环图(DAG),图上的每个任务节点对应一个 std.Build.Step。

什么是 Step?

在源码定义中,Step 是一个通用的任务节点:

 1// lib/std/Build/Step.zig
 2pub const Step = struct {
 3    id: Id,
 4    name: []const u8,
 5    owner: *Build,
 6    makeFn: MakeFn,
 7
 8    dependencies: std.array_list.Managed(*Step),
 9    dependants: ArrayList(*Step),
10    inputs: Inputs,
11    // ...
12};
  1. 统一的任务契约(makeFn): 每个 Step 挂载一个执行函数 *const fn (step: *Step, options: MakeOptions) anyerror!void。无论是调用编译器编译源码、运行测试、写文件还是转译 C 头文件,只要实现该签名,就能作为独立任务接入构建图。
  2. 依赖关系(dependOn): 节点之间通过 step_a.dependOn(step_b) 建立先后时序依赖,形成 DAG 的拓扑执行链条。
  3. 内置 Step 类型: Zig 标准库根据构建任务内置了多种特化的 Step 实现(参见 lib/std/Build/Step.zig:L145 的 Id 枚举):
    • top_level:顶层命名入口(如 b.default_step 对应的 install,或通过 b.step("run", ...) 注册的自定义入口);
    • compile(Step.Compile):编译与链接任务,负责产出二进制产物;
    • run(Step.Run):执行生成的可执行文件或外部命令;
    • write_file(Step.WriteFile):动态生成或写入文件;
    • config_header(Step.ConfigHeader):基于 CMake/C 模板生成配置头;
    • translate_c(Step.TranslateC):将 C 头文件转译为 Zig AST。
  graph TD
    subgraph S_Top ["顶层入口 (Top Level)"]
        TL_Install["b.default_step (默认 install)"]
        TL_Test["b.step('test', ...) (测试入口)"]
    end

    subgraph S_Pipeline ["任务执行管线 (Step DAG)"]
        S_Art["Step: InstallArtifact<br/>产物安装到 zig-out"]
        S_Compile["Step.Compile<br/>编译器与链接器调用"]
        S_Gen["Step.ConfigHeader / Step.WriteFile<br/>文件与代码生成"]
        S_TestRun["Step.Run<br/>执行测试二进制"]
    end

    TL_Install -- "dependOn" --> S_Art
    S_Art -- "dependOn" --> S_Compile
    S_Compile -- "dependOn" --> S_Gen
    TL_Test -- "dependOn" --> S_TestRun
    S_TestRun -- "dependOn" --> S_Compile

    classDef default fill:#f8f9fa,stroke:#495057;
    style S_Top fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style S_Pipeline fill:#e6f3ff,stroke:#0066cc,stroke-width:2px;
    style TL_Install fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style TL_Test fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style S_Art fill:#f8f9fa,stroke:#495057,stroke-width:2px;
    style S_Compile fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
    style S_Gen fill:#e6ffe6,stroke:#009900,stroke-width:2px;
    style S_TestRun fill:#cce5ff,stroke:#0066cc,stroke-width:2px;

在这些 Step 中,负责编译链接的是 Step.Compile。它与 Module 的分工是理解 Zig 构建模型的关键。

1.2 Module vs Step.Compile

在早期的 Zig 中,编译配置(宏定义、包含路径、libc 链接等)直接设置在静态库或可执行程序对象(lib 或 exe)上。当同一个项目需要同时输出静态库、动态库和测试二进制时,会导致配置在多处重复定义。

现代 Zig 将“源代码与编译配置单元”与“输出的二进制产物”分开处理:

  graph TD
    %% Definitions
    subgraph S_Artifact ["Artifact 层 (构建产物与链接任务 - Step.Compile)"]
        A_Lib["addLibrary (静态库 / 动态库)"]
        A_Exe["addExecutable (可执行文件)"]
        A_Test["addTest (单元测试二进制)"]
    end

    subgraph S_Module ["Module 层 (核心编译单元 - std.Build.Module)"]
        M_Env["编译上下文环境<br/>- Target 目标架构与 OS<br/>- Optimize 优化级别<br/>- link_libc 开关"]
        M_Src["源代码与编译属性<br/>- Zig / C 源代码列表<br/>- 头文件 include 路径<br/>- 预处理宏定义 c_macros"]
        M_Dep["模块依赖关系表<br/>- import_table (子模块 DAG)"]
    end

    A_Lib -- "指定 root_module" --> M_Env
    A_Exe -- "指定 root_module" --> M_Env
    A_Test -- "指定 root_module" --> M_Env

    M_Env -- "组织与管理" --> M_Src
    M_Env -- "依赖引用" --> M_Dep

    classDef default fill:#f8f9fa,stroke:#495057;
    style S_Artifact fill:#f8f9fa,stroke:#495057,stroke-width:2px;
    style S_Module fill:#e6f3ff,stroke:#0066cc,stroke-width:2px;
    style A_Lib fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style A_Exe fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style A_Test fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style M_Env fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
    style M_Src fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
    style M_Dep fill:#cce5ff,stroke:#0066cc,stroke-width:2px;

概念职责

  1. std.Build.Module(编译单元):
    • 源码位置:lib/std/Build/Module.zig
    • 职责:代表一组源码(可以是 Zig、C 源文件或混编)及其所需的编译上下文(target、optimize、c_macros、include_dirs 等)。它不直接对应 .a 或 .exe 等产物格式,而是一个可编译的逻辑单元。
  2. std.Build.Step.Compile(构建产物 / 链接任务):
    • 源码位置:lib/std/Build/Step/Compile.zig
    • 职责:由 b.addExecutable、b.addLibrary 等函数创建,负责驱动编译器后端与链接器,生成最终的可执行文件、静态库(.a / .lib)或动态库(.so / .dylib)。

纯 C 静态库中 root_module 的作用

即使工程中没有 .zig 源码,创建 C 静态库时 root_module 依然是必填参数(参见 lib/std/Build.zig:L842 的 addLibrary 定义):

1pub const LibraryOptions = struct {
2    linkage: std.builtin.LinkMode = .static,
3    name: []const u8,
4    root_module: *Module,
5    version: ?std.SemanticVersion = null,
6    // ...
7};

编译 C 源文件同样需要明确的目标架构、操作系统、宏定义与包含路径。Zig 将这些编译配置统一收敛在 Module 中,而 Step.Compile 仅负责最终的产物输出与链接方式。同一份 Module 可以同时提供给静态库、动态库和测试目标使用,避免重复配置。


1.3 配置期与执行期分离

zig build 的生命周期严格区分为两个阶段:

  graph TD
    subgraph Phase1 ["阶段一:配置期 (Configuration)"]
        B_Code["执行 build.zig 中的 build(b)"]
        B_Graph["构建 Step 有向无环图 (DAG)"]
        B_Option["收集并绑定编译参数选项"]
        B_Code --> B_Graph
        B_Graph --> B_Option
    end

    subgraph Phase2 ["阶段二:执行期 (Execution)"]
        E_Topo["按拓扑序确定执行节点"]
        E_Cache["增量缓存命中检查"]
        E_Worker["执行 Step.make() 真实构建与产物落盘"]
        E_Topo --> E_Cache
        E_Cache --> E_Worker
    end

    Phase1 -- "依赖图构建完毕进入执行期" --> Phase2

    classDef default fill:#f8f9fa,stroke:#495057;
    style Phase1 fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style Phase2 fill:#e6ffe6,stroke:#009900,stroke-width:2px;
    style B_Code fill:#f8f9fa,stroke:#495057,stroke-width:2px;
    style B_Graph fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
    style B_Option fill:#f8f9fa,stroke:#495057,stroke-width:2px;
    style E_Topo fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
    style E_Cache fill:#fff3cd,stroke:#ffc107,stroke-width:2px;
    style E_Worker fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
  1. 配置阶段(Graph Evaluation):
    • 构建环境调用用户定义的 pub fn build(b: *std.Build) void;
    • 此时不会编译源码,也不会向磁盘写入中间文件。所有的 API 调用(如 b.addExecutable、b.addConfigHeader)仅在内存中分配 Step 节点并记录依赖关系,最终形成一张 DAG。
  2. 执行阶段(Graph Execution):
    • 构建引擎根据目标步(如默认的 install 或 run)拓扑遍历依赖子图;
    • 节点借助哈希指纹比对实现增量缓存,命中者直接跳过,未命中者调用 Step.make() 执行真实构建并落盘产物。

因此,在 build() 函数内部不能通过同步文件 I/O 假设生成文件已存在。文件的生成和流转需要通过下一节介绍的 LazyPath 表达。

常见问题:初学者如果在 build() 中直接调用 std.fs.cwd().openFile(...) 读取通过 b.addConfigHeader() 或 b.addWriteFiles() 生成的配置文件,会报文件不存在错误。因为在配置期这些步骤仅是内存中的图节点,文件要等到执行期由对应的 Step.make() 触发后才会写入磁盘。

关于 Zig 底层如何动态编译 build_runner 独立程序、如何进行多线程拓扑调度与派生编译器进程,参见 《Zig 构建系统(下):编译管线与底层运行机制》。


1.4 惰性路径 LazyPath

在构建管线中,常需要将上一步生成的代码或头文件传递给下一步使用(例如配置头生成、Protobuf 或代码转译)。使用普通绝对路径字符串不仅无法跨目录,还会丢失依赖时序。

Zig 提供了 LazyPath 抽象:

 1// lib/std/Build.zig
 2pub const LazyPath = union(enum) {
 3    src_path: struct {
 4        owner: *std.Build,
 5        sub_path: []const u8,
 6    },
 7    generated: struct {
 8        file: *const GeneratedFile,
 9        up: usize = 0,
10        sub_path: []const u8 = "",
11    },
12    cwd_relative: []const u8,
13    dependency: struct {
14        dependency: *Dependency,
15        sub_path: []const u8,
16    },
17    // ...
18};

核心机制

  1. 自动依赖推导: 当一个 LazyPath 为 .generated 变体时,它内部持有生成该文件的 Step 指针。下游消费该路径时(例如 step.addIncludePath(lazy_path)),Zig 会在内部自动调用:
    1lazy_path.addStepDependencies(&step);
    
    构建系统会根据数据引用的流向自动建立步骤间的依赖关系,无需手动调用 consumer_step.dependOn(&generator_step)。
  2. 统一路径来源: 无论是工程源码树内的文件(b.path(...))、编译缓存中由某步写出的文件(step.getOutputFile())、第三方依赖包内的文件(dep.path(...)),还是外部环境路径(cwd_relative),在构建 API 中都抽象为统一的 LazyPath。

2. 核心 API 解析

2.1 模块与产物创建

b.createModule vs b.addModule

模块依赖 module.addImport


2.2 头文件转译 b.addTranslateC

在 Zig 业务代码中可以直接写 @cImport({ @cInclude("foo.h"); }),但这种方式由编译器即时转译,难以跨包缓存,编译参数也不易统一。在构建脚本中使用 b.addTranslateC 可以将其转为一个显式的构建步骤:

  graph LR
    H_File["C 头文件 (*.h)"]
    TC_Step["b.addTranslateC 步骤<br/>- Include 路径配置<br/>- 宏定义 (-D)"]
    Z_AST["转译生成的 Zig AST / 源码<br/>(.zig-cache/o/.../c.zig)"]
    Mod["translate_c.createModule()<br/>导出标准 Zig Module"]
    Exe["exe.root_module.addImport('c', mod)"]
    Code["Zig 业务源码: const c = @import('c')"]

    H_File --> TC_Step
    TC_Step -- "make() 转译" --> Z_AST
    Z_AST --> Mod
    Mod --> Exe
    Exe --> Code

    classDef default fill:#f8f9fa,stroke:#495057;
    style H_File fill:#fff0e6,stroke:#ff9900,stroke-width:2px;
    style TC_Step fill:#cce5ff,stroke:#0066cc,stroke-width:2px;
    style Z_AST fill:#fff3cd,stroke:#ffc107,stroke-width:2px;
    style Mod fill:#e6ffe6,stroke:#009900,stroke-width:2px;
    style Exe fill:#f8f9fa,stroke:#495057,stroke-width:2px;
    style Code fill:#f8f9fa,stroke:#495057,stroke-width:2px;

核心机制

  1. 源码定义:lib/std/Build/Step/TranslateC.zig:L29。
  2. addTranslateC 作为一个独立的 Step 接入构建图;
  3. 转译结果以完整 .zig 源码形式缓存在 .zig-cache/o/ 目录下;
  4. 调用 translate_c.createModule() 将其封装为 *std.Build.Module,并通过 addImport("c", mod) 挂载到目标程序。业务代码使用 const c = @import("c"); 即可直接使用转译出的类型与函数声明。

2.3 文件生成与模板配置

大型 C 库通常使用 CMake 模板(如 config.h.in)根据平台检测结果生成配置头。Zig 提供了对应的支持:

配置头 b.addConfigHeader

 1const config_h = b.addConfigHeader(
 2    .{
 3        .style = .{ .cmake = upstream.path("include/config.h.in") },
 4        .include_path = "config.h",
 5    },
 6    .{
 7        .HAVE_STDDEF_H = true,
 8        .SIZEOF_SIZE_T = @as(i64, ptr_size),
 9        .DEFAULT_CHARSET = "utf8mb4",
10    },
11);

文件写入 b.addWriteFiles

1const c_h = b.addWriteFiles().add("c.h",
2    \\#include <foo.h>
3    \\#include <foo_error.h>
4);

2.4 库与头文件导出

在构建供他人使用的 C 静态库时,需要让下游能够获取到该库关联的公共头文件。

linkLibrary 自动传播包含路径

在 Zig 中,通过静态库 Artifact 导出头文件是标准做法。当在下游调用 exe.root_module.linkLibrary(lib) 时,底层在 lib/std/Build/Module.zig:L658 如下处理:

1// lib/std/Build/Module.zig
2fn linkLibraryOrObject(m: *Module, other: *Step.Compile) void {
3    const allocator = m.owner.allocator;
4    _ = other.getEmittedBin(); // 声明对目标二进制产物的依赖
5
6    m.link_objects.append(allocator, .{ .other_step = other }) catch @panic("OOM");
7    // 将该库关联的包含目录树追加到当前模块的包含路径列表中
8    m.include_dirs.append(allocator, .{ .other_step = other }) catch @panic("OOM");
9}

其中 m.include_dirs.append(allocator, .{ .other_step = other }) 会把该库关联的包含目录树自动追加到下游模块中。因此下游无论是编译 C 文件还是混编,都不需要再为该库额外调用 addIncludePath。

上游标准范式

库的提供方在生成静态库时,将公共头文件及动态生成的配置头文件安装到该库的头文件树中,并暴露该 Artifact:

 1// 上游 build.zig
 2const lib = b.addLibrary(.{
 3    .name = "foo",
 4    .linkage = .static,
 5    .root_module = b.createModule(.{
 6        .target = target,
 7        .optimize = optimize,
 8    }),
 9});
10
11// 安装原始静态公共头文件目录
12lib.installHeadersDirectory(upstream.path("include"), "", .{});
13
14// 安装动态生成的配置头文件
15lib.installConfigHeader(config_h);
16
17// 正式暴露该 Artifact 供下游消费
18b.installArtifact(lib);

下游消费场景

链接消费

下游只需声明链接依赖:

1// 下游 build.zig
2const foo_dep = b.dependency("foo", .{
3    .target = target,
4    .optimize = optimize,
5});
6
7// 调用 linkLibrary,自动挂载该库关联的所有头文件
8exe.root_module.linkLibrary(foo_dep.artifact("foo"));

下游的 C/C++ 源文件即可直接 #include <foo.h>。

转译消费

如果下游是纯 Zig 工程,需要使用 b.addTranslateC 将 C 头文件转译为 Zig Module:

由于 addTranslateC 是一个前置代码生成步骤(此时尚未建立二进制产物的链接关系),转译器在解析 #include <foo.h> 时需要知道头文件位置。可以通过以下两种方式获取上游库的包含目录树:

  1. 直接从 Artifact 获取头文件树:
    1const lib_artifact = foo_dep.artifact("foo");
    2translate_c.addIncludePath(lib_artifact.getEmittedIncludeTree());
    
  2. 通过上游导出的命名路径(namedLazyPath): 上游若通过 b.addNamedLazyPath("include", lib.getEmittedIncludeTree()); 导出,下游可直接调用:
    1translate_c.addIncludePath(foo_dep.namedLazyPath("include"));
    

符号导入与二进制链接

配置完头文件路径后,下游需要完成转译模块挂载与静态库链接两步:

1// 1. 注入转译模块:业务源码可通过 const foo = @import("foo"); 使用 C 类型与函数声明
2exe.root_module.addImport("foo", translate_c.createModule());
3
4// 2. 链接静态库:提供函数底层的二进制实现
5exe.root_module.linkLibrary(lib_artifact);

注意:addTranslateC 仅将 .h 头文件翻译为 Zig 的 extern fn 符号声明与结构体定义,不包含函数的实现代码。实现位于静态库 lib_artifact 中。如果只调用 addImport 而未链接静态库,类型检查可以通过,但在链接阶段会报 undefined reference to 'foo_xxx' 错误。


3. 总结

Zig 构建系统的设计体现了其工具链整合的思路:

  1. Step 抽象与有向图:构建任务统一抽象为 Step 节点与 DAG,在配置阶段确定依赖拓扑;
  2. Module 与 Artifact 解耦:Module 负责源码集合与编译参数,Step.Compile 负责二进制产物生成,便于在不同目标间复用代码;
  3. LazyPath 自动依赖推导:基于文件数据的生成与引用关系自动建立 Step 依赖,减少手动维护 dependOn;
  4. 头文件自动传播:下游通过 linkLibrary 引入二进制依赖的同时,自动获得其关联的包含路径;
  5. 自包含工具链:内置 Clang 与链接器,跨平台交叉编译无需依赖外部宿主环境。

关于复杂 C 库的完整移植工程实践,可以参考我最近开源的 zig-mariadb-connector; 此外,Zig 社区维护的 All Your Codebase 也提供了大量常用 C 库的封装实现,可供查阅参考。

评论

欢迎读者通过邮件与我交流,也可以在 Mastodon 或 Twitter 上关注我。