Extending C with Zig

One of the most compelling wedges for Zig to gain a foothold is in development and maintenance of libraries that already have significant amounts of C code. Since Zig can export symbols understood by C linkers, a library written in C can be incrementally migrated to Zig without breaking the interface.

Math

For our example, we'll start with a trivial addition library. Here's our C library's interface:


// add.h
#ifndef ADD_H
#define ADD_H

#include 

// Add two numbers together and return the sum.
int32_t add(int32_t a, int32_t b);

#endif

      

And here's the implementation.


// add.c
#include "add.h"

int32_t add(int32_t a, int32_t b)
{
    return a + b;
}

Simple, right? Let's also make a program that uses it and a Makefile to build the whole thing.


// main.c
#include 
#include 
#include "add.h"

int main(void)
{
    printf("7 + 3 = %"PRIi32"\n", add(7, 3));
    return 0;
}


# Makefile
CC=gcc
CFLAGS=-Wall -Werror -Wextra -Os -fPIE
LD=ld
LDFLAGS=-melf_x86_64 -r
LPATH=-L.

.PHONY: clean

%.o: %.c
	$(CC) -o $@ -c $(CFLAGS) $^

libadd.a: add.o
	$(LD) $(LDFLAGS) -o $@ $^

main: main.o libadd.a
	$(CC) -o main $(CFLAGS) $(LPATH) $< -ladd

clean:
	rm -f *.o *.a main

A bit more complexity, but if you've ever built a library it should mostly look familiar.

Add Zig

Now it's time to make the add library, but in Zig. Coincidentally, Zig's default library initializer matches this one's interface perfectly.


$ zig init-lib
Created build.zig
Created src/main.zig

Next, try `zig build --help` or `zig build test`
      

Excellent. Zig has built a build.zig file for us (Zig's equivalent of a Makefile) and put some code in main.zig for us. Let's take a look.


// src/main.zig
const std = @import("std");
const testing = std.testing;

export fn add(a: i32, b: i32) i32 {
    return a + b;
}

test "basic add functionality" {
    testing.expect(add(3, 7) == 10);
}
      

and the default build.zig:


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

pub fn build(b: *Builder) void {
    const mode = b.standardReleaseOptions();
    const lib = b.addStaticLibrary("add", "src/main.zig");
    lib.setBuildMode(mode);
    lib.install();

    var main_tests = b.addTest("src/main.zig");
    main_tests.setBuildMode(mode);

    const test_step = b.step("test", "Run library tests");
    test_step.dependOn(&main_tests.step);
}
      

The code is already there. Our Zig implementation of add is already marked with export and it thoughtfully included a test. All we need now is to hook the build system up in a way that can generate a libadd.a that will link with main.o. For that, we need to fiddle a bit with our build.zig.

Building a Library

The build.zig that was generated by zig init-lib works fine for making libraries to be used with Zig code, but it's missing a few things that we need to link from C. First among those is to stick our generated libadd.a file somewhere we can find it. We'll use the current directory, since it's easy. A call to lib.setOutputDir(".") will handle that for us.

The next issue is that modern linkers expect (and modern C compilers emit) position-independent code. It's easy to tell Zig's build system to do the same, by adding the line lib.force_pic = true;. With those bits in place, our build.zig looks like this:


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

pub fn build(b: *Builder) void {
    const mode = b.standardReleaseOptions();
    const lib = b.addStaticLibrary("add", "src/main.zig");
    lib.setBuildMode(mode);
    lib.force_pic = true;
    lib.setOutputDir(".");
    lib.install();

    var main_tests = b.addTest("src/main.zig");
    main_tests.setBuildMode(mode);

    const test_step = b.step("test", "Run library tests");
    test_step.dependOn(&main_tests.step);
}
      

All set. Let's run zig build and try make main and see what happens.


$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. main.o -ladd
/usr/bin/ld: ./libadd.a(/.../add.o): in function `std.fs.Dir.openFile':
/home/nathan/lib/zig/std/fs.zig:639: undefined reference to `__zig_probe_stack'
/usr/bin/ld: ./libadd.a(/.../add.o): in function `std.os.toPosixPath':
/home/nathan/lib/zig/std/os.zig:4171: undefined reference to `__zig_probe_stack'
/usr/bin/ld: ./libadd.a(/.../add.o): in function `std.fs.file.File.stat':
/home/nathan/lib/zig/std/fs/file.zig:306: undefined reference to `__muloti4'
/usr/bin/ld: /home/nathan/lib/zig/std/fs/file.zig:307: undefined reference to `__muloti4'
/usr/bin/ld: /home/nathan/lib/zig/std/fs/file.zig:308: undefined reference to `__muloti4'
/usr/bin/ld: ./libadd.a(/.../add.o): in function `std.debug.printLineFromFileAnyOs':
/home/nathan/lib/zig/std/debug.zig:1007: undefined reference to `__zig_probe_stack'
/usr/bin/ld: ./libadd.a(/.../add.o): in function `std.dwarf.DwarfInfo.getLineNumberInfo':
/home/nathan/lib/zig/std/dwarf.zig:693: undefined reference to `__zig_probe_stack'
collect2: error: ld returned 1 exit status
make: *** [Makefile:17: main] Error 1
      

It blew up! That's because some of Zig's safety features happen at run time. Specifically, the stack protection code needs to be linked in. But what if our library is super small? Sure, safety is nice, but maybe this has to fit into tiny microcontrollers. Fortunately, Zig has us covered. Zig's different build modes let us choose different points on the safety/speed tradeoff spectrum, and Zig's build system gives us access to the current build mode.

The two things we're choosing between are bundling the compiler's runtime, which means sticking those symbols in our compiled .a file, and turning off the stack checking. One is accessed with the bundle_compiler_rt property and the other with disable_stack_probing. In the safe build modes (ReleaseSafe and Debug) we don't mind the extra bloat. Whereas in the unsafe build modes (ReleaseFast, ReleaseSmall) we do. Let's add that sentiment to our build.zig now.


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

pub fn build(b: *Builder) void {
    const mode = b.standardReleaseOptions();
    const lib = b.addStaticLibrary("add", "src/main.zig");
    lib.setBuildMode(mode);
    switch (mode) {
        .Debug, .ReleaseSafe => lib.bundle_compiler_rt = true,
        .ReleaseFast, .ReleaseSmall => lib.disable_stack_probing = true,
    }
    lib.force_pic = true;
    lib.setOutputDir(".");
    lib.install();

    var main_tests = b.addTest("src/main.zig");
    main_tests.setBuildMode(mode);

    const test_step = b.step("test", "Run library tests");
    test_step.dependOn(&main_tests.step);
}
      

And now we can try it out. Note that since Zig's build system caches its output, we have to touch libadd.a to update its timestamp.


$ make clean
rm -f *.o *.a main
$ zig build -Drelease-safe
$ touch libadd.a 
$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. main.o -ladd
$ zig build -Drelease-small
$ make clean
rm -f *.o *.a main
$ zig build -Drelease-safe
$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. main.o -ladd
$ ./main
7 + 3 = 10
$ zig build -Drelease-small
$ touch libadd.a
$ make main
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. main.o -ladd
$ ./main
7 + 3 = 10
      

Hooray, it works, and in different build modes! Zig also has an option to auto-generate header files (called emit_h) but it's not entirely complete, and since we already had a hand-written header file whose interface we match exactly, it's not necessary. Feel free to take a look at what it does generate, though.

Incremental Rewriting

Way back in the first paragraph, I said that incrementally replacing a C library with Zig was the use case. But what we've done so far is entirely replace a C library with Zig. If we want to replace part of a library, we'll need a larger library to start with. Let's take our libadd and add some extra math in a separate file, along with its interface.


// mul.h
#ifndef MUL_H
#define MUL_H

#include 

// Multiply two numbers together and return the product.
int32_t mul(int32_t a, int32_t b);

#endif

// mul.c
#include "mul.h"

int32_t mul(int32_t a, int32_t b)
{
    return a * b;
}

      

That was easy. Now we just have to tweak our Makefile to link libadd.a and mul.o into a larger library. Let's call it libi32math.


# Makefile
CC=gcc
CFLAGS=-Wall -Werror -Wextra -Os -fPIE
LD=ld
LDFLAGS=-melf_x86_64 -r --whole-archive
LPATH=-L.
ZIGFLAGS=-Drelease-fast

.PHONY: clean libadd.a

%.o: %.c
	$(CC) -o $@ -c $(CFLAGS) $^

libadd.a:
	zig build
	touch libadd.a

libi32math.a: libadd.a mul.o
	$(LD) $(LDFLAGS) -o $@ $^

main: main.o libi32math.a
	$(CC) -o main $(CFLAGS) $(LPATH) $< -li32math

clean:
	rm -f *.o *.a main

        

And just like that, we have a small part of our i32math library written in Zig. Let's try running a program that uses it.


$ make clean
rm -f *.o *.a main
$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
zig build
touch libadd.a
gcc -o mul.o -c -Wall -Werror -Wextra -Os -fPIE mul.c
ld -melf_x86_64 -r --whole-archive -o libi32math.a libadd.a mul.o
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. main.o -li32math
$ ./main
7 + 3 = 10
7 * 3 = 21
        

If we wanted to migrate the library's build system over to Zig's build system, we could. Zig can build C code and its build system can do the job that Make is doing for our little i32math library. That, however, is a project for another day.