Using Zig As Cross Platform C Toolchain
I like to learn at least one new programming language every year - it’s mostly for fun - they are rarely useful for projects that I actually have to ship. This year, I’ve decided to look at Zig. After skimming through the official documentation and finishing the ray tracing in one weekend exercise, I realize that Zig can help with my game even though it has zero lines of Zig code.
A little background information. Most of the gameplay code is written in the game engine’s scripting environment in TypeScript. But there are a few native binaries that I need to ship as well. Given the game supports six platforms: Windows, Mac, Linux, Web, iOS, and Android, this process is quite tedious (although usually these binaries are only needed for a subset of platforms)
Currently, I do not use a cross-platform compilation toolchain - because they are hard to set up and have a lot of issues. Instead, I set up a toolchain for each target platform (I need it anyway to ship the game executable), compile the binary, put it in the repository, and call it a day. It’s not an elegant solution but it works - as long as I don’t update them very often.
Compile C with Zig
Zig has a decent cross compilation support. Not only that, it also provides a way to build C code: zig cc
Let’s try with an example project. Duktape is an embeddable JavaScript engine with a small footprint, written in C99. Since Windows is my main development environment, there are two choices: MSVC or MinGW(GCC/Clang). Each involves gigabytes of downloads and a complex installation process.
Compared to that, setting up Zig is simple: download the prebuild binary for Windows, extract in a directory, add zig
executable to PATH
and we are good to go.
Now we can try to compile the example that comes with DukTape. Here’s the command to do that in the official documentation:
gcc -std=c99 -o hello -Isrc src/duktape.c examples/hello/hello.c -lm
So we can replace gcc
with zig cc
(Note: I am using Git Bash/MinGW64 shell)
zig cc -std=c99 -o hello -Isrc src/duktape.c examples/hello/hello.c -lm
The command finishes without an error - that’s a good sign. Let’s try to run it
$ ./hello
Illegal instruction
Oops, that doesn’t look good. After some googling, it turns out that by default, zig will pass -fsanitize=undefined -fsanitize-trap=undefined
to Clang, which will cause undefined behavior (UB) to generate “illegal instruction”. In the DukTape code, there are some UB but since our purpose is not to fix it (we have a game to ship!), let’s ignore it for now by adding -fno-sanitize=undefined
zig cc -std=c99 -o hello -Isrc src/duktape.c examples/hello/hello.c -lm -fno-sanitize=undefined
Now it looks better!
$ ./hello
Hello world!
2+3=5
This is very impressive considering all I need is a ~60MB self-contained Zig compiler on the Windows platform. But it’s not only that - zig cc
also supports cross-compilation out of the box.
Cross Compilation
Let’s try compiling the same code on Windows for macOS. This can be achieved by simply adding a target
zig cc -std=c99 -o hello -Isrc src/duktape.c examples/hello/hello.c -lm -fno-sanitize=undefined -target x86_64-macos
Then copy the binary to my Macbook Pro (Intel), make it executable, and run it
% chmod +x ./hello
% ./hello
Hello world!
2+3=5
It just works!
Working With C in Zig
At this point, my game, which has zero line of Zig code, can already benefit from the wonderful toolchain that Zig comes with. But since we are here, let’s maybe convert some of the C code to Zig - after all Zig is a more modern language and is much more pleasant to use.
We can use zig init-exe
to initialize an empty Zig project template. We will get a build script build.zig
and a main source file src/main.zig
. To use Duktape in this Zig project, let’s copy duktape.c
duktape.h
and duk_config.h
to src/vendor
. There are two main approaches to import C code: @cImport
and zig translate-c
. They both utilize the same underlying infrastructure but the latter offers more flexibility.
First, we need to translate duktape.h
to Zig and write it to a file
zig translate-c -lc duktape.h > duktape.zig
Then we can simply import the Zig file in our main
const std = @import("std");
const duktape = @import("vendor/duktape.zig");
pub fn main() !void {
const ctx = duktape.duk_create_heap_default();
const code = duktape.duk_peval_string(ctx, "5+1");
std.log.info("peval result code = {}", .{code});
const result = duktape.duk_get_int(ctx, -1);
std.log.info("peval result = {}", .{result});
}
Another benefit of zig translate-c
is that we can get some nice IntelliSense support from IDE/Editor via Zig Language Server.
To run our project, we need to modify the build script. The default project template should have a section that looks like this:
const exe = b.addExecutable("duktape-zig", "src/main.zig");
exe.setTarget(target);
exe.setBuildMode(mode);
exe.install();
We need to add the C source file and enable libc
linking (we also need to pass -fno-sanitize=undefined
to Clang as discussed above)
const flags = [_][]const u8 {
"-fno-sanitize=undefined",
};
exe.addCSourceFile("src/vendor/duktape.c", &flags);
exe.linkLibC();
We can now run the project by using zig build run
. However, if we do this, we will get an error:
$ zig build run
.\src\vendor\duktape.zig:3670:65: error: expected type '?fn(?*anyopaque, usize) callconv(.C) ?*anyopaque', found '?*anyopaque'
pub inline fn duk_create_heap_default() @TypeOf(duk_create_heap(NULL, NULL, NULL, NULL, NULL)) {
Let’s inspect the translated duktape.zig
file:
pub const NULL = @import("std").zig.c_translation.cast(?*anyopaque, @as(c_int, 0));
pub inline fn duk_create_heap_default() @TypeOf(duk_create_heap(NULL, NULL, NULL, NULL, NULL)) {
return duk_create_heap(NULL, NULL, NULL, NULL, NULL);
}
pub const duk_alloc_function = ?fn (?*anyopaque, duk_size_t) callconv(.C) ?*anyopaque;
pub extern fn duk_create_heap(alloc_func: duk_alloc_function, realloc_func: duk_realloc_function, free_func: duk_free_function, heap_udata: ?*anyopaque, fatal_handler: duk_fatal_function) ?*duk_context;
This looks complicated at first but after some digging, we can see that duk_create_heap
accepts an optional function type, but NULL
, which is used for bridging C’s NULL
, cannot be coerced into that - thus the complaint from the compiler. There’s in fact an open issue about this. If we look at duktape.h
source file, it looks like this
#define duk_create_heap_default() \
duk_create_heap(NULL, NULL, NULL, NULL, NULL)
A simple solution to get our program to compile is to use Zig’s null
instead of NULL
:
pub inline fn duk_create_heap_default() @TypeOf(duk_create_heap(null, null, null, null, null)) {
return duk_create_heap(null, null, null, null, null);
}
Now if we do zig build run
, we will get
$ zig build run
info: peval result code = 0
info: peval result = 6
Conclusion
Traditionally, if I need to work with C files but want some batteries included, C++ is the language to go to. C++ has a large feature set and a heavy toolchain but most of the time, I only use a small subset of features. Plus C++ doesn’t spark joy.
Zig fills this niche nicely. It is easy to set up, has a modern toolchain and build system that works well on different platform. It comes with cross-compilation support out of the box. The language is easy to pick up and an absolute joy to work with. If you don’t need the sledgehammer that is C++, give Zig a try.