Iterative Replacement of C with Zig

Zig is a programming language in a similar realm as C. Being more modern, it has a number of useful constructs such as sum types, compile-time introspection, improved error handling and no preprocessor!

This post will not describe the language itself (check the project page for that), but will show how it can be used to convert an existing C code-base into zig. We will look at a simplistic example, but the general strategy remains the same.

The zig compiler that will be used in this post can be found here.

The finished conversion result is at this repository. For each heading, there is a corresponding commit which describes all changes made at each point in the conversion process.

The Project

commit

The project we will be replacing has the following structure:

$ tree .
.
├── compute.c
├── compute.h
├── compute_helper.c
├── compute_helper.h
├── display.c
├── display.h
├── main.c
└── Makefile

The contents of the files are:

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;
}

Makefile

SRCS := compute.c compute_helper.c display.c main.c
OBJS := $(SRCS:%.c=build/%.o)

main: $(OBJS)
	gcc -o main $(OBJS)

$(OBJS): build/%.o: %.c | mkdirs
	gcc -std=c99 -c $< -o $@

mkdirs:
	@mkdir -p build

clean:
	rm -rf build main

The .h file contents simply expose their corresponding .c implementations.

$ make
gcc -std=c99 -c compute.c -o build/compute.o
gcc -std=c99 -c compute_helper.c -o build/compute_helper.o
gcc -std=c99 -c display.c -o build/display.o
gcc -std=c99 -c main.c -o build/main.o
gcc -o main build/compute.o build/compute_helper.o build/display.o build/main.o

$ ./main
G

The Zig Build System

commit

The first thing we will change is replacing the Makefile with zigs own custom build system. The build system of zig is written in zig itself, which reduces the requirement of knowing the oft-arcane Makefile idiosyncrasies.

build.zig

const Builder = @import("std").build.Builder;

pub fn build(b: &Builder) {
    const exe = b.addCExecutable("main");
    exe.addCompileFlags([][]const u8 {
        "-std=c99"
    });

    const source_files = [][]const u8 {
        "compute.c",
        "compute_helper.c",
        "display.c",
        "main.c"
    };

    for (source_files) |source| {
        exe.addSourceFile(source);
    }

    exe.setOutputPath("./main");
    b.default_step.dependOn(&exe.step);
}

First, we begin by specifying the main executable which we will be building. This constructs an object which represents a build-step. For each source file in our project, we simply add the file to main executable step.

This approach is far more imperative than the declarative approach of a Makefile. In my view, this is a good choice. Makefiles whilst concise can become exceedingly opaque and hard to parse, especially as a project grows and extra conditions need to be handled.

$ zig build --verbose
cc -c compute.c -o zig-cache/compute.c.o -std=c99
cc -c compute_helper.c -o zig-cache/compute_helper.c.o -std=c99
cc -c display.c -o zig-cache/display.c.o -std=c99
cc -c main.c -o zig-cache/main.c.o -std=c99
cc zig-cache/compute.c.o zig-cache/compute_helper.c.o zig-cache/display.c.o \
    zig-cache/main.c.o -o main -Wl,-rpath,zig-cache -rdynamic

$ ./main
G

First C Replacement

commit

The first actual source code we will replace is compute.c.

compute.zig

use @cImport(@cInclude("compute_helper.h"));

export fn compute(a: u8) -> u8 {
    compute_helper(a) + 5
}

This snippet demonstrates a few features of zig. First, zig is able to parse C header files directly. No binding interface needing! In this case, the use statement will bring all definitions from compute_helper.h into the global namespace, allowing us to call the compute_helper function.

The other important thing to note here is the export specifier on our function. This is important as it tells zig that it should compile this against the C ABI. This means we can call this function from within other C files.

Since our header files are simple, we can continue using them unmodified. Zig does automatically generate C headers as well however. We can compare these against the expected definitions to make sure that we implemented the function correctly.

zig-cache/compute.zig.h

#ifndef COMPUTE_2E_ZIG_H
#define COMPUTE_2E_ZIG_H

#include <stdint.h>

#ifdef __cplusplus
#define COMPUTE_2E_ZIG_EXTERN_C extern "C"
#else
#define COMPUTE_2E_ZIG_EXTERN_C
#endif

#if defined(_WIN32)
#define COMPUTE_2E_ZIG_EXPORT COMPUTE_2E_ZIG_EXTERN_C __declspec(dllimport)
#else
#define COMPUTE_2E_ZIG_EXPORT COMPUTE_2E_ZIG_EXTERN_C __attribute__((visibility ("default")))
#endif

COMPUTE_2E_ZIG_EXPORT uint8_t compute(uint8_t a);
COMPUTE_2E_ZIG_EXPORT __attribute__((__noreturn__)) void __zig_panic(const uint8_t * message_ptr, uintptr_t message_len);

#endif

Build System Modification

The second step we need to perform is modifying build.zig to compile both C and zig files and link them together.

build.zig

const Builder = @import("std").build.Builder;

pub fn build(b: &Builder) {
    const exe = b.addCExecutable("main");
    b.addCIncludePath(".");
    exe.addCompileFlags([][]const u8 {
        "-std=c99"
    });

    const source_files = [][]const u8 {
        "compute_helper.c",
        "display.c",
        "main.c"
    };

    for (source_files) |source| {
        exe.addSourceFile(source);
    }

    const zig_source_files = [][]const u8 {
        "compute.zig",
    };

    for (zig_source_files) |source| {
        const object = b.addObject(source, source);
        exe.addObject(object);
    }

    exe.setOutputPath("./main");
    b.default_step.dependOn(&exe.step);
}

This is mostly same, except we now have a list of zig source files as well. This should be fairly self-explanatory; for each zig source, we create an object build step. This is then added to the exe build step.

Note that we also add the current directory to the C include path. This is important since the @cInclude function used by zig does not read headers from the local directory.

$ zig build --verbose
zig build-obj compute.zig --cache-dir zig-cache --output zig-cache/compute.zig.o \
    --output-h zig-cache/compute.zig.h --name compute.zig -isystem .
cc -c compute_helper.c -o zig-cache/compute_helper.c.o -std=c99 -I zig-cache
cc -c display.c -o zig-cache/display.c.o -std=c99 -I zig-cache
cc -c main.c -o zig-cache/main.c.o -std=c99 -I zig-cache
cc zig-cache/compute.zig.o zig-cache/compute_helper.c.o zig-cache/display.c.o \
    zig-cache/main.c.o -o main -Wl,-rpath,zig-cache -rdynamic

$ ./main
G

Using the Zig Standard Library

commit

The next file we will replace is display.c.

display.zig

const std = @import("std");
const printf = std.io.stdout.printf;

export fn display_char(c: u8)
{
    %%printf("{c}\n", c);
}

Since we want to end up using only zig, we can replace the C printf statement with zig’s own stdlib implementation. Zig does not depend on libc at all. Because this is the only use of libc in our project, we can use the nostdlib to enforce this during our C compilation.

build.zig

exe.addCompileFlags([][]const u8 {
    "-std=c99",
    "-nostdlib",
});

The only other changes are removing display.c from the C sources, and adding display.zig to the zig sources.

$ zig build --verbose
zig build-obj compute.zig --cache-dir zig-cache --output zig-cache/compute.zig.o \
    --output-h zig-cache/compute.zig.h --name compute.zig -isystem .
zig build-obj display.zig --cache-dir zig-cache --output zig-cache/display.zig.o \
    --output-h zig-cache/display.zig.h --name display.zig -isystem .
cc -c compute_helper.c -o zig-cache/compute_helper.c.o -std=c99 -nostdlib -I zig-cache -I zig-cache
cc -c main.c -o zig-cache/main.c.o -std=c99 -nostdlib -I zig-cache -I zig-cache
cc zig-cache/compute.zig.o zig-cache/display.zig.o zig-cache/compute_helper.c.o \
    zig-cache/main.c.o -o main -Wl,-rpath,zig-cache -rdynamic

$ ./main
G

Removing Header Files

commit

As we get further along in our replacement, we will eventually reach the point where we have zig files which are not used by any other C files. This is great as it means we can remove the header files.

Consider now as we change compute_helper.c.

compute_helper.zig

pub fn compute_helper(a: u8) -> u8
{
    a + 1
}

The only dependency on this is compute.zig. We don’t need to export this using the C ABI and can just mark it pub for visibility. compute.c can then be changed to import a zig file instead.

compute.zig

pub use @import("compute_helper.zig");

export fn compute(a: u8) -> u8 {
    compute_helper(a) + 5
}
$ zig build --verbose
zig build-obj compute.zig --cache-dir zig-cache --output zig-cache/compute.zig.o \
    --output-h zig-cache/compute.zig.h --name compute.zig -isystem .
zig build-obj compute_helper.zig --cache-dir zig-cache --output zig-cache/compute_helper.zig.o \
    --output-h zig-cache/compute_helper.zig.h --name compute_helper.zig -isystem .
zig build-obj display.zig --cache-dir zig-cache --output zig-cache/display.zig.o \
    --output-h zig-cache/display.zig.h --name display.zig -isystem .
cc -c main.c -o zig-cache/main.c.o -std=c99 -nostdlib -I zig-cache -I zig-cache -I zig-cache
cc zig-cache/compute.zig.o zig-cache/compute_helper.zig.o zig-cache/display.zig.o \
    zig-cache/main.c.o -o main -Wl,-rpath,zig-cache -rdynamic

$ ./main
G

The Final File

commit

Our project now has only 1 remnant left of C. Let’s remove it all!

main.zig

use @import("display.zig");
use @import("compute.zig");

pub fn main() -> %void {
    display_char(compute('A'));
}

The main things to note here are the use statements for import. Since we were converting a C project, we didn’t initially have any namespacing. Since zig has a proper module system we usually strongly prefer assigning our imports to a constant. e.g. const display = @import("display.zig").

Now, we need to edit our build.zig file.

build.zig

const Builder = @import("std").build.Builder;

pub fn build(b: &Builder) {
    const exe = b.addExecutable("main", "main.zig");

    exe.setOutputPath("./main");
    b.default_step.dependOn(&exe.step);
}

Much simpler! Zig can make use of the implicit dependency graph formed between imports. Individual object files do not need to be built for each file explicitly.

$ zig build --verbose
zig build-exe main.zig --cache-dir zig-cache --output main --name main

./main
G

Closing

Zig makes this type of iterative conversion comparatively easier than most other languages. For larger projects there will be unknown difficulties however. These will be continually improved as the language becomes more stable and refined.

Being able to easily replace C with a newer modern alternative is a real bonus in terms of safety and ergonomics. See this post by the creator of the language some short examples of improvements.

If you want to know more about zig as a language, check out the project page.