Build your project Zig-style
摘要
作者通过 300 多行 Zig 代码实现了 bygge-zig 原型,成功驱动了包含 proc-macro 和 build.rs 的复杂 Rust 项目编译。文中深入分析了 Rust 单元图(unit graph)的解析、Zig 构建系统的性能优势与文档缺失现状,以及 Cargo 在处理元数据和依赖关系时所承担的巨大复杂性。
荐读理由
通过 Rust Nightly 的 unit-graph 提取构建计划并尝试用 Zig 重写构建流程的实验,为你揭示了 Cargo 内部复杂性的具体构成,并提供了一个利用 Zig 构建系统加速 Rust 项目编译或进行工具链深度定制的可行思路。
原文
Build your project Zig-style
Jun 16, 2026 · 8 minute read · rust
Over the past few nights I've been tinkering with the Zig build system, after it got a bit of a rework lately. I had a plan: Learn a bit more about Zig, learn a bit more about the Zig build system and get a deeper understanding of what Cargo does to build a Rust project.
Building Rust projects with something that is not Rust is something I have experience with. With Zig I tinkered around a bit before and I'm keeping an eye on its development. The Zig build system was completely new to me.
So just like that I present: bygge-zig. It builds Rust code:
src/mozilla/glean$ ls -l Cargo.toml
-rw-r--r-- 1 jer staff 616 Jun 15 16:10 Cargo.toml
src/mozilla/glean$ zig build --summary line
Build Summary: 194/194 steps succeeded
src/mozilla/glean$ du -sh .zig-cache
952M .zig-cache
src/mozilla/glean$ tree zig-out
zig-out
├── bin
│ └── uniffi-bindgen
└── lib
├── glean
├── glean_core
└── uniffi_bindgen
This is a build of the Glean SDK, my main project at work.
In 374 lines of Zig (and 79 lines of Ruby) I replicated what Cargo does in 80.000 lines of Rust. That includes building Rust code, building proc-macros and building and running build scripts (those build.rs files in the top-level directory of some crates).
Of course that is an oversimplification. Turns out a complete Rust build system is really tricky.
bygge-zig does nothing about resolving and fetching dependencies. It does not figure out what depends on what. It does not decide which features should be active. It translates what the build should look like into something the Zig build system can handle.
At one point Cargo had build plans, which allowed to gather information about how to run the build, mostly. That was removed and now there is a new way to get most of that information: the unit graph (on Rust Nightly):
$ cargo +nightly build -Z unstable-options --unit-graph | jq .
{
"version": 1,
"units": [
{
"pkg_id": "path+file:///home/jer/src/bygge-zig/crates/hello-world#0.1.0",
"target": {
"kind": [
"bin"
],
"crate_types": [
"bin"
],
"name": "hello-world",
"src_path": "/home/jer/src/bygge-zig/crates/hello-world/src/main.rs",
"edition": "2024",
"doc": true,
"doctest": false,
"test": true
},
"profile": {
"name": "dev",
"opt_level": "0",
"lto": "false",
"codegen_backend": null,
"codegen_units": null,
"debuginfo": 2,
"split_debuginfo": "unpacked",
"debug_assertions": true,
"overflow_checks": true,
"rpath": false,
"incremental": true,
"panic": "unwind",
"strip": {
"deferred": "None"
}
},
"platform": null,
"mode": "build",
"features": [],
"dependencies": [
{
"index": 1,
"extern_crate_name": "world",
"public": false,
"noprelude": false,
"nounused": false
}
]
},
<snip>
This emits the units of work that Cargo will execute for the build. It still requires a lot more effort to turn that into something actually executable. For example the generated shell command for the first unit in the example graph is this:
CARGO_CRATE_NAME=hello_world \
CARGO_PKG_NAME=hello-world \
CARGO_PKG_VERSION=1.0.0 \
<snip>
rustc /home/jer/src/bygge-zig/crates/hello-world/src/main.rs \
--edition 2024 \
--extern world=target/debug/deps/libworld.rlib \
-L target/debug/deps
This assumes the world library is already built and placed in the target/debug/deps/ directory. There's many more more environment variables that are set for a build.
The Glean SDK (and the expanded sample project) does make use of both proc-macros and build scripts across its dependencies. The way they need to be built differs from library code. proc-macros are built into dynamic libraries and loaded by the Rust compiler at compile time. Build scripts are always built for the host target and run before the crate's code gets compiled. Build scripts print out additional configuration to stdout, which is parsed by Cargo and injected into the rest of the build.
$ RUSTC=rustc OUT_DIR=.zig-cache/tmp .zig-cache/o/98128f/rust/build_script_build
cargo::rustc-check-cfg=cfg(build_ran)
cargo::rustc-cfg=build_ran
$ cat .zig-cache/tmp/code.rs
pub const NAME: &'static str = "builder";
This will result in the parameter --cfg=build_ran being appended to the rustc invocation for hello-world/src/lib.rs.
Not every environment variable is read by every project. Not every build.rs output line is needed for every build. To build the Glean SDK I'm getting away with the bare minimum. Nonetheless bygge-zig is able to drive quite a complicated build. And it does that on both macOS and Linux just fine.
What I learned about Zig
In this project I didn't really use much of what makes Zig Zig. No comptime, no error handling, no arena allocators. It is definitely an improvement over writing similar code in C, with a larger standard library and some generic data types to use. Some of the more Zig things I barely scratched. String handling requires an allocator, so it needs to be passed around, it's embedded in the std.Build structure available in a build.zig. Built-in deserialization works by using comptime magic behind the scenes. JSON parsing makes use of that (but comes with terrible error messages).
I really got used to Rust's ownership system, where I don't have to think about who's responsible for freeing some data. Zig doesn't have that. Sometimes you pass things by value, other times by reference. But in the latter case are you now responsible to free it? That's communicated by documentation, not by types. In bygge-zig I simply do not deallocate. It's a short-running process, so all memory is gone by the end of it anyway.
Zig wants all code to be warning-free. And so do I — when the code is done. Unused variables however are a part of development and I would prefer if the build still runs through in that case. Different philosophy. At the same time Zig is lazy and unused bits of the code, like functions that never get called, don't even get looked at and don't trigger compile errors.
The biggest plus though is: Compiling Zig code is fast.
What I learned about the Zig build system
It's badly documented, if at all.
The Zig Build System article tells you a bit of how to use it for your Zig project and maybe some dependencies. The std.Build page has a bit of API documentation, but no usage examples. Some parts don't even get API documentation and so you have to guess from the name how it's intended to be used. Because of the recent build system changes a lot of the existing usage doesn't apply anymore. After the recent big changes to the build system it's also incomplete. Some sharp edges are expected.
My current use of the Zig build system in bygge-zig is inefficient, guaranteed to be wrong and very very hacky. After all I'm not using it for what it's mainly build for (building Zig projects). But I got it to work.
I still don't know how the caching system is supposed to work. Right now bygge-zig rebuilds far too much stuff. Every run of zig build grows .zig-cache by some 200 MB. This makes bygge-zig far worse than Cargo. There's still more to learn about this.
What I learned about the Cargo build
It's hella complicated. I learned some of what it does with bygge back then, but I cheated on some things and hardcoded a lot of options. Cargo does so much behind the scenes and getting that replicated in an outside tool is hard. Every crate in your dependency tree relies on a different Cargo feature. Implementing a replacement is thus a lot of trial and error until you get it right. There's a lot of legacy ways to do things and Cargo supports them all. On top of that rustc does a lot of things by itself as well. rustc knows how to traverse the module tree of a crate if you give it the top-level file. Built Rust libraries are identified by their metadata and metadata gets placed into .rmeta files. Metadata (and filenames in case of Cargo) contain hashes of all the input, including the compiler version and other external bits of information. rustc refuses to use built libraries if the metadata of the library and its transitive dependencies is wrong or missing. Sometimes the error message doesn't tell you that.
I feel like I now understand 20% of the build process.
这条对你有帮助吗?