Zig Build System

Published: 2023-04-13   Updated: 2024-01-07   Tags: zig

Table of Contents

As the popularity of zig cc, more developers may realize Zig is not only a programming language, but also a toolchain to help maintaining C/C++/Zig project.

Today I want to discuss one vital part of this toolchain: Zig's build system, build.zig the first non-trivial Zig program for newcomers.

Note: This post is based on Zig 0.11.0-dev.2560+602029bb2.

Step

When we initialize project scaffold via zig init-exe, it will generate build.zig with detailed comments to help us understand what it does. The most frequent used commands are:

  • zig build, default step install is invoked
  • zig build test, test step is invoked

Here we introduce the most important concept in Zig's build system: step. A step describe one task, such as compile binary, run the test.

Step.zig defines MakeFn as step's main interface:

1
pub const MakeFn = *const fn (self: *Step, prog_node: *std.Progress.Node) anyerror!void;

All other concrete steps implements it via composition with @fieldParentPtr. If readers don't know this idioms, refer this post. The following are most common used steps:

  • CompileStep, used to compile binary/static library/static library
  • RunStep, used to execute one program
  • InstallArtifactStep, used to copy build artifacts to zig-out directory

std.Build provides lots of convenient API to define build steps and their relation, such as:

  • addExecutable define a CompileStep for application binary
  • addTest define a CompileStep for test binary
  • addRunArtifact define a RunStep for one CompileStep

Steps' relation is formed by Step.dependOn function, and their relation construct a directed acyclic graph(DAG), which is used to drive the whole build process.

Build Step DAG

Figure above show a simple DAG of steps, steps at the top are special, and they can be invoked by zig build ${topLevelStep}, Build.step function creates such top level steps.

After explanation above, readers should have a deeper understand what build.zig does.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const exe = b.addExecutable(.{
        .name = "demo",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
    const unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    const run_unit_tests = b.addRunArtifact(unit_tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_unit_tests.step);
}

How build systems run

Different with Rust, there is no rustc-like zigc to compile build.zig, then how does build.zig works?

The answer is sub-process. When you build a fresh project, zig build will first compile build.zig to a binary, then run this binary to execute specific chosen steps.

The build binary sub-process to other Zig sub-commands to finish their jobs, such as zig build-exe for CompileStep.

build_runner.zig contains the main entry for the build binary, build.zig is imported using const root = @import("@build"), it requires four arguments when start:

  • Zig executable
  • directory where build.zig locates
  • Local cache directory(optional)
  • Global cache directory(optional)

We can manually invoke the build binary like this:

1
2
3
4
5
6
$ find . -name build
./zig-cache/o/0c7d271c608c27670403b31835132c7d/build
$ ./zig-cache/o/0c7d271c608c27670403b31835132c7d/build $(which zig) $(pwd) .cache .glocal-cache
$ zig-out/bin/demo
All your codebase are belong to us.
Run `zig build test` to run the tests.

So when you have bugs in build.zig, you first need to find where the build binary is, then run it in gdb/lldb with args above.

Conclusion

A fully featured build system is necessary to keep developers productive, but flexibility come at a cost of misuse, so design a good build system is hard.

Zig's build system is based on the language itself, developers don't need to learn a new language to write their build(CMake, I'm looking at you!). And Zig provides a declarative API to construct steps' DAG, lots of pre-defined steps to finish common task, powerful as will as practical.

Besides build system, package manager in Zig is in rapid development. With those, developer can build old/complex C/C++ projects in just one command: zig build. Some early adopters:

  • pcre2, Perl-Compatible Regular Expressions
  • llama.cpp, Port of Facebook's LLaMA model in C/C++
  • raylib, A simple and easy-to-use library to enjoy videogames programming

Discussions on Lobsters and Reddit, you can also email me to share your thoughts.

References