The Zig build system is still missing documentation and for a lot of people, this is a killer argument not to use it. Others often search for recipies to build their project, but also struggle with the build system.
This series is an attempt to give an in-depth introduction into the build system and how to use it.
We start at the very beginning with a freshly initialized Zig project and will work our way towards more complex projects. On the way, we will learn how to use libraries and packages, add C code, and even how to create our own build steps.
Disclaimer
I will expect you to have at least some basic experience with Zig already, as i will not explain syntax or semantics of the Zig language. I will also link to several points in the standard library source, so you can see where all of this comes from. I recommend you to read the source of the build system, as most of it is self-explanatory if you start digging after functions you see in the build script. Everything is implemented in the standard library, there is no hidden build magic happening.
Getting started
We create a new project by making a new folder, and invoke zig init-exe
in that folder.
This will give us the following build.zig
file (of which i stripped off the comments):
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const target = b.standardTargetOptions(.{});
const mode = b.standardReleaseOptions();
const exe = b.addExecutable("fresh", "src/main.zig");
exe.setTarget(target);
exe.setBuildMode(mode);
exe.install();
const run_cmd = exe.run();
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);
}
Basics
The core idea of the build system is that the Zig toolchain will compile a Zig program (build.zig
) which exports a special entry point (pub fn build(b: *std.build.Builder) void
) that will be called when we invoke zig build
.
This function will then create a directed acyclic graph of std.build.Step
nodes, where each Step
will then execute a part of our build process.
Each Step
has a set of dependencies that need to be made before the step itself is made. As a user, we can invoke certain named steps by calling zig build step-name
or use one of the predefined steps (for example install
).
To create such a step, we need to invoke Builder.step
:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const named_step = b.step("step-name", "This is what is shown in help");
}
This will create us a new step step-name
which will be shown when we invoke zig build --help
:
[felix@denkplatte-v2 c2978668]$ zig build --help
Usage: zig build [steps] [options]
Steps:
install (default) Copy build artifacts to prefix path
uninstall Remove build artifacts from prefix path
step-name This is what is shown in help
General Options:
...
Note that this Step
still doesn't do anything except putting that nice little entry into zig build --help
and allowing us to invoke zig build step-name
.
Step
follows the same interface pattern as std.mem.Allocator
and requires the implementation of a single make
function. This will be invoked when the step is made. For our step created here, that function does nothing.
Now we need to build ourself a nice little Zig program:
Compiling Zig source
To compile an executable with the build system, the Builder
exposes Builder.addExecutable
which will create us a new LibExeObjStep
. This Step
implementation is a convenient wrapper around zig build-exe
, zig build-lib
, zig build-obj
or zig test
depending on how it is initialized. More on this will come later in this article.
Now let's create a step that compiles us our src/main.zig
file (which was previous created by zig init-exe
):
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
const compile_step = b.step("compile", "Compiles src/main.zig");
compile_step.dependOn(&exe.step);
}
We added a few lines here. First of all, const exe = b.addExecutable("fresh", "src/main.zig");
will create a new LibExeObjStep
that will compile src/main.zig
into a file called fresh
(or fresh.exe
on Windows).
The second thing added is compile_step.dependOn(&exe.step);
. This is how we build our dependency graph and declare that when compile_step
is made, exe
also needs to be made.
You can check this out by invoking zig build
, then zig build compile
. The first invocation will do nothing, but the second one will output some compilation messages.
This will always compile in Debug mode for the current machine, so for a first starter, this is probably enough. But if you want to start publishing your project, you might want to enable cross compilation:
Cross compilation
Cross compilation is enabled by setting the target and build mode of our program:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
exe.setBuildMode(.ReleaseSafe);
exe.setTarget(...);
const compile_step = b.step("compile", "Compiles src/main.zig");
compile_step.dependOn(&exe.step);
}
Here, exe.setBuildMode(.ReleaseSafe);
will pass -O ReleaseSafe
to the build invocation. exe.setTarget(...);
will set what -target ...
will see. But! LibExeObjStep.setTarget
requires a std.zig.CrossTarget
as a parameter, which you want typically to be configurable.
Luckily, the build system provides us with two convenience functions for that:
These functions can be used like this to make both the build mode and the target available as a command line option:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
const target = b.standardTargetOptions(.{});
exe.setTarget(target);
const mode = b.standardReleaseOptions();
exe.setBuildMode(mode);
const compile_step = b.step("compile", "Compiles src/main.zig");
compile_step.dependOn(&exe.step);
}
If you now invoke zig build --help
, you'll get the following section in the output which was previously empty:
Project-Specific Options:
-Dtarget=[string] The CPU architecture, OS, and ABI to build for
-Dcpu=[string] Target CPU features to add or subtract
-Drelease-safe=[bool] Optimizations on and safety on
-Drelease-fast=[bool] Optimizations on and safety off
-Drelease-small=[bool] Size optimizations on and safety off
The first two are added by standardTargetOptions
, the others are added by standardReleaseOptions
. These options can now be used when invoking our build script:
zig build -Dtarget=x86_64-windows-gnu -Dcpu=athlon_fx
zig build -Drelease-safe=true
zig build -Drelease-small
As you can see, for a boolean option, we can omit the =true
and just set the option itself.
But we still have to invoke zig build compile
, as the default invocation is still not doing anything. Let's change this!
Installing artifacts
To install anything, we have to make it depend on the install
step of the Builder
. This step is always created and can be accessed via Builder.getInstallStep()
. We also need to create a new InstallArtifactStep
that will copy our exe artifact to the install directory (which is usually zig-out
):
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
const install_exe = b.addInstallArtifact(exe);
b.getInstallStep().dependOn(&install_exe.step);
}
This will now do several things:
- It will create a new
InstallArtifactStep
that copies the compilation result ofexe
to$prefix/bin
- As the
InstallArtifactStep
(implicitly) depends onexe
, it will buildexe
as well - It will make the
InstallArtifactStep
when we callzig build install
(or justzig build
for short) - The
InstallArtifactStep
registeres the output file forexe
in a list that allows uninstalling it again
When you now invoke zig build
, you'll see that a new directory zig-out
was created which kinda looks like this:
zig-out
└── bin
└── fresh
You can now run ./zig-out/bin/fresh
to see this nice message:
info: All your codebase are belong to us.
Or you can uninstall the artifact again by invoking zig build uninstall
. This will delete all files created by zig build install
, but not directories!
As the install process is a very common operation, it has two short hands to make the code shorter:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
b.installArtifact(exe);
}
or even shorter:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
exe.install();
}
All of the last three code snippets will do exactly the same, but with less and less granularity of control.
If you ship a project with several applications built, you might want to create several separate install steps and depend on them manually instead of just invoking exe.install()
, but usually that's just the right thing to do.
Note that we can also install any other file with Builder.installFile
(or others, there are a lot of variants) and Builder.installDirectory
Now a single part is missing from understanding the initial build script to full extend:
Running built applications
For development user experience and general convenience, it's pratical to run programs directly from the build script. This is usually exposed via a run
step that can be invoked via zig build run
.
To do this, we need a std.build.RunStep
which will execute any executable we can run on the system:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
const run_step = std.build.RunStep.create(exe.builder, "run fresh");
run_step.addArtifactArg(exe);
const step = b.step("run", "Runs the executable");
step.dependOn(&run_step.step);
}
RunStep
has several functions that will add values to the argv
of the executed process:
-
addArg
will add a single string argument to argv. -
addArgs
will add several strings at the same time -
addArtifactArg
will add the result file of aLibExeObjStep
to argv -
addFileSourceArg
will add any file generated by other steps to the argv.
Note that the first argument must be the path to the executable we want to run. In this case, we want to run the compiled output of exe
.
As running build artifacts is also a very common step, we can shortcut this code:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
const run_step = exe.run();
const step = b.step("run", "Runs the executable");
step.dependOn(&run_step.step);
}
When we now invoke zig build run
, we'll see the same output as running the installed exe ourselves:
info: All your codebase are belong to us.
Note that there's a important difference here: When using the RunStep
, we run the executable from ./zig-cache/o/b0f56fa4ce81bb82c61d98fb6f77b809/fresh
instead of zig-out/bin/fresh
! This might be relevant if you load files relative to the executable path.
RunStep
is very flexibly configurable and allows passing data on stdin
to the process as well as verifying the output on stdout
and stderr
. You can also change the working directory or environment variables.
Oh, and another thing:
If you want to pass arguments to your process from the zig build
command line, you can do that by accessing Builder.args
:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const exe = b.addExecutable("fresh", "src/main.zig");
const run_step = exe.run();
if (b.args) |args| {
run_step.addArgs(args);
}
const step = b.step("run", "Runs the executable");
step.dependOn(&run_step.step);
}
This allows you passing in argument that follow a --
on the cli:
zig build run -- -o foo.bin foo.asm
Conclusion
This first chapter of this series should already enable you to fully understand the build script at the start of this article and also to create your own build scripts.
Most projects don't even need more than building, installing and running some Zig executables, so you're good to go with this!
Also watch our for the next part, where I will cover building C and C++ projects
Oldest comments (11)
This is fantastic, thank you!
Thank you. This really helps.
Great article. thanks
Thanks a lot for the article!
Small typo:
"Now let's create a step that comiles" -> compiles
Fixed! Thanks for the correction
awesome, thank you
hello,
I had to change my code with dev 0.11.0
Prog.install(); does not work anymore
USE only :
maybe to be more consistent with uninstall
For those interested, the source of the build system is github.com/ziglang/zig/tree/master... as of 8-2023
This is now obsolete ... any chance of updating it?
It seems there's an error. You say
and follow this up with
b.getInstallStep().dependOn(&install_exe.step);
, which says the opposite – to install anything, you have to make the install step of the builder depend on it. Which makes more sense – the install step needs all its prerequisites be available when it runs.My argument is build system APIs are changing pretty much from a version to another, what is the value of having a code that will be obsolete in the next release?