别再把 build.zig 当配置文件:Zig 构建系统从零到实战
很多语言把构建配置写成一堆声明:源码目录、编译参数、链接库、安装目录分别放在不同字段里。Zig 走了另一条路:build.zig 本身就是一个 Zig 程序。
这意味着,构建逻辑可以使用普通的变量、条件判断、循环、函数和类型检查。项目从"编译一个文件"发展到"编译多个目标、运行测试、生成代码、接入第三方库"时,仍然只需要扩展这份 Zig 程序。
本文按 Zig 0.16.0 的 API 编写,重点放在能落地的项目结构和 Demo,而不是把 API 名字罗列一遍。
一、zig build 到底做了什么?
运行下面的命令:
bash
zig build
Zig 会在当前项目中找到 build.zig,编译并执行其中的 build 函数。这个函数通常长这样:
zig
const std = @import("std");
pub fn build(b: *std.Build) void {
// 在这里描述项目如何构建
}
b 是构建上下文,类型为 *std.Build。可执行文件、库、测试、命令和自定义步骤,都通过这个对象创建。
需要注意,build 函数执行时主要是在"登记任务",并不是看到 addExecutable 就立刻编译。构建系统会把任务组织成一张有向无环图(DAG),再根据依赖关系执行。没有被当前步骤依赖的任务不会被无意义地执行,这也是并发构建和缓存生效的基础。
可以把常见对象先记成下面几类:
text
Build 构建上下文
Module Zig 模块,描述源码和导入关系
Artifact 构建产物,例如可执行文件、静态库、动态库
Step 构建步骤,例如 install、run、test
Target 目标平台,例如 x86_64-linux、aarch64-macos
OptimizeMode 优化模式,例如 Debug、ReleaseSafe
二、最小可运行项目
先准备如下目录:
text
hello/
├── build.zig
└── src/
└── main.zig
src/main.zig:
zig
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var stdout_buffer: [256]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writer(init.io, &stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("hello from Zig build\n", .{});
try stdout.flush();
}
对应的 build.zig:
zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "hello",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(exe);
}
这段代码分成三件事:
standardTargetOptions读取-Dtarget;standardOptimizeOption读取-Doptimize;addExecutable创建可执行文件,installArtifact把它加入默认安装步骤。
执行:
bash
zig build
./zig-out/bin/hello
默认产物位于 zig-out/bin/hello。.zig-cache 保存中间缓存,zig-out 是安装前缀,两者都不应该提交到 Git。zig-out 的位置可以通过 --prefix 改变:
bash
zig build --prefix dist
这里有一个很关键的区别:b.path("src/main.zig") 表示项目内的路径对象;路径不应该写死成 /Users/... 这样的绝对路径,否则缓存、跨平台构建和被其他项目复用都会受到影响。
三、为什么新版推荐 root_module?
旧版本常见写法是把 .root_source_file、.target 和 .optimize 直接放进 addExecutable。Zig 0.16.0 更推荐先通过 b.createModule 创建模块,再把模块交给 addExecutable:
zig
const module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "hello",
.root_module = module,
});
这样做的好处是:模块的导入关系、编译参数和依赖关系都有明确的挂载位置。后面加入本地模块或第三方模块时,直接操作 exe.root_module 即可。
四、添加 run 步骤:让项目真正像项目
zig build 默认只执行安装步骤。开发阶段更常用的是编译后立即运行,因此可以注册一个名为 run 的步骤:
zig
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the application");
run_step.dependOn(&run_cmd.step);
完整调用链可以读成:
text
zig build run
↓
run_step
↓
addRunArtifact(exe)
↓
编译 exe,并执行生成的程序
程序参数放在 -- 后面:
bash
zig build run -- config.json --verbose
-- 前面的内容属于 Zig 构建命令,-- 后面的内容才会交给应用程序。
五、本地模块:把源码拆开仍然能清晰导入
假设项目需要一个问候模块:
text
src/
├── main.zig
└── greeting.zig
src/greeting.zig:
zig
pub fn text() []const u8 {
return "hello from a local module";
}
在 build.zig 中声明模块并挂到可执行文件:
zig
const greeting = b.createModule(.{
.root_source_file = b.path("src/greeting.zig"),
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("greeting", greeting);
src/main.zig 里就可以这样使用:
zig
const std = @import("std");
const greeting = @import("greeting");
pub fn main() void {
std.debug.print("{s}\n", .{greeting.text()});
}
这里的 "greeting" 不是文件名,而是构建脚本注册的导入名。模块名和文件名可以相同,也可以不同。
六、测试步骤不能只编译,必须连接运行步骤
b.addTest 创建的是测试产物。要真正执行测试,还需要用 b.addRunArtifact 创建运行步骤,再把运行步骤接到自定义的 test 步骤上:
zig
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
src/main.zig 里增加测试:
zig
test "greeting text" {
try std.testing.expectEqualStrings(
"hello from a local module",
greeting.text(),
);
}
运行:
bash
zig build test
只写 b.addTest 而不连接 addRunArtifact,结果只是生成测试二进制,测试代码并不会被执行。这是构建脚本里非常常见的遗漏。
七、一个完整 Demo:程序、模块、运行和测试
下面这份 build.zig 可以直接作为项目骨架:
zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const greeting = b.createModule(.{
.root_source_file = b.path("src/greeting.zig"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "demo",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.root_module.addImport("greeting", greeting);
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| run_cmd.addArgs(args);
const run_step = b.step("run", "Run the demo");
run_step.dependOn(&run_cmd.step);
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
tests.root_module.addImport("greeting", greeting);
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
}
对应命令:
bash
zig build # 构建并安装到 zig-out/bin/demo
zig build run # 编译后运行
zig build run -- Alice # 把 Alice 传给程序
zig build test # 执行单元测试
zig build -Doptimize=ReleaseSafe
zig build -Dtarget=x86_64-windows
zig build --summary all # 显示构建步骤和缓存情况
-Dtarget 和 -Doptimize 能正常生效,前提就是脚本使用了两个 standard... 函数。硬编码目标平台会直接破坏交叉编译能力。
八、静态库和动态库
可执行文件之外,构建系统也能生成库。Zig 0.16.0 可以统一使用 addLibrary,通过 linkage 选择静态或动态:
zig
const lib = b.addLibrary(.{
.name = "math",
.linkage = .static,
.root_module = b.createModule(.{
.root_source_file = b.path("src/math.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(lib);
生成动态库时把 .static 改成 .dynamic。如果同一个项目里的可执行文件要链接这个库,可以使用:
zig
exe.linkLibrary(lib);
库是否安装、Demo 是否安装,都可以通过 b.option 控制。没有被当前步骤依赖的产物不会被额外编译,这正是构建图带来的好处。
九、第三方依赖:build.zig.zon 和 b.dependency
build.zig 描述"怎么构建",build.zig.zon 描述"项目依赖哪些包"。依赖声明示意如下:
zon
.{
.name = "demo",
.version = "0.1.0",
.dependencies = .{
.some_lib = .{
.url = "https://example.com/some_lib.tar.gz",
.hash = "1220...",
},
},
}
在 build.zig 中加载依赖:
zig
const dep = b.dependency("some_lib", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("some_lib", dep.module("some_lib"));
依赖的模块名由依赖包提供,不能只根据压缩包文件名猜测。实际接入时应查看依赖包的 build.zig,确认 dep.module("...") 使用的名称。
十、链接系统库和编译 C 代码
确实依赖系统库时,可以让模块链接 libc,再连接具体库:
zig
const exe = b.addExecutable(.{
.name = "zip-tool",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
exe.root_module.linkSystemLibrary("z", .{});
项目自带的 Zig 依赖通常更容易复现,也更容易交叉编译;发行版打包时则可能必须链接系统库。两种方式没有绝对替代关系,关键在于明确库由谁提供、版本由谁控制。
十一、自定义步骤和构建参数
b.step 只负责注册名字和描述,必须通过 dependOn 连接实际任务:
zig
const check_step = b.step("check", "Run project checks");
const check_cmd = b.addSystemCommand(&.{ "echo", "checks passed" });
check_step.dependOn(&check_cmd.step);
之后执行:
bash
zig build check
自定义布尔参数可以这样定义:
zig
const enable_demo = b.option(
bool,
"enable-demo",
"Install the demo executable",
) orelse false;
if (enable_demo) {
b.installArtifact(exe);
}
调用时写成:
bash
zig build -Denable-demo
这种配置适合控制示例程序、基准测试、可选后端和额外工具。与环境变量相比,-D 参数会出现在 zig build --help 中,也能进入构建图和缓存计算,更适合项目级配置。
十二、常见问题
忘记 installArtifact
只创建 exe 不代表默认的 zig build 会安装它。需要明确写出:
zig
b.installArtifact(exe);
自定义步骤注册了却没有动作
b.step("run", "...") 只是一个空节点。必须让它依赖 run_cmd.step、test_run.step 或其他实际步骤。
把 zig-out 和 .zig-cache 提交进仓库
这两个目录都是生成物。项目通常只提交源码、build.zig 和 build.zig.zon,并在 .gitignore 中排除:
gitignore
.zig-cache/
zig-out/
直接复制旧版本 API
Zig 仍处于快速演进阶段,构建 API 也会变化。尤其是 root_module、I/O API、测试运行方式和 C 代码导入方式,不能把 0.12 或 0.13 的片段直接拼进 0.16 项目。实际排查时先确认:
bash
zig version
zig build --help
再以当前版本的官方 Build System 文档和 zig init-exe 生成的模板为准。
总结
build.zig 可以理解成一份用 Zig 写成的项目构建程序:
text
standardTargetOptions 读取目标平台
standardOptimizeOption 读取优化模式
createModule 描述源码模块
addExecutable 创建可执行文件
addLibrary 创建库
addTest 创建测试产物
addRunArtifact 执行程序或测试
step + dependOn 组织自定义命令
installArtifact 加入默认安装步骤
真正值得掌握的不是某个 API 的参数顺序,而是"产物 + 步骤 + 依赖关系"这套模型。掌握这三层之后,单文件程序、库、测试、代码生成、跨平台发布和第三方依赖,都可以沿着同一张构建图逐步扩展。