Extending C with Zig¶
Note
This guide was last updated in March of 2024 with an 0.12.0-dev build. Zig is a moving target, and stuff written about it may fall out of date. If you find something broken about this, feel free to let me know.
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:
#ifndef ADD_H
#define ADD_H
#include <stdint.h>
// Add two numbers together and return the sum.
int32_t add(int32_t a, int32_t b);
#endif
And here’s the implementation.
#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.
#include <stdio.h>;
#include <inttypes.h>;
#include "add.h"
int main(void)
{
printf("7 + 3 = %"PRIi32"\n", add(7, 3));
return 0;
}
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.
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 std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib = b.addStaticLibrary(.{
.name = "temp",
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
b.installArtifact(lib);
const main_tests = b.addTest(.{
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
const run_main_tests = b.addRunArtifact(main_tests);
const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_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, Zig comes with a bunch of cool safety checks, but in order to use them we need to include the compiler runtime.
Finally, 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.pie = true;
. With that
in place, our build.zig looks like this:
const std = @import("std");
const Build = std.Build;
pub fn build(b: *Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib = b.addStaticLibrary(
.{
.name = "add",
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
},
);
b.installArtifact(lib);
lib.pie = true;
const main_tests = b.addTest(.{
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
const run_main_tests = b.addRunArtifact(main_tests);
const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_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 -Lzig-out/lib 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.
If we’re building using a safe build mode (ReleaseSafe or Debug) we
need to include Zig’s compiler_rt
by setting
lib.bundle_compiler_rt = true
. If we’re using an unsafe
build mode (ReleaseFast, ReleaseSmall) then we don’t want the extra
bloat and can leave it off. Let’s add that sentiment to our build.zig
now.
const std = @import("std");
const Build = std.Build;
pub fn build(b: *Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib = b.addStaticLibrary(
.{
.name = "add",
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
},
);
b.installArtifact(lib);
switch (optimize) {
.Debug, .ReleaseSafe => lib.bundle_compiler_rt = true,
.ReleaseFast, .ReleaseSmall => {},
}
lib.pie = true;
const main_tests = b.addTest(.{
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
const run_main_tests = b.addRunArtifact(main_tests);
const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_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
rm -rf zig-out zig-cache
$ zig build -Doptimize=ReleaseSafe
$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
gcc -o mul.o -c -Wall -Werror -Wextra -Os -fPIE mul.c
ld -melf_x86_64 -r --whole-archive -Lzig-out/lib -L. -o zig-out/lib/libadd.a mul.o
$ make clean
rm -f *.o *.a main
rm -rf zig-out zig-cache
$ zig build -Doptimize=ReleaseSmall
$ zig build -Doptimize=ReleaseSafe
$ 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 -Doptimize=ReleaseSmall
$ touch zig-out/lib/libadd.a
$ make main
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. -Lzig-out/lib 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.
#ifndef MUL_H
#define MUL_H
#include <stdint.h>;
// Multiply two numbers together and return the product.
int32_t mul(int32_t a, int32_t b);
#endif
#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
.
CC=gcc
CFLAGS=-Wall -Werror -Wextra -Os -fPIE
LD=ld
LDFLAGS=-melf_x86_64 -r --whole-archive
ZIGOUT=zig-out/lib
LPATH=-L$(ZIGOUT) -L.
.PHONY: clean libadd.a
%.o:: %.c
$(CC) -o $@ -c $(CFLAGS) $^
$(ZIGOUT)/libadd.a:
zig fmt build.zig src/*.zig
zig build
libi32math.a: $(ZIGOUT)/libadd.a mul.o
$(LD) $(LDFLAGS) $(LPATH) -o $@ $^
main: main.o libi32math.a
$(CC) -o main $(CFLAGS) $(LPATH) $< -li32math
clean:
rm -f *.o *.a main
rm -rf zig-out zig-cache
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.
Done¶
That’s all there is to it. The code for the completed i32math library can be found on sourcehut or github.