Creating a C Project with Zig Build System

ยท

4 min read

This article is inspired by https://ziglang.org/learn/overview/#export-functions-variables-and-types-for-c-code-to-depend-on

In this article we will explore creating a C Project using the Zig Build System, the advantages towards this approach are the following:

  • The ability to target multiple systems with ease

  • Being able to mix C and Zig code with ease

  • Using LLVM Optimizations

  • Caching

Let's create a folder called mathtest and it will contain the following structure:

mathtest/
    build.zig
    mathtest.zig 
    test.c

Now let's begin with the easiest file mathtest.zig which will contain one function add that will be exported to c:

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

Now if we were to manually link mathtest.zig with test.c, we would need test.c to have someway to know about our function from the shared library, so when we work on test.c we will put the add function as extern at the top.

#include<stdio.h>
extern int add(int a, int b);

int main(int argc, char **argv){
    int result = add(42, 1337);
    printf("%d\n", result);
    return 0;
}

Now we are ready to work on the actual build system of Zig which will be read from build.zig. Firstly we will import the Builder type from the standard library which is located in the build namespace.

After of which we will create the build function which will be public, and take in a pointer to Builder as the parameter b, and return nothing or void.

const Builder = @import("std").build.Builder;
pub fn build(b: *Builder) void {
// we will work inside here now
}

Inside of the function, we will need to declare two constants, target and optimize, which determine which target our binary will be compiling for, and the different optimization mode it will be set to respectively. To keep it simple, we will keep them at the defaults by using the method b.standardTargetOptions and b.standardOptimizationOptions respectively.

    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

Now we are ready to create our shared library which will be assigned to the constant lib and created using the function b.addSharedLibrary. This function requires the type SharedLibraryOptions, which requires the following:

  • name: []const u8

  • root_source_file: ?LazyPath

  • target: CrossTarget

  • optimize: std.builtin.Mode

This can be seen done below:

    const lib = b.addSharedLibrary(
    .{
        .name = "mathtest", 
        .root_source_file = .{ .path = "mathtest.zig" }, 
        .target = target,
        .optimize = optimize 
    });

We are now ready to create our executable by firstly declaring the constant exe and assigning it to b.addExecutable with the ExecutableOptions set with name as "test". After we need to add a few things for our executable...

Firstly we need to add test.c as the file of our executable, this can be done using the method addCSourceFile which will expect a LazyPath (we will set path to test.c) as the file and [][]const u8 for the flags (we will simply declare a slice with the only item being "-std=c99").

Secondly we need to link our library to our executable, which will be done with the linkLibrary method and lib can simply be passed into it.

Since this executable is a C file, we will also need to make sure to link libc, which is simply done using the method linkLibC.

With these options set, we can use b.installArtifact where the installArtifact method registers an artifact to be installed. An artifact in our context is the result of a build step from an executable file.

    const exe = b.addExecutable(.{ .name = "test" });
    exe.addCSourceFile(
        .{ 
            .file = .{ .path = "test.c" }, 
            .flags = &[_][]const u8{"-std=c99"} 
    });
    exe.linkLibrary(lib);
    exe.linkLibC();
    b.installArtifact(exe);

We are now ready to create our run command which will be invoked by the command zig build run, and for this to occur we first need the constant run_cmd which will be assigned to b.addRunArtifact and we will pass in exe to it.

We will have this run step to have a dependency to retrieve the install step from the Builder instance b. This is done using run_cmd.step.dependOn(b.getInstallStep()).

After we will create the run step (constant run_step) by using the b.step function which expects a name and description that will be used when you invoke zig build --help. For run_step to actually use our run command, we will have it depend on run_cmd.step.

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    const run_step = b.step("run", "Run the program");
    run_step.dependOn(&run_cmd.step);

Our full build.zig code can be seen below:

const Builder = @import("std").build.Builder;
pub fn build(b: *Builder) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const lib = b.addSharedLibrary(
    .{
        .name = "mathtest", 
        .root_source_file = .{ .path = "mathtest.zig" }, 
        .target = target,
        .optimize = optimize 
    });
    const exe = b.addExecutable(.{ .name = "test" });
    exe.addCSourceFile(
        .{ 
            .file = .{ .path = "test.c" }, 
            .flags = &[_][]const u8{"-std=c99"} 
    });
    exe.linkLibrary(lib);
    exe.linkLibC();
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    const run_step = b.step("run", "Run the program");
    run_step.dependOn(&run_cmd.step);
}

When we run our project now, we can see the following output:

$ zig build run 
1379

Did you find this article valuable?

Support Mustafif Khan by becoming a sponsor. Any amount is appreciated!

ย