Testing C code using Zig
2024-08-08
Background
Sure, it's 2024 and C is dead, right? So dead! Of course it's not! C is very much alive and powering much of our world today. So it is a bit of a concern that we seem to have less and less systems developers around each year.
What has been interesting over the past year or two has been how many lower-level projects I have been involved in that required writing tools using languages like C, C++, Rust and Go. Along the way I also happened to run into Zig and found it to be a great tool both for consuming and testing C code and for writing new tools. Zig is definitely something to watch, it has great potential and it's already enabling tools like Bun.
NOTE: I'm assuming you have some basic Zig and C knowledge. If you don't, I humbly suggest that you look at the Zig learning resources first, otherwise you might find it challenging to follow along. Also, FreeCodecamp has an excellent C tutorial video to help you get started with C, or just to refresh your C knowledge.
Who this is for
Honestly? It's for me, well, future me. I work on many projects and it often involve me using different technologies. Technologies that I then don't touch for months or even years again. So this article is a way for me to document what I've used in the past so future me can just read it and remember how it worked!
It is possibly also slightly useful to someone who's curious about how to make Zig and C tango. See, Zig can often be used as a drop-in compiler replacement for your favourite C or C++ compiler chain. It can of course also build solutions using the excellent Zig language. Or, as we will see later, use Zig and C together to build solutions and provide a neat way of writing some tests for your C code.
Build C code with Zig
To follow along, you just need to install Zig on your system. See the getting started guide for tips and tricks. I'll be using Zig 0.13.0, which is the current stable version at the time of writing.
Let's make sure things are working by building a super simple little C binary using Zig. Create a new source file called withzig.c and add the following content:
#include <stdio.h> #include <stdlib.h> int summer(int left, int right) { return left + right; } int main() { int value1 = 12; int value2 = 30; printf("The sum of %d and %d is %d.\n", value1, value2, summer(value1, value2)); return EXIT_SUCCESS; }
Now that we have a source file, let's use Zig to build it. This is the build command:
zig cc withzig.c
If everything goes right, you should end up with a a.out file, the default name if you do not specify one. Depending on your platform, it could be something else, I'm using a Mac so this is my reality. If you want to specify an output filename, just do this:
zig cc withzig.c -o myoutputfilename
The -o flag lets you specify the name of the output file, pretty handy. So now that we know how to build C files using Zig, go ahead and execute the produced binary. If I execute myoutputfilename file on my system, I see:
The sum of 12 and 30 is 42.
Calling C from Zig
Now that we know Zig can compile C, how about calling some C functions from Zig? For starters, we will simply use some C standard library functionality from Zig. This is where the Zig build system comes in. Don't worry, it's not that complicated and there's great documentation. We'll do it step-by-step though, so you can see it in action.
Create a new directory and then execute the following command inside it:
zig init
This generates a new Zig project, you can see the output:
info: created build.zig info: created build.zig.zon info: created src/main.zig info: created src/root.zig info: see `zig build --help` for a menu of options
Try running zig build run (it takes a few seconds the first time) to see what the default program Zig gives us does:
All your codebase are belong to us. Run `zig build test` to run the tests.
Now open the folder in your favourite code editor. There are some options listed on the tools page to get you going. Also consider Zed, which I find to be really fast and light.
The first thing we want to do, is add the C Standard Library, libc, to our project. This will allow us to call into C code from Zig. To do that, open the build.zig file.
In the build.zig file, look for the const exe = b.addExecutable code block around line 32. After the block, most likely on line 38, add the following code:
exe.linkLibC();
My build.zig from line 32 - 38 looks like this:
const exe = b.addExecutable(.{ .name = "calling_c", .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); exe.linkLibC(); // Link libc into our project
Let's open the main.zig in the src directory. Remove all the content and replace it with the following code:
const std = @import("std"); const cStringFunctions = @cImport({ @cInclude("string.h"); }); pub fn main() void { const myString = "This is my string, it contains 45 characters."; std.debug.print("Zig: '{s}' contains {d} characters.\n", .{ myString, myString.len }); std.debug.print("C: '{s}' contains {d} characters.\n", .{ myString, cStringFunctions.strlen(myString) }); }
So what's happening here? We create a standard Zig string, then we ask Zig to show the length of the string. Next we ask the C Standard Library to do the same. So we are calling into C and passing data from Zig. With pretty much no effort! To run our code, make sure you are in the project root directory, then run:
zig build run
That should generate the following output:
Zig: 'This is my string, it contains 45 characters.' contains 45 characters. C: 'This is my string, it contains 45 characters.' contains 45 characters.
So let's add some Zig tests as well, and see how that works. Just under the main function, add the test function:
test "Zig and C agree on string lengths" { const inputString = "This is the input string we will use for this test."; const zigLength = inputString.len; const cLength = cStringFunctions.strlen(inputString); try std.testing.expect(zigLength == cLength); }
Let's run the tests with:
zig build test --summary all
You can also run zig build test, but it will not display anything. The summary flag is handy during development and debugging of tests. You should see something like:
Build Summary: 5/5 steps succeeded; 2/2 tests passed test success ├─ run test 1 passed 424ms MaxRSS:1M │ └─ zig test Debug native success 2s MaxRSS:189M └─ run test 1 passed 773ms MaxRSS:1M └─ zig test Debug native success 2s MaxRSS:191M
Running the same command again, shows how Zig's cache will kick in and it won't rebuild anything, because nothing has changed. Instead, it goes a lot quicker!
Build Summary: 5/5 steps succeeded test cached ├─ run test cached │ └─ zig test Debug native cached 51ms MaxRSS:16M └─ run test cached └─ zig test Debug native cached 53ms MaxRSS:16M
Alright, we can call C Standard Library code. It's interesting, but not that useful. Let's now add our own C functions and call them from Zig.
Calling your own C functions
In my scenario, I have existing C code that I want to call. It's not the libc code, but rather custom C code, so it would be nice to add some tests for it. With Zig, that's possible!
First, create a new directory adjacent to the src directory called cfiles. Now create a new file in the cfiles directory called my_math.c with the following content:
int summer(int left, int right) { return left + right; }
That's the function from our initial C test file. We're putting it in a separate file so that we can see how Zig can include C sources. Of course, we need to tell the Zig to include this source, so add the following code to the build.zig file after the exe.linkLibC() line:.
exe.addIncludePath(b.path("cfiles"));
This tells the Zig build system to include the source files in the cfiles directory when building the solution. Now that we know the files will be included, let's import our my_math.c file into the main.zig file. Below the const cStringFunctions..., add the following code:
const cMyMath = @cImport(@cInclude("my_math.c"));
Update the main function to include the following code, after the existing code:
const left = 26; const right = 16; std.debug.print("The sum of {d} and {d} is {d}.\n", .{ left, right, cMyMath.summer(left, right) });
Do a zig build run and observe the output:
Zig: 'This is my string, it contains 45 characters.' contains 45 characters. C: 'This is my string, it contains 45 characters.' contains 45 characters. The sum of 26 and 16 is 42.
And just like that, we have Zig calling a custom C function! How about adding a test for the function? Add the following code to the end of the main.zig file:
test "Zig calling a custom C function" { const left = 28; const right = 14; const expected = 42; const result = cMyMath.summer(left, right); try std.testing.expect(result == expected); }
Now run it with zig build test and watch things go wrong!
test └─ run test └─ zig test Debug native 2 errors src/main.zig:7:17: error: C import failed const cMyMath = @cImport(@cInclude("my_math.c")); ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ referenced by: test.Zig calling a custom C function: src/main.zig:31:20
Oops! What's going on? Zig has two different configurations, one for building and one for testing. We have not included the my_math.c source file in our test configuration yet. Let's fix that by opening build.zig and navigating to the const exe_unit_tests = b.addTest... code block. Right after, add the following code:
exe_unit_tests.addIncludePath(b.path("cfiles"));
You can now run zig build test --summary all again and you'll see:
Build Summary: 5/5 steps succeeded; 2/2 tests passed test success ├─ run test cached │ └─ zig test Debug native cached 52ms MaxRSS:16M └─ run test 2 passed 371ms MaxRSS:937K └─ zig test Debug native success 2s MaxRSS:206M
Great! It says 2/2 tests passed, that is good news. But are we sure Zig is bothering to run the C code at all? Go ahead and change something in the C function. I changed it to return the sum of the numbers plus 1, and reran the tests:
test └─ run test 1/2 passed, 1 failed
So Zig picked up the C file changed and recompiled the app by itself. Excellent! Remember to change the C file back though! So there you have the basics in place for how to use C code with Zig and how to use Zig to write tests for your C functions.
Full code
I removed the root.zig library file and all references to it to keep things a bit less confusing.
The full source code for main.zig is:
const std = @import("std"); const cStringFunctions = @cImport({ @cInclude("string.h"); }); const cMyMath = @cImport(@cInclude("my_math.c")); pub fn main() void { const myString = "This is my string, it contains 45 characters."; std.debug.print("Zig: '{s}' contains {d} characters.\n", .{ myString, myString.len }); std.debug.print("C: '{s}' contains {d} characters.\n", .{ myString, cStringFunctions.strlen(myString) }); const left = 26; const right = 16; std.debug.print("The sum of {d} and {d} is {d}.\n", .{ left, right, cMyMath.summer(left, right) }); } test "Zig and C agree on string lengths" { const inputString = "This is the input string we will use for this test."; const zigLength = inputString.len; const cLength = cStringFunctions.strlen(inputString); try std.testing.expect(zigLength == cLength); } test "Zig calling a custom C function" { const left = 28; const right = 14; const expected = 42; const result = cMyMath.summer(left, right); try std.testing.expect(result == expected); }
The build.zig file:
const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const exe = b.addExecutable(.{ .name = "calling_c", .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); exe.linkLibC(); // Link libc into our project exe.addIncludePath(b.path("cfiles")); // include C sources b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| { run_cmd.addArgs(args); } const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); const exe_unit_tests = b.addTest(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); exe_unit_tests.addIncludePath(b.path("cfiles")); const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_exe_unit_tests.step); }
Resources
To learn more about Zig or C, you can use the following resources as a starting point:
- Zig's learning resources.
- For C and or Zig practice, Exercism is amazing.
- The Zig Master Youtube video series is a great resource.
- If you are serious about learning C, Low Level Academy has an excellent course.
- FreeCodecamp's excellent C tutorial video.
Conclusion
Zig is a powerful tool in your C arsenal. Not only does it provide a handy compiler, it also allows you to use the ergonomics of Zig to your advantage, even in a C project. Using Zig to write tests for C libraries helps me to deliver a better product, perhaps it can do the same for you.
Of course, Zig is more than that, it's also a modern programming language that's definitely worth exploring if you are doing any kind of system development. I'm also a big fan of Zig for WebAssembly work, both porting existing C code and developing new functionality using Zig as the primary langauge.
Return to the blog index