Zig NEWS

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

zig build explained - part 2

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

The Zig build system is still missing documentation and for a lot of people, this is a reason 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.

To get started, you should check out the first article which gives an overview and introduction into the build system. In this chapter, we're going to tackle C and C++ projects and how to solve common tasks.

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 for functions you see in the build script. Everything is implemented in the standard library, there is no hidden build magic happening.

Note

From here on, i will always just provide a minimal build.zig that will explain what is necessary to solve a single problem. If you want to learn how to glue all these files together into a nice and comfy build file, read the first article.

Note

You will find all source files referenced in the build scripts in this Git repository. So if you want to try building those examples, just go ahead!

Building C code on the command line

Zig features two ways to build C source which can be easily confused when to use which.

Using zig cc

Zig ships clang, the LLVM c compiler. The first one here is zig cc or zig c++ which is a near-1:1 frontend to clang. I will only cover this topic shortly, as we cannot directly access those features from build.zig (and we don't need to!).

zig cc, as said, is the clang frontend exposed. You can directly set your CC variable to zig cc and use Makefiles, CMake or other build systems with zig cc instead of gcc or clang, allowing you to utilize the full cross compilation experience of Zig for already existing projects. Note that this is the theory, as a lot of build systems cannot handle spaces in the compiler name. A workaround for that problem is a simple wrapper script or tool that will just forward all arguments to zig cc.

Assuming we have a project build from main.c and buffer.c, we can build it with the following command line:

zig cc -o example buffer.c main.c
Enter fullscreen mode Exit fullscreen mode

This will build us a nice executable called example (on Windows, you should use example.exe instead of example). Contrary to normal clang, Zig will insert a -fsanitize=undefined by default, which will catch your use of undefined behaviour.

If you do not want to use this, you have to pass -fno-sanitize=undefined or use an optimized release mode like -O2.

Cross-compilation with zig cc is as easy as with Zig itself:

zig cc -o example.exe -target x86_64-windows-gnu buffer.c main.c
Enter fullscreen mode Exit fullscreen mode

As you see, just passing a target triple to -target will invoke the cross compilation. Just make sure you have all your external libraries prepared for cross-compilation as well!

Using zig build-exe and others

The other way to build a C project with the Zig toolchain is the same way as building a Zig project:

zig build-exe -lc main.c buffer.c
Enter fullscreen mode Exit fullscreen mode

The main difference here is that you have to pass -lc explicitly to link to libc, and the executable name will be derived from the first file passed. If you want to use a different executable name, pass --name example to get the example file again.

Cross-compilation is also the same, just pass -target x86_64-windows-gnu or any other target triple:

zig build-exe -lc -target x86_64-windows-gnu main.c buffer.c
Enter fullscreen mode Exit fullscreen mode

You will notice that with this build command, Zig will automatically attach the .exe extension to your output file and will also generate a .pdb debug database. If you pass --name example here, the output file will also have the correct .exe extension, so you don't have to think about this here.

Building C code from build.zig

So how can we build our small two-file example with build.zig?

First, we need to create a new compilation target:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("example", null);
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

Then, we a add our two C files via addCSourceFile:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("main.c", &[_][]const u8 {});
    exe.addCSourceFile("buffer.c", &[_][]const u8 {});
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

The first argument addCSourceFile is the name of the C or C++ file to add, the second argument is a list of command line options to use for this file.

Note that we pass null to addExecutable, as we don't have a Zig source file we want to build.

If we now invoke zig build, we'll get a nasty error message:

error(compilation): clang failed with stderr: /tmp/ba0d5c93/main.c:1:10: fatal error: 'stdio.h' file not found

error(compilation): clang failed with stderr: /tmp/ba0d5c93/buffer.c:1:10: fatal error: 'stdlib.h' file not found

/tmp/ba0d5c93/main.c:1:1: error: unable to build C object: clang exited with code 1
/tmp/ba0d5c93/buffer.c:1:1: error: unable to build C object: clang exited with code 1
Enter fullscreen mode Exit fullscreen mode

This is because we didn't link libc. Let's add this quickly:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("main.c", &[_][]const u8 {});
    exe.addCSourceFile("buffer.c", &[_][]const u8 {});
    exe.linkLibC();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

Now, invoking zig build will just run fine and produce a nice little executable in zig-out/bin. Sweet, we've build our first little C project with Zig!

If you want to skip checking for undefined behaviour in your C code, you have to add the option to your invocation:

// main.c is fine, we just want a normal build
exe.addCSourceFile("main.c", &[_][]const u8{});
// buffer.c has a bug somewhere we don't care about right now.
// just ignore the UBsan here:
exe.addCSourceFile("buffer.c", &[_][]const u8{"-fno-sanitize=undefined"});
Enter fullscreen mode Exit fullscreen mode

Using external libraries

Usually, C projects depend on other libraries, often pre-installed on Unix systems or available via package managers.

To demonstrate that, we create a small tool that will download a file via the curl library that will print the contents of that file to the standard output:

#include <stdio.h>
#include <curl/curl.h>

static size_t writeData(void *ptr, size_t size, size_t nmemb, FILE *stream) {
    size_t written;
    written = fwrite(ptr, size, nmemb, stream);
    return written;
}

int main(int argc, char ** argv) 
{
  if(argc != 2)
    return 1;

  char const * url = argv[1];
  CURL * curl = curl_easy_init();
  if (curl == NULL)
    return 1;

  curl_easy_setopt(curl, CURLOPT_URL, url);
  curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeData);
  curl_easy_setopt(curl, CURLOPT_WRITEDATA, stdout);
  CURLcode res = curl_easy_perform(curl);
  curl_easy_cleanup(curl);

  if(res != CURLE_OK)
    return 1;

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

To build this, we need to provide the right arguments to the compiler for include paths, libraries and whatsoever. Luckily, Zig has builtin integration for pkg-config we can use:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("downloader", null);
    exe.addCSourceFile("download.c", &[_][]const u8{});
    exe.linkLibC();
    exe.linkSystemLibrary("libcurl"); // add libcurl to the project
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

Let's build the program and invoke it with an URL:

zig build
./zig-out/bin/downloader  https://mq32.de/public/ziggy.txt
Enter fullscreen mode Exit fullscreen mode

If you don't want to invoke pkg-config, but just pass an argument to link a library, you can use linkSystemLibraryName, which will just append the argument to -l on the command line interface. This might be needed when you perform a cross-compile.

Configuring the paths

As we cannot use pkg-config for cross-compilation projects or we want to use prebuilt propietary libraries like the BASS audio library, we need to configure include paths and library paths.

This is done via the functions addIncludeDir and addLibPath:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("player", null);
    exe.addCSourceFile("bass-player.c", &[_][]const u8{});
    exe.linkLibC();
    exe.addIncludeDir("bass/linux");
    exe.addLibPath("bass/linux/x64");
    exe.linkSystemLibraryName("bass");
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

Both addIncludeDir and addLibPath can be called many times to add several paths to the compiler. Those functions will not only affect C code, but Zig code as well, so @cImport will have access to all headers available in the include path.

Include paths per file

So if we need to have different include paths per C file, we need to solve that a bit differently:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("multi-main.c", &[_][]const u8{});
    exe.addCSourceFile("multi.c", &[_][]const u8{ "-I", "inc1" });
    exe.addCSourceFile("multi.c", &[_][]const u8{ "-I", "inc2" });
    exe.linkLibC();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

As we can still pass any C compiler flags via addCSourceFile, we can also set include dirs here manually.

The example above is very constructed, so you might be wondering why you might need something like this. The answer is that some libraries have very generic header names like api.h or buffer.h and you want to use two different libs which share header names.

Building a C++ project

We only covered C files until now, but building a C++ project isn't much harder. You still use addCSourceFile, but just pass a file that has a typical C++ file extension like cpp, cxx, c++ or cc:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("main.c", &[_][]const u8{});
    exe.addCSourceFile("buffer.cc", &[_][]const u8{}); 
    exe.linkLibC();
    exe.linkLibCpp();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we also need to call linkLibCpp which will link the c++ standard library shipped with Zig.

And that's pretty much all you need to know about building C++ files, there is not much more magic to it.

Specifying the language versions

Imagine you create a huge project and you have very old and newer C or C++ files and they might be written in different language standards. For this, we can use the compiler flags to pass -std=c90 or -std=c++98:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("main.c", &[_][]const u8{ "-std=c90"}); // use ANSI C
    exe.addCSourceFile("buffer.cc", &[_][]const u8{ "-std=c++17" });  // use modern C++
    exe.linkLibC();
    exe.linkLibCpp();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

Conditional compilation

Compared to Zig, C and C++ have very tedious ways of doing conditional compilation. Due to the lack of lazy evaluation, sometimes files have to be included/excluded based on the target. You also have to provide macro defines to enable/disable certain project features.

Both variants are easy to handle with the Zig build system:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const target = b.standardTargetOptions(.{});
    const use_platform_io = b.option(bool, "platform-io", "Uses the native api instead of the C wrapper") orelse true;

    const exe = b.addExecutable("example", null);
    exe.setTarget(target);
    exe.addCSourceFile("print-main.c", &[_][]const u8{});
    if (use_platform_io) {
        exe.defineCMacro("USE_PLATFORM_IO", null);
        if (exe.target.isWindows()) {
            exe.addCSourceFile("print-windows.c", &[_][]const u8{});
        } else {
            exe.addCSourceFile("print-unix.c", &[_][]const u8{});
        }
    }
    exe.linkLibC();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

With defineCMacro we can define our own macros like we pass them with the -D compiler flag. The first argument is the macro name, the second value is an optional that, if not null, will set the value of the macro.

Conditional inclusion of files is as easy as using an if, as you do exactly this. Just don't call addCSourceFile based on any constraint you want to define in your build script. Only include for a certain platform? Check out the above script how to do that. Include a file based on the system time? Maybe a bad idea, but it's possible!

Compiling huge projects

As most C (and even worse, C++) projects have a huge amount of files (SDL2 has 411 C files and 40 C++ files), we have to find a easier way to build them. Calling addCSourceFile 400 times just doesn't scale well.

So the first optimization we can do here, is putting our c and c++ flags into their own variable:

const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    const flags = [_][]const u8{
        "-Wall",
        "-Wextra",
        "-Werror=return-type",
    };
    const cflags = flags ++ [_][]const u8{
        "-std=c99",
    };

    const cxxflags = cflags ++ [_][]const u8{
        "-std=c++17", "-fno-exceptions",
    };

    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("main.c", &cflags);
    exe.addCSourceFile("buffer.cc", &cxxflags);
    // ... and here: thousand of lines more!
    exe.linkLibC();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

This allows easy sharing of the flags between different components of a project and between different languages.

There is another variant of addCSourceFile which is called addCSourceFiles. Instead of a file name, it takes a slice of file names to all source files buildable. This allows us to collect all files in a certain folder:

const std = @import("std");

pub fn build(b: *std.build.Builder) !void {
    var sources = std.ArrayList([]const u8).init(b.allocator);

    // Search for all C/C++ files in `src` and add them
    {
        var dir = try std.fs.cwd().openDir("src", .{ .iterate = true });

        var walker = try dir.walk(b.allocator);
        defer walker.deinit();

        const allowed_exts = [_][]const u8{ ".c", ".cpp", ".cxx", ".c++", ".cc" };
        while (try walker.next()) |entry| {
            const ext = std.fs.path.extension(entry.basename);
            const include_file = for (allowed_exts) |e| {
                if (std.mem.eql(u8, ext, e))
                    break true;
            } else false;
            if (include_file) {
                // we have to clone the path as walker.next() or walker.deinit() will override/kill it
                try sources.append(b.dupe(entry.path));
            }
        }
    }

    const exe = b.addExecutable("example", null);
    exe.addCSourceFiles(sources.items, &[_][]const u8{});
    exe.linkLibC();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we can easily search for all files in a certain folder, match on the file name and add them to our source collection. We then just have to call addCSourceFiles once per file collection and are ready to rock.

You can make nice rules to match the exe.target and folder name to include only generic files and the right ones for your platform based on that. But this exercise is left to the reader.

Note: Other build systems care for file names, the Zig one doesn't! For example, you cannot have two files called data.c in a qmake project! Zig doesn't care, add as many files with the same name as you want, just make sure they are in different folders 😏.

Compiling Objective C

I totally forgot! Zig does not only support building C and C++, but also supports building Objective C via clang!

The support is not on the level of a C or C++, but at least on macOS you can already compile Objective C programs and add frameworks:

const std = @import("std");

pub fn build(b: *std.build.Builder) !void {
    const exe = b.addExecutable("example", null);
    exe.addCSourceFile("main.m", &[_][]const u8{});
    exe.linkFramework("Foundation");
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

Here, linking libc is implicit, as adding a Framework will automatically force libc to be linked. Isn't this cool?

Mixing C and Zig source code

Now, a final chapter: Mixing C code and Zig code!

To do this, we simply set the second parameter in addExecutable to a file name and we hit compile!

const std = @import("std");

pub fn build(b: *std.build.Builder) !void {
    const exe = b.addExecutable("example", "main.zig");
    exe.addCSourceFile("buffer.c", &[_][]const u8{});
    exe.linkLibC();
    exe.install();
}
Enter fullscreen mode Exit fullscreen mode

And that's all that needs to be done! Or is it?

Well, there is actually one case that isn't supported well right now:
The entry point of your application must be in Zig code right now, as the root file has to export a pub fn main(…) ….
So if you port over code from a C project to Zig and you want to go for that way, you have to forward argc and argv to your C code and rename the main in C to some other function (for example oldMain) and call it from Zig. If you need argc and argv, you can get them via std.process.argsAlloc. Or even better: Rewrite your entry point in Zig and remove some C from your project!

Conclusion

You should be ready now to port over pretty much any C/C++ project you have to build.zig assuming you only build a single output file.

If you need more than one build artifact, for example a shared library and a executable, you should read the next article which is about composing several projects in one build.zig to create a convenient build experience.

Stay tuned!

Discussion (9)

Collapse
tedk profile image
Ted

This is a great series.

Could you please do something on how to add 'folders' and 'zig files in folders' to the build system.
I find structuring projects somewhat, i.e, ading folders and files to what is automatically created after zig build init, somewhat difficult.
Many thanks.

Collapse
xq profile image
Felix "xq" Queißner Author

There is no "adding folders or files" for Zig. Zig uses relative paths for imports and builds all dependencies from a single .zig file. This is very different from C, where you usually have tons of files in the build process. Zig only requires the "root" one which contains the application entry point (usually main)

Just put your starting file into any folder you like and use a relative path to that in the build.zig file in b.addExecutable (or similar)

Collapse
kassane profile image
Matheus C. França

Amazing!
Still sometimes surprise me at how easy it is compared to other existing building systems.

Collapse
clarkthan profile image
Clark Than

Awesome, very very useful for newbie

Collapse
jackji profile image
jack

Really nice!

Collapse
pjz profile image
Paul Jimenez

Hopefully your next one will focus on using C libraries from within zig? Like maybe write your downloader again, but in zig, and have it import the curl library and use it?

Collapse
xq profile image
Felix "xq" Queißner Author

Yeah, the next article will be on composition of different projects. Libraries, executables, packages and everything mixed!

Collapse
david_vanderson profile image
David Vanderson

Fantastic - exactly the kind of information I've been looking for!

Collapse
xq profile image
Felix "xq" Queißner Author

Very happy to help!