Replacing C with Zig, Bit by Bit
Target audience
This post is aimed at people who are interested in learning the basics of the Zig build system and Zig’s interop with C. You should probably know a little bit of both C and Zig if you want to follow along, but you don’t need to be an expert in either language.
The following is a recreation of a blog post by
Marc Tiehuis called
Iterative Replacement of C with Zig. In the original post, the author starts with a tiny multi-file C
project and converts it one file at a time to Zig. It’s a great post!
The only problem with it is that it was written in 2017, and since then,
Zig has made almost every breaking change you can think of. I’ll be
following the author’s original steps while expanding and updating the
Zig code and the explanations. I’ll do my best to keep the post up to
date with the newest point release of Zig, which is currently
0.15.1
. (If you notice it’s out of date, feel free to nudge
me by email! My email is luke @ this domain.)
Try this at home!
In order to make a useful demo, the project we’re converting from C to Zig needs to have a few separate files. There are 4 source files, 3 header files, and a Makefile. It’s a bit hard to track just by reading, so consider making a copy of the project on your own computer and following along.
The Project
Our starting point in C is exactly the same as in Marc’s original post.
> tree
.
├── compute_helper.c
├── compute_helper.h
├── compute.c
├── compute.h
├── display.c
├── display.h
├── main.c
└── Makefile
1 directory, 8 files
main.c
#include "display.h"
#include "compute.h"
int main(void) {
display_char(compute('A'));
}
display.c
#include <stdio.h>
void display_char(char c) {
printf("%c\n", c);
}
compute.c
#include "compute_helper.h"
char compute(char a) {
return compute_helper(a) + 5;
}
compute_helper.c
char compute_helper(char a) {
return a + 1;
}
Header Files
C header files usually include #ifndef
s and some other
stuff, but I’m keeping it simple because the headers aren’t the star of
the show today.
// display.h
void display_char(char);
// compute.h
char compute(char);
// compute_helper.h
char compute_helper(char);
Makefile
I’m doing this on macOS, so I swapped the original gcc
for
clang
which is preinstalled. Notice that we’re using the
C99 standard. This is just for demonstration — we want some kind of
compiler flag to play with as we replace all the parts with Zig.
# Makefile
SRCS := compute.c compute_helper.c display.c main.c
OBJS := $(SRCS:%.c=build/%.o)
main: $(OBJS)
clang -o main $(OBJS)
$(OBJS): build/%.o: %.c | mkdirs
clang -std=c99 -c $< -o $@
mkdirs:
@mkdir -p build
clean:
rm -rf build main
If you’re following along, now’s a good time to see if everything works.
If you run make
, you should get output like this:
> make
clang -std=c99 -c compute.c -o build/compute.o
clang -std=c99 -c compute_helper.c -o build/compute_helper.o
clang -std=c99 -c display.c -o build/display.o
clang -std=c99 -c main.c -o build/main.o
clang -o main build/compute.o build/compute_helper.o build/display.o build/main.o
And let’s run the executable:
> ./main
G
Working as intended.
The Zig Build System
The first step we’ll take is replacing our Makefile with Zig’s build system.
Here we deviate from Marc’s original post. Zig has made many changes to
the build system, and while there are
many examples of
complicated real-world C projects building with Zig, it can be difficult
to find a small digestible example. Here’s my take on a minimal
build.zig
for our C project.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "main",
.root_module = b.createModule(.{
.target = b.graph.host,
}),
});
const source_files = .{
"compute.c",
"compute_helper.c",
"display.c",
"main.c",
};
const flags = .{
"-std=c99",
};
exe.addCSourceFiles(.{
.files = &source_files,
.flags = &flags,
});
exe.linkLibC();
b.installArtifact(exe);
}
I find that it’s easiest to think about the Zig build system as a sort
of assembly line. We start by creating our executable exe
,
then we add in our C code using exe.addCSourceFiles()
, then
we link to the C standard library, then finally we install the
executable. In our case, “install” just means save the executable to our
output directory, zig-out/
. It’s more verbose than a
Makefile, but it’s a bit simpler conceptually since we don’t have to
think about object files (yet).
If everything is working, we can run zig build
to get an
executable located at zig-out/bin/main
.
> zig build
> ./zig-out/bin/main
G
Great, it’s working.
Bonus: Run Step
Since we’re going to run this code a lot, we can add a step in our
build.zig
to run the executable for us. Here’s the snippet
we’ll add, right at the end of the build
function:
// build.zig
pub fn build(b: *std.Build) void {
// ...
const run = b.addRunArtifact(exe);
// This next part is new
const run_step = b.step("run", "Run the executable");
run_step.dependOn(&run.step);
}
First we create run
, which is an action that runs the
executable exe
. Next, we add a step
to our
build process, which analogous to a target in a Makefile — basically
just a named thing we can ask for from the command line. We give
it a name, run
, and a description,
Run the executable
(the description appears in the help
menu, accessible via zig build -h
). Finally we link the two
together: we make our run step depend on the run action so
the action executes when we call the step. Once we’ve done this, we can
use it like this:
> zig build run
G
Great, it works! Let’s move on to replacing some of our C source code.
First C Replacement
Let’s start by replacing compute.c
with
compute.zig
.
Here’s compute.c
for reference:
// compute.c
#include "compute_helper.h"
char compute(char a) {
return compute_helper(a) + 5;
}
And our new compute.zig
:
// compute.zig
const c = @cImport({
@cInclude("compute_helper.h");
});
export fn compute(a: u8) u8 {
return c.compute_helper(a) + 5;
}
Here we’re using Zig’s @cImport
to bring in
compute_helper
. We bind it to a variable just like
@import
. There is another way to do this using
zig translate-c
, but @cImport
makes more sense
to me and they both work fine. If there’s a good reason to use
translate-c
that I don’t know about, please let me know!
Now we need to adapt build.zig
to build both Zig and C
source code, and link them together.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "main",
.target = b.graph.host,
});
const source_files = .{
"compute_helper.c",
"display.c",
"main.c",
// remove "compute.c"
};
const flags = .{
"-std=c99",
};
exe.addCSourceFiles(.{
.files = &source_files,
.flags = &flags,
});
exe.linkLibC();
// this part is new
const compute = b.addObject(.{
.name = "compute",
.root_module = b.createModule(.{
.root_source_file = b.path("compute.zig"),
.target = b.graph.host,
}),
});
compute.addIncludePath(b.path(".")); // <- note this!
exe.addObject(compute);
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the executable");
run_step.dependOn(&run.step);
}
Since we’re linking C code with Zig code, our
build.zig
gets more verbose (don’t worry, it’ll get simpler
again by the end). We will use the b.addObject
function to
create another output of the build system, an object file (Zig calls
these outputs “artifacts”). Creating an object artifact follows the same
pattern as creating an executable using b.addExecutable
.
Did you see the note comment?
compute.addIncludePath(b.path(".")); // <- note this!
By default Zig doesn’t allow us to @cImport
random header
files from just anywhere, so we also need to add an include path to our
compute
object. We’re just adding the current directory but
you can imagine getting a lot more granular in a large project with many
nested folders.
Fun fact: if you want to make an object out of multiple Zig files, Zig
starts from the .root_source_file
and traverses the graph
of @import
s to resolve dependencies, so you don’t need to
manually make objects out of each one. We’ll use that feature later, but
for now let’s build and run again to make sure it all works.
> zig build run
G
Next up: display.c
!
Using the Zig Standard Library
display.c
uses the C standard library, so
display.zig
will use Zig’s own stdlib.
Before:
// display.c
#include <stdio.h>
void display_char(char c) {
printf("%c\n", c);
}
And after:
// display.zig
const std = @import("std");
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
export fn display_char(c: u8) void {
stdout.print("{c}\n", .{c}) catch {};
stdout.flush() catch {};
}
Zig’s printing is buffered, but we need to provide the buffer.
Unfortunately this makes Zig’s hello world about on par with Java in
terms of boilerplate, but thankfully we have
std.debug.print
for printf-debugging.
Normally we would use try
instead of
catch {}
for an IO operation since those can fail. However,
we can’t throw Zig errors over the boundary into C code, and
display_char
is still being called from C land, so we’re
choosing to swallow the error for now.1
Now that we have more than one Zig source, let’s adapt
build.zig
to make object files for all of them. Look for
the comments to see where the changes are.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "main",
.target = b.graph.host,
});
const source_files = .{
"compute_helper.c",
"main.c",
// remove "display.c"
};
const flags = .{
"-std=c99",
"-nostdlib",
};
exe.addCSourceFiles(.{
.files = &source_files,
.flags = &flags,
});
// this is new
const zig_source_files = .{
"compute",
"display",
};
// this is the important part
inline for (zig_source_files) |source| {
const obj = b.addObject(.{
.name = source,
.root_source_file = b.path(source ++ ".zig"),
.target = b.graph.host,
});
obj.addIncludePath(b.path("."));
exe.addObject(obj);
}
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the executable");
run_step.dependOn(&run.step);
}
Instead of repeating ourselves for each object file we need to create,
we’re making use of the fact that a build.zig
is written in
Zig, a full programming language. We have for
loops!
We’re using a little trick to make object creation easier inside the
loop. Each object needs a name and a path, so instead of maintaining a
list of names and a list of paths, we’re listing the names with no file
extension and appending .zig
inside the loop to get the
filepath.
One problem I ran into here was getting around the Zig compiler to
actually get this code to execute. I first tried a basic
for
loop, but Zig didn’t like that and marking the loop as
inline
fixed the issue. That doesn’t make sense to me,
because from my understanding, inline
just makes the loop
unroll at compile time, but build.zig
always runs at
compile time. What do we need inline
for? If you have any
insight into why this is the way it is, please let me know!
Zigifying Imports
Next up is compute_helper.c
, which is called by
compute.zig
. Since compute.zig
is already in
Zig land, we don’t have to fuss with @cImport
s or
@cInclude
s anymore, we can just @import
it
directly.
Here’s the old compute_helper.c
:
// compute_helper.c
char compute_helper(char a) {
return a + 1;
}
And the new compute_helper.zig
:
// compute_helper.zig
pub fn compute_helper(a: u8) u8 {
return a + 1;
}
And compute.zig
’s import changes from
@cImport
to just @import
.
// compute.zig
const c = @import("compute_helper.zig"); // <- this is new
export fn compute(a: u8) u8 {
return c.compute_helper(a) + 5;
}
Finally, we update build.zig
. You might think we would add
compute_helper
to the list of Zig sources, but we don’t
need to: as I mentioned earlier, Zig finds dependencies using
@import
statements and brings them in automatically. So we
just delete compute_helper.c
from the list of C source
files and we’re done. We can also delete
compute_helper.h
since we’re not exposing it to C anymore.
// build.zig
pub fn build(b: *std.Build) void {
// ...
const source_files = .{
"main.c",
// remove "compute_helper.c"
};
// ...
const zig_source_files = .{
"compute",
"display",
// no need to add anything here
};
// ...
}
This should build without any issues:
> zig build run
G
The Final File
It’s time to bring over the main act, by which I mean
main.c
!
Before we had this:
// main.c
#include "display.h"
#include "compute.h"
int main(void) {
display_char(compute('A'));
}
And now we have main.zig
:
// main.zig
const display_char = @import("display.zig").display_char;
const compute = @import("compute.zig").compute;
pub fn main() void {
display_char(compute('A'));
}
This is the part where our build.zig
gets dramatically
simpler. Since we’re no longer linking with C, we no longer have to
manually list source files — Zig’s trick of figuring out which files it
needs to compile based on @import
s can do everything for
us. We’ll tell Zig where to start by adding a
.root_source_file
field to our executable exe
,
and it will handle the rest. We get to drop the source file lists, the
adding objects and source files, and the inline for
loop.
Here it is:
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "main",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = b.graph.host,
}),
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the executable");
run_step.dependOn(&run.step);
}
Now everything should just work.
> zig build run
G
And we’re done! We’ve converted our C project to a Zig project.
Closing
In the interest of keeping things simple, we’ve stuck with an extremely
minimal build.zig
. We could do a lot more with it, like
supporting cross-compilation or Zig’s release modes, but we’ll leave
that for another time. The build.zig
in the default
zig init
is a great place to go next.
If you’re looking for something more in-depth, you might appreciate the
sources I used while writing this blog post. Of course the
original post by Marc Tiehuis
deserves credit, and there’s also an excellent free online book called
Introduction to Zig by Pedro Duarte Faria. The author updates it regularly, so be sure to check it out! It’s the
most approachable Zig resource I’ve found anywhere. There’s also the
standard library documentation (I hope you like reading source code)
available by running zig std
in your terminal, and
zig.guide and the
Zig language reference
are always useful.
#writing
1: I haven’t written enough Zig to know for sure, but I suspect this is one of the few significant pain points in a C-to-Zig conversion. Error handling has to be updated every time the language boundary moves. I’d love to hear from anyone who has real-world experience with this. Is it a big deal or a non-issue?