Luke's Web Site

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 #ifndefs 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 @imports 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 @cImports or @cIncludes 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 @imports 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?