C Pointers in practice
15-08-2024
But, why?!
In my previous post, I showed how to test C code with Zig
and the most frequent question I have received was:
"WHY would you even be using C? It's not safe, it's difficult and it is not needed in modern times, wake up!".
As it stands, I'm not advocating that you drop your favourite language and switch to C, but
I am of the opinion that learning some C can only benefit anyone who's getting into programming more complex solutions.
There's also the fact that there's an absolute ton of C code running our world out there, so knowing a bit about it
is not a bad thing. We need more systems developers and right now that seems like a dying art, which should be of greater concern.
The biggest thing I think you learn from C is how computer memory is managed. Sure, it's super convenient, productive and a ton safer to use a garbage collected language, but even then it's important to understand why that is the case. More modern languages provide a lot of productivity and safety tools, just look at Go as an example. Small, compact runtimes with garbage collection, powerful language and ergonomic tooling, all in one. You can go through an entire career using languages like Go and never care about memory management, and that's perfectly fine!
The challenge comes in when, like me, you work in different industries and environments, some of them more constrained than others, especially when it comes to CPU and memory resources. That's when a deeper understanding of how memory is managed can help you get more out of the resources available, even if you use a garbage-collected language.
Getting started
We'll kick things off with a simple C program that asks for a name and then displays a greeting. Create a new source file called step1.c and add the following content (comments are optional of course):
#include <stdio.h>
#include <stdlib.h>
// ======================================================================= main
int main() {
char *name;
printf("Who goes there?: ");
scanf("%20s", name);
printf("Oh, hello there %s!\n", name);
return EXIT_SUCCESS;
}
All this program does in ask for a name and then read it into a pointer, up to a maximum of 20 characters. If you try it out, you should see something similar to this:
Who goes there?: Bobby
Oh, hello there Bobby!
So, nothing fancy and also nothing that requires you to know anything about pointers or memory. In fact, I know quite a few folks who use things like char *name without knowing why. To them, it also does not matter, so they just do it this way because it works. There's nothing wrong with not knowing or understanding everything. I certainly don't and I know I never will.
Adding a touch more detail
That's great, for a single name and all. But what about a scenario where you want to ask for a bit more information, perhaps from multiple people? How would you handle that? Now we get to introduce structs and arrays, something you definitely use in even really simple programs. Most langauges have the same concepts, but let's take a loot at how it works in C.
Here is the code for step2.c:
zig init
This code is based on real examples I have seen in the wild and we will use it to learn a bit more about memory and how it is allocated and used.
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