Zig NEWS

loading...
Cover image for zig build explained - part 1

zig build explained - part 1

Felix "xq" Queißner
Updated on ・9 min read

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);
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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:
  ...
Enter fullscreen mode Exit fullscreen mode

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 comiles 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

This will now do several things:

  • It will create a new InstallArtifactStep that copies the compilation result of exe to $prefix/bin
  • As the InstallArtifactStep (implicitly) depends on exe, it will build exe as well
  • It will make the InstallArtifactStep when we call zig build install (or just zig build for short)
  • The InstallArtifactStep registeres the output file for exe 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
Enter fullscreen mode Exit fullscreen mode

You can now run ./zig-out/bin/fresh to see this nice message:

info: All your codebase are belong to us.
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 a LibExeObjStep 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);
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

This allows you passing in argument that follow a -- on the cli:

zig build run -- -o foo.bin foo.asm
Enter fullscreen mode Exit fullscreen mode

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

Discussion (3)

Collapse
ed profile image
ed

Thank you. This really helps.

Collapse
david_vanderson profile image
David Vanderson

This is fantastic, thank you!

Collapse
clarkthan profile image
Clark Than

Great article. thanks