The Zig Build System1 can be used even for projects without a single line of Zig code. I am far from an expert in complex build systems, having mostly used language tooling or contributing to projects that already chose a tool such as GNU autotools.

I probably can’t do a better job of explaining the general motivation for introducing Zig than Loris Cro2 or Andrew Kelley3. In my specific case, I’m working on a tool for building Linux disk images and booting them in virtial machines using QEMU4.

Now this tool happens to be written in Zig, but that’s not terribly important in one specific sense. My requirements for the tool are:

  • cross-platform (Linux and MacOS)
  • can leverage new features of QEMU
  • easy to install

That second requirement implies that I cannot rely on the QEMU package provided by the OS or distro. Homebrew is pretty on-top of updating, but some Linux distributions like Debian purposefully freeze the version between releases (barring security updates).

The last point is something I’ve come to value after working with Golang. It’s so easy to set GOARCH and GOOS and get binaries that can be shipped to users. The only constraint is that you can’t use C libraries via cgo (or can you?5).

Regardless, it’s brilliant to be able to run

$ curl -sL https://ziglang.org/builds/zig-linux-$(uname -m)-0.12.0.tar.xz | tar -C ~/.local/share/zig -Jxf -
$ ~/.local/share/zig/zig-linux-$(uname -m)-0.12.0/zig version
0.12.0

and the person who built and uploaded the release tarballs didn’t need a build machine for each architecture.

Meson

QEMU is implemented in C and uses a build system called meson6, which I’ve not been exposed to before. Luckily I’ve done quite a bit of python development before, so the DSL is familiar and so is the source code.

$ https://gitlab.com/qemu-project/qemu.git
$ cd qemu
$ wc -l --total=only **/meson.build
13197

Uh…

So I’m not very inclined to rewrite that by hand. I notice that meson has support for different backends, so I figure I just need to add a new backend for Zig. Oh so naive, and so wrong!

I went down this path for a bit and ended up with some python code in mesonbuild/backend/zigbackend.py that looked like:

    def generate_executable(self, file, target: build.Executable):
        if not target.name.startswith('qemu-system-'):
            return
        name = self.sanitize_name(target)
        file.write(f'''

    const {name} = b.addExecutable(.{{
        .name = "{target.name}",
        .root_source_file = .{{ .path = "src/qemu.zig" }},
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    }});''')

        if len(target.sources) > 0:
            file.write(f'''
    {name}.addCSourceFiles(.{{
        .files = &[_][]const u8{{''')
            for src in target.sources:
                file.write(f'''
            "{src.relative_name()}",''')
            file.write('''
        },''')

            self.write_c_flags(target, file)

            file.write('''
    });''')

It worked, and it was actually pretty helpful, but I soon realized that this was the wrong layer to be working at. I actually needed to write a new interpreter for meson, because my build.zig ought to have logic such as

if get_option('kvm').allowed() and host_os == 'linux'
  accelerators += 'CONFIG_KVM'
endif

It might be worth coming back to this, but for now I’m using the output I generated only as a starting point and will fix it by hand.

Dependencies

Like you might expect from a language build tool, meson is able to build a project’s dependencies as long as those dependencies also use meson. The QEMU project has several examples of that, for example:

$ cat subprojects/slirp.wrap 
[wrap-git]
url = https://gitlab.freedesktop.org/slirp/libslirp.git
revision = 26be815b86e8d49add8c9a8b320239b9594ff03d

[provide]
slirp = libslirp_dep

This is great because it’s easy for me to identify and pull down those sources, and it’s bad for me because it means more repositories for which I need to write build.zig files.

Ultimately, I had to write zig build files for both slirp and glib and a few of the smaller repos in QEMU’s GitLab instance. zlib had already been packaged7.

Tip

You can just go look at the repo8, but here I am trying to write a blog post, so I might as well note some tricks I learned along the way.

QEMU has some python and shell scripts that generate files. Zig has the std.Build.Step.Run type for that, but one usage detail eluded me for awhile. By default, the exit code of the command will not be checked; you need to opt in to that via

const cmd = b.addSystemCommand(&[_][]const u8{"python3"});
cmd.expectExitCode(0);

Results

$ zig build
...
$ ls -lh zig-out/bin/
total 36M
-rwxr-xr-x 1 jordan jordan 36M Apr 26 18:16 qemu-system-x86_64
$ ldd zig-out/bin/qemu-system-x86_64 
        not a dynamic executable
$ zig-out/bin/qemu-system-x86_64 --help | head -n 3
QEMU emulator version dev (8.2.90)
Copyright (c) 2003-2024 Fabrice Bellard and the QEMU Project developers
usage: qemu-system-x86_64 [options] [disk_image]

It works! But it’s far from done. I actually vendored a few configuration headers that were generated by meson, which is of course cheating. Writing configuration headers using Zig is not difficult, but converting all the logic that determines the values is going to be a chore.

And sure, that looks like a large executable, but it’s only about double the size of the binary with dynamic linking (on Debian Bookworm):

$ ls -lh $(command -v qemu-system-x86_64)
-rwxr-xr-x 1 root root 16M Feb 21 14:14 /usr/bin/qemu-system-x86_64
$ ldd $(command -v qemu-system-x86_64)
48

Looks like the ammount of options I was able to disable helped quite a bit.

So I have garnered a bit of experience with the zig build system and compiled a static build of QEMU, but I haven’t fully realized the vision I started with. I want to distribute a tool that uses QEMU without asking the user to install any prerequisites.

A quick peek at system/main.c, the entrypoint for QEMU, and the power of Zig’s interop with C really shines:

const std = @import("std");
const process = std.process;

const c = @cImport({
    @cInclude("qemu/osdep.h");
    @cInclude("sysemu/sysemu.h");
});

pub fn main() !void {
    c.qemu_init(@intCast(std.os.argv.len), @ptrCast(std.os.argv.ptr));
    const status = c.qemu_main_loop();
    c.qemu_cleanup(status);
    process.exit(@intCast(status));
}

No more std.ChildProcess; I’m just a std.posix.fork() and a function call away from embedding a purpose-built QEMU into my dev tool.