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};
- 统一的任务契约(
makeFn): 每个Step挂载一个执行函数*const fn (step: *Step, options: MakeOptions) anyerror!void。无论是调用编译器编译源码、运行测试、写文件还是转译 C 头文件,只要实现该签名,就能作为独立任务接入构建图。 - 依赖关系(
dependOn): 节点之间通过step_a.dependOn(step_b)建立先后时序依赖,形成 DAG 的拓扑执行链条。 - 内置 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;
概念职责
std.Build.Module(编译单元):- 源码位置:lib/std/Build/Module.zig
- 职责:代表一组源码(可以是 Zig、C 源文件或混编)及其所需的编译上下文(
target、optimize、c_macros、include_dirs等)。它不直接对应.a或.exe等产物格式,而是一个可编译的逻辑单元。
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;
- 配置阶段(Graph Evaluation):
- 构建环境调用用户定义的
pub fn build(b: *std.Build) void; - 此时不会编译源码,也不会向磁盘写入中间文件。所有的 API 调用(如
b.addExecutable、b.addConfigHeader)仅在内存中分配Step节点并记录依赖关系,最终形成一张 DAG。
- 构建环境调用用户定义的
- 执行阶段(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};
核心机制
- 自动依赖推导:
当一个
LazyPath为.generated变体时,它内部持有生成该文件的Step指针。下游消费该路径时(例如step.addIncludePath(lazy_path)),Zig 会在内部自动调用:构建系统会根据数据引用的流向自动建立步骤间的依赖关系,无需手动调用1lazy_path.addStepDependencies(&step);consumer_step.dependOn(&generator_step)。 - 统一路径来源:
无论是工程源码树内的文件(
b.path(...))、编译缓存中由某步写出的文件(step.getOutputFile())、第三方依赖包内的文件(dep.path(...)),还是外部环境路径(cwd_relative),在构建 API 中都抽象为统一的LazyPath。
2. 核心 API 解析
2.1 模块与产物创建
b.createModule vs b.addModule
b.createModule(lib/std/Build.zig:L918): 创建一个私有模块,通常用于当前工程的内部组件组装(如作为可执行文件的根模块,或为测试程序构建依赖)。b.addModule: 在创建模块的同时将其注册到当前 package 的暴露表(b.modules)中。第三方项目通过b.dependency("my_pkg", ...).module("name")引入时,只有通过addModule注册的模块才能被下游消费。
模块依赖 module.addImport
- 源码位置:lib/std/Build/Module.zig:L326
- 在 Zig 源码中,
@import("foo")寻找的不是磁盘物理路径,而是当前模块的import_table。在构建脚本中通过mod.addImport("foo", other_mod)建立映射,控制模块命名空间的隔离。
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;
核心机制
- 源码定义:lib/std/Build/Step/TranslateC.zig:L29。
addTranslateC作为一个独立的Step接入构建图;- 转译结果以完整
.zig源码形式缓存在.zig-cache/o/目录下; - 调用
translate_c.createModule()将其封装为*std.Build.Module,并通过addImport("c", mod)挂载到目标程序。业务代码使用const c = @import("c");即可直接使用转译出的类型与函数声明。
2.3 文件生成与模板配置
大型 C 库通常使用 CMake 模板(如 config.h.in)根据平台检测结果生成配置头。Zig 提供了对应的支持:
配置头 b.addConfigHeader
- 源码位置:lib/std/Build/Step/ConfigHeader.zig:L60
- 支持以
.cmake(即 CMake 的#cmakedefine风格)读取 upstream 的.h.in文件,并基于 Zig 的匿名结构体注入宏值:
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
- 源码位置:lib/std/Build/Step/WriteFile.zig:L88
- 适合在构建阶段动态写出代码或中间文件。例如为
addTranslateC提供一个包含多个头文件的聚合入口:
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> 时需要知道头文件位置。可以通过以下两种方式获取上游库的包含目录树:
- 直接从 Artifact 获取头文件树:
1const lib_artifact = foo_dep.artifact("foo"); 2translate_c.addIncludePath(lib_artifact.getEmittedIncludeTree()); - 通过上游导出的命名路径(
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 构建系统的设计体现了其工具链整合的思路:
- Step 抽象与有向图:构建任务统一抽象为 Step 节点与 DAG,在配置阶段确定依赖拓扑;
- Module 与 Artifact 解耦:Module 负责源码集合与编译参数,Step.Compile 负责二进制产物生成,便于在不同目标间复用代码;
- LazyPath 自动依赖推导:基于文件数据的生成与引用关系自动建立 Step 依赖,减少手动维护
dependOn; - 头文件自动传播:下游通过
linkLibrary引入二进制依赖的同时,自动获得其关联的包含路径; - 自包含工具链:内置 Clang 与链接器,跨平台交叉编译无需依赖外部宿主环境。
关于复杂 C 库的完整移植工程实践,可以参考我最近开源的 zig-mariadb-connector; 此外,Zig 社区维护的 All Your Codebase 也提供了大量常用 C 库的封装实现,可供查阅参考。