14  Zig interoperability with C

In this chapter, we are going to discuss the interoperability of Zig with C. We have discussed at Section 9.11 how you can use the zig compiler to build C code. But we haven’t discussed yet how to actually use C code in Zig. In other words, we haven’t discussed yet how to call and use C code from Zig.

This is the main subject of this chapter. Also, in our next small project in this book, we are going to use a C library in it. As consequence, we will put in practice a lot of the knowledge discussed here on this next project.

14.1 How to call C code from Zig

Interoperability with C is not something new. Most high-level programming languages have FFI (foreign function interfaces), which can be used to call C code. For example, Python have Cython, R have .Call(), Javascript have ccall(), etc. But Zig integrates with C in a deeper level, which affects not only the way that C code get’s called, but also, how this C code is compiled and incorporated into your Zig project.

In summary, Zig have great interoperability with C. If you want to call any C code from Zig, you have to perform the following steps:

  • import a C header file into your Zig code.
  • link your Zig code with the C library.

14.1.1 Strategies to import C header files

Using C code in Zig always involves performing the two steps cited above. However, when we talk specifically about the first step listed above, there are currently two different ways to perform this first step, which are:

  • translating the C header file into Zig code, through the zig translate-c command, and then, import and use the translated Zig code.
  • importing the C header file directly into your Zig module through the @cImport() built-in function.

If you are not familiar with translate-c, this is a subcommand inside the zig compiler that takes C files as input, and outputs the Zig representation of the C code present in these C files. In other words, this subcommand works like a transpiler. It takes C code, and translates it into the equivalent Zig code.

I think it would be ok to interpret translate-c as a tool to generate Zig bindings to C code, similarly to the rust-bindgen1 tool, which generates Rust FFI bindings to C code. But that would not be a precise interpretation of translate-c. The idea behind this tool is to really translate the C code into Zig code.

Now, on a surface level, @cImport() versus translate-c might seem like two completely different strategies. But in fact, they are effectively the exact same strategy. Because, under the hood, the @cImport() built-in function is just a shortcut to translate-c. Both tools use the same “C to Zig” translation functionality. So when you use @cImport(), you are essentially asking the zig compiler to translate the C header file into Zig code, then, to import this Zig code into your current Zig module.

At the present moment, there is an accepted proposal at the Zig project, to move @cImport() to the Zig build system2. If this proposal is completed, then, the “use @cImport()” strategy would be transformed into “call a translate C function in your Zig build script”. So, the step of translating the C code into Zig code would be moved to the build script of your Zig project, and you would only need to import the translated Zig code into your Zig module to start calling C code from Zig.

If you think about this proposal for a minute, you will understand that this is actually a small change. I mean, the logic is the same, and the steps are still essentially the same. The only difference is that one of the steps will be moved to the build script of your Zig project.

14.1.2 Linking Zig code with a C library

Regardless of which of the two strategies from the previous section you choose, if you want to call C code from Zig, you must link your Zig code with the C library that contains the C code that you want to call.

In other words, everytime you use some C code in your Zig code, you introduce a dependency in your build process. This should come as no surprise to anyone that have any experience with C and C++. Because this is no different in C. Everytime you use a C library in your C code, you also have to build and link your C code with this C library that you are using.

When we use a C library in our Zig code, the zig compiler needs to access the definition of the C functions that are being called in your Zig code. The C header file of this library provides the declarations of these C functions, but not their definitions. So, in order to access these definitions, the zig compiler needs to build your Zig code and link it with the C library during the build process.

As we discussed across the Chapter 9, there are different strategies to link something with a library. This might involve building the C library first, and then, linking it with the Zig code. Or, it could also involve just the linking step, if this C library is already built and installed in your system. Anyway, if you have doubts about this, comeback to Chapter 9.

14.2 Importing C header files

At Section 14.1.1, we have described that, currently, there are two different paths that you can take to import a C header file into your Zig modules, translate-c or @cImport(). This section describes each strategy separately in more details.

14.2.1 Strategy 1: using translate-c

When we choose this strategy, we first need to use the translate-c tool to translate the C header files that we want to use into Zig code. For example, suppose we wanted to use the fopen() C function from the stdio.h C header file. We can translate the stdio.h C header file through the bash command below:

zig translate-c /usr/include/stdio.h \
    -lc -I/usr/include \
    -D_NO_CRT_STDIO_INLINE=1 > c.zig \

Notice that, in this bash command, we are passing the necessary compiler flags (-D to define macros, -l to link libraries, -I to add an “include path”) to compile and use the stdio.h header file. Also notice that we are saving the results of the translation process inside a Zig module called c.zig.

Therefore, after running this command, all we have to do is to import this c.zig module, and start calling the C functions that you want to call from it. The example below demonstrates that. Is important to remember what we’ve discussed at Section 14.1.2. In order to compile this example you have to link this code with libc, by passing the flag -lc to the zig compiler.

const c = @import("c.zig");
pub fn main() !void {
    const x: f32 = 1772.94122;
    _ = c.printf("%.3f\n", x);
}
1772.941

14.2.2 Strategy 2: using @cImport()

To import a C header file into our Zig code, we can use the built-in functions @cInclude() and @cImport(). Inside the @cImport() function, we open a block (with a pair of curly braces). Inside this block we can (if we need to) include multiple @cDefine() calls to define C macros when including this specific C header file. But for the most part, you will probably need to use just a single call inside this block, which is a call to @cInclude().

This @cInclude() function is equivalent to the #include statement in C. You provide the name of the C header that you want to include as input to this @cInclude() function, then, in conjunction with @cImport(), it will perform the necessary steps to include this C header file into your Zig code.

You should bind the result of @cImport() to a constant object, pretty much like you would do with @import(). You just assign the result to a constant object in your Zig code, and, as consequence, all C functions, C structs, C macros, etc. that are defined inside the C header files will be available through this constant object.

Look at the code example below, where we are importing the Standard I/O C Library (stdio.h), and calling the printf()3 C function. Notice that we have also used in this example the C function powf()4, which comes from the C Math Library (math.h). In order to compile this example, you have to link this Zig code with both the C Standard Library and the C Math Library, by passing the flags -lc and -lm to the zig compiler.

const c = @cImport({
    @cDefine("_NO_CRT_STDIO_INLINE", "1");
    @cInclude("stdio.h");
    @cInclude("math.h");
});

pub fn main() !void {
    const x: f32 = 15.2;
    const y = c.powf(x, @as(f32, 2.6));
    _ = c.printf("%.3f\n", y);
}
1182.478

14.3 About passing Zig values to C functions

Zig objects have some intrinsic differences between their C equivalents. Probably the most noticeable one is the difference between C strings and Zig strings, which I described at Section 1.8. Zig strings are objects that contains both an array of arbitrary bytes and a length value. On the other hand, a C string is usually just a pointer to a null-terminated array of arbitrary bytes.

Because of these intrinsic differences, in some specific cases, you cannot pass Zig objects directly as inputs to C functions before you convert them into C compatible values. However, in some other cases, you are allowed to pass Zig objects and Zig literal values directly as inputs to C functions, and everything will work just fine, because the zig compiler will handle everything for you.

So we have two different scenarios being described here. Let’s call them “auto-conversion” and “need-conversion”. The “auto-conversion” scenario is when the zig compiler handles everything for you, and automatically convert your Zig objects/values into C compatible values. In contrast, the “need-conversion” scenario is when you, the programmer, have the responsibility of converting that Zig object into a C compatible value, before passing it to C code.

There is also a third scenario that is not being described here, which is when you create a C object, or, a C struct, or a C compatible value in your Zig code, and you pass this C object/value as input to a C function in your Zig code. This scenario will be described later at Section 14.4. In this section, we are focused on the scenarios where we are passing Zig objects/values to C code, instead of C objects/values being passed to C code.

14.3.1 The “auto-conversion” scenario

An “auto-conversion” scenario is when the zig compiler automatically converts our Zig objects into C compatible values for us. This specific scenario happens mostly in two instances:

  • with string literal values;
  • with any of the primitive data types that were introduced at Section 1.5.

When we think about the second instance described above, the zig compiler does automatically convert any of the primitive data types into their C equivalents, because the compiler knows how to properly convert a i16 into a signed short, or, a u8 into a unsigned char, etc. Now, when we think about string literal values, they can be automatically converted into C strings as well, especially because the zig compiler does not forces a specific Zig data type into a string literal at first glance, unless you store this string literal into a Zig object, and explicitly annotate the data type of this object.

Thus, with string literal values, the zig compiler have more freedom to infer which is the appropriate data type to be used in each situation. You could say that the string literal value “inherits its data type” depending on the context that it is used. Most of the times, this data type is going to be the type that we commonly associate with Zig strings ([]const u8). But it might be a different type depending on the situation. When the zig compiler detects that you are providing a string literal value as input to some C function, the compiler automatically interprets this string literal as a C string value.

As an example, look at the code exposed below. Here we are using the fopen() C function to simply open and close a file. If you do not know how this fopen() function works in C, it takes two C strings as input. But in this code example below, we are passing some string literals written in our Zig code directly as inputs to this fopen() C function.

In other words, we are not doing any conversion from a Zig string to a C string. We are just passing the Zig string literals directly as inputs to the C function. And it works just fine! Because the compiler interprets the string "foo.txt" as a C string given the current context.

const c = @cImport({
    @cDefine("_NO_CRT_STDIO_INLINE", "1");
    @cInclude("stdio.h");
});

pub fn main() !void {
    const file = c.fopen("foo.txt", "rb");
    if (file == null) {
        @panic("Could not open file!");
    }
    if (c.fclose(file) != 0) {
        return error.CouldNotCloseFileDescriptor;
    }
}

Let’s make some experiments, by writing the same code in different manners, and we see how this affects the program. As a starting point, let’s store the "foo.txt" string inside a Zig object, like the path object below, and then, we pass this Zig object as input to the fopen() C function.

If we do this, the program still compiles and runs successfully. Notice that I have omitted most of the code in this example below. This is just for brevity reasons, because the remainder of the program is still the same. The only difference between this example and the previous one is just these two lines exposed below.

    const path = "foo.txt";
    const file = c.fopen(path, "rb");
    // Remainder of the program

Now, what happens if you give an explicit data type to the path object? Well, if I force the zig compiler to interpret this path object as a Zig string object, by annotating the path object with the data type []const u8, then, I actually get a compile error as demonstrated below. We get this compile error because now I’m forcing the zig compiler to interpret path as a Zig string object.

According to the error message, the fopen() C function was expecting to receive an input value of type [*c]const u8 (C string) instead of a value of type []const u8 (Zig string). In more details, the type [*c]const u8 is actually the Zig type representation of a C string. The [*c] portion of this type identifies a C pointer. So, this Zig type essentially means: a C pointer to an array ([*c]) of constant bytes (const u8).

    const path: []const u8 = "foo.txt";
    const file = c.fopen(path, "rb");
    // Remainder of the program
t.zig:2:7 error: expected type '[*c]const u8', found '[]const u8':
    const file = c.fopen(path, "rb");
                         ^~~~

Therefore, when we talk exclusively about string literal values, as long as you don’t give an explicit data type to these string literal values, the zig compiler should be capable of automatically converting them into C strings as needed.

But what about using one of the primitive data types that were introduced at Section 1.5? Let’s take code exposed below as an example of that. Here, we are giving some float literal values as input to the C function powf(). Notice that this code example compiles and runs successfully.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const cmath = @cImport({
    @cInclude("math.h");
});

pub fn main() !void {
    const y = cmath.powf(15.68, 2.32);
    try stdout.print("{d}\n", .{y});
}
593.2023

Once again, because the zig compiler does not associate a specific data type with the literal values 15.68 and 2.32 at first glance, the compiler can automatically convert these values into their C float (or double) equivalents, before it passes to the powf() C function. Now, even if I give an explicit Zig data type to these literal values, by storing them into a Zig object, and explicit annotating the type of these objects, the code still compiles and runs successfully.

    const x: f32 = 15.68;
    const y = cmath.powf(x, 2.32);
    // The remainder of the program
593.2023

14.3.2 The “need-conversion” scenario

A “need-conversion” scenario is when we need to manually convert our Zig objects into C compatible values before passing them as input to C functions. You will fall in this scenario, when passing Zig string objects to C functions.

We have already seen this specific circumstance in the last fopen() example, which is reproduced below. You can see in this example, that we have given an explicit Zig data type ([]const u8) to our path object, and, as a consequence of that, we have forced the zig compiler to see this path object, as a Zig string object. Therefore, we need now to manually convert this path object into a C string before we pass it to fopen().

    const path: []const u8 = "foo.txt";
    const file = c.fopen(path, "rb");
    // Remainder of the program
t.zig:10:26: error: expected type '[*c]const u8', found '[]const u8'
    const file = c.fopen(path, "rb");
                         ^~~~

There are different ways to convert a Zig string object into a C string. One way to solve this problem is to provide the pointer to the underlying array of bytes, instead of providing the Zig object directly as input. You can access this pointer by using the ptr property of the Zig string object.

The code example below demonstrates this strategy. Notice that, by giving the pointer to the underlying array in path through the ptr property, we get no compile errors as result while using the fopen() C function.

    const path: []const u8 = "foo.txt";
    const file = c.fopen(path.ptr, "rb");
    // Remainder of the program

This strategy works because this pointer to the underlying array found in the ptr property, is semantically identical to a C pointer to an array of bytes, i.e. a C object of type *unsigned char. This is why this option also solves the problem of converting the Zig string into a C string.

Another option is to explicitly convert the Zig string object into a C pointer by using the built-in function @ptrCast(). With this function we can convert an object of type []const u8 into an object of type [*c]const u8. As I described at the previous section, the [*c] portion of the type means that it is a C pointer. This strategy is not-recommended. But it is useful to demonstrate the use of @ptrCast().

You may recall of @as() and @ptrCast() from Section 2.5. Just as a recap, the @as() built-in function is used to explicitly convert (or cast) a Zig value from a type “x” into a value of type “y”. But in our case here, we are converting a pointer object. Everytime a pointer is involved in some “type casting operation” in Zig, the @ptrCast() function is involved.

In the example below, we are using this function to cast our path object into a C pointer to an array of bytes. Then, we pass this C pointer as input to the fopen() function. Notice that this code example compiles successfully with no errors.

    const path: []const u8 = "foo.txt";
    const c_path: [*c]const u8 = @ptrCast(path);
    const file = c.fopen(c_path, "rb");
    // Remainder of the program

14.4 Creating C objects in Zig

Creating C objects, or, in other words, creating instances of C structs in your Zig code is actually something quite easy to do. You first need to import the C header file (like I described at Section 14.2) that defines the C struct that you are trying to instantiate in your Zig code. After that, you can just create a new object in your Zig code, and annotate it with the data type of the C struct.

For example, suppose we have a C header file called user.h, and that this header file is declaring a new struct named User. This C header file is exposed below:

#include <stdint.h>

typedef struct {
    uint64_t id;
    char* name;
} User;

This User C struct have two distinct fields, or two struct members, named id and name. The field id is an unsigned 64-bit integer value, while the field name is just a standard C string. Now, suppose that I want to create an instance of this User struct in my Zig code. I can do that by importing this user.h header file into my Zig code, and creating a new object with type User. These steps are reproduced in the code example below.

Notice that I have used the keyword undefined in this example. This allows me to create the new_user object without the need to provide an initial value to the object. As consequence, the underlying memory associated with this new_user object is uninitialized, i.e. the memory is currently populated with “garbage” values. Thus, this expression have the exact same effect of the expression User new_user; in C, which means “declare a new object named new_user of type User”.

Is our responsibility to properly initialize this memory associated with this new_user object, by assigning valid values to the members (or the fields) of the C struct. In the example below, I’m assigning the integer 1 to the member id. I am also saving the string "pedropark99" into the member name. Notice in this example that I manually add the null character (zero byte) to the end of the allocated array for this string. This null character marks the end of the array in C.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const c = @cImport({
    @cInclude("user.h");
});

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var new_user: c.User = undefined;
    new_user.id = 1;
    var user_name = try allocator.alloc(u8, 12);
    defer allocator.free(user_name);
    @memcpy(user_name[0..(user_name.len - 1)], "pedropark99");
    user_name[user_name.len - 1] = 0;
    new_user.name = user_name.ptr;
}

So, in this example above, we are manually initializing each field of the C struct. We could say that, in this instance, we are “manually instantiating the C struct object”. However, when we use C libraries in our Zig code, we rarely need to manually instantiate the C structs like that. Only because C libraries usually provide a “constructor function” in their public APIs. As consequence, we normally rely on these constructor functions to properly initialize the C structs, and the struct fields for us.

For example, consider the Harfbuzz C library. This a text shaping C library, and it works around a “buffer object”, or, more specifically, an instance of the C struct hb_buffer_t. Therefore, we need to create an instance of this C struct if we want to use this C library. Luckily, this library offers the function hb_buffer_create(), which we can use to create such object. So the Zig code necessary to create such object would probably look something like this:

const c = @cImport({
    @cInclude("hb.h");
});
var buf: c.hb_buffer_t = c.hb_buffer_create();
// Do stuff with the "buffer object"

Therefore, we do not need to manually create an instance of the C struct hb_buffer_t here, and manually assign valid values to each field in this C struct. Because the constructor function hb_buffer_create() is doing this heavy job for us.

Since this buf object, and also, the new_user object from previous examples, are instances of C structs, these objects are by themselves C compatible values. They are C objects defined in our Zig code. As consequence, you can freely pass these objects as input to any C function that expects to receive this type of C struct as input. You do not need to use any special syntax, or, to convert them in any special manner to use them in C code. This is how we create and use C objects in our Zig code.

14.5 Passing C structs across Zig functions

Now that we have learned how to create/declare C objects in our Zig code, we need to learn how to pass these C objects as inputs to Zig functions. As I described at Section 14.4, we can freely pass these C objects as inputs to C code that we call from our Zig code. But what about passing these C objects to Zig functions?

In essence, this specific case requires one small adjustment in the Zig function declaration. All you need to do, is to make sure that you pass your C object by reference to the function, instead of passing it by value. To do that, you have to annotate the data type of the function argument that is receiving this C object as “a pointer to the C struct”, instead of annotating it as “an instance of the C struct”.

Let’s consider the C struct User from the user.h C header file that we have used at Section 14.4. Now, consider that we want to create a Zig function that sets the value of the id field in this C struct, like the set_user_id() function declared below. Notice that the user argument in this function is annotated as a pointer (*) to a c.User object.

Therefore, all you have to do when passing C objects to Zig functions, is to add * to the data type of the function argument that is receiving the C object. This will make sure that the C object is passed by reference to the function.

Because we have transformed the function argument into a pointer, everytime that you have to access the value pointed by this input pointer inside the function body, for whatever reason (e.g. you want to read, update, or delete this value), you have to dereference the pointer with the .* syntax that we learned from Chapter 6. Notice that the set_user_id() function is using this syntax to alter the value in the id field of the User struct pointed by the input pointer.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const c = @cImport({
    @cInclude("user.h");
});
fn set_user_id(id: u64, user: *c.User) void {
    user.*.id = id;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var new_user: c.User = undefined;
    new_user.id = 1;
    var user_name = try allocator.alloc(u8, 12);
    defer allocator.free(user_name);
    @memcpy(user_name[0..(user_name.len - 1)], "pedropark99");
    user_name[user_name.len - 1] = 0;
    new_user.name = user_name.ptr;

    set_user_id(25, &new_user);
    try stdout.print("New ID: {any}\n", .{new_user.id});
}
New ID: 25

  1. https://github.com/rust-lang/rust-bindgen↩︎

  2. https://github.com/ziglang/zig/issues/20630↩︎

  3. https://cplusplus.com/reference/cstdio/printf/↩︎

  4. https://en.cppreference.com/w/c/numeric/math/pow↩︎