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
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
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
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
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();
}
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();
}
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
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();
}
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"});
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;
}
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();
}
Let's build the program and invoke it with an URL:
zig build
./zig-out/bin/downloader https://mq32.de/public/ziggy.txt
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();
}
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();
}
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();
}
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();
}
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();
}
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();
}
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();
}
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();
}
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();
}
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!
Oldest comments (13)
Amazing!
Still sometimes surprise me at how easy it is compared to other existing building systems.
Fantastic - exactly the kind of information I've been looking for!
Very happy to help!
Really nice!
Awesome, very very useful for newbie
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.
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 (usuallymain
)Just put your starting file into any folder you like and use a relative path to that in the
build.zig
file inb.addExecutable
(or similar)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?
Yeah, the next article will be on composition of different projects. Libraries, executables, packages and everything mixed!
Hi, This entire tutorial is now outdated as of Zig 0.11.0. Can you please update it?
Zig API is changed, code examples does not work. Please, add a disclaimer notifyng Zig version cap supported