C interoperability

C interoperability

Introduction

The interoperability between Zig and C is one of the most important feature of Zig. But why ?

It is one of the main goal of Zig to incrementally improve your C/C++/Zig codebase, so you could use C code almost as much as easy as you could use Zig code.

It also allows to have both the advtanges of a modern language and a mature ecosystem, since you can use any C library in your Zig codebase.

Before seeing how to interoperate those two langages, we are going to see the main syntaxic differences between them.

Main syntaxic differences between Zig and C

There are a few main differences between the 2 languages that you have high chance of encountering but obviously not all of them are listed here.

Types

The official zig documentation gives you the Zig primitives and their equivalent in C, it is recommended to have a look at both of these links in order to realize what are the Zig equivalents for each C types.

Loops

In Zig, the for loop is not used as much as in C. Instead, the while loop is used more often. The for loop is used when iterating over multiples elements of a container (typically slices or arrays), so it basically is a kind of foreach loop. In all the other cases the while loop is used. Note that this changed recently and now Zig has new a way to simply iterrate in a loop with a for. But keep in mind that all the old code might still have that old while loop, even the official documentation still has some of them.

  const stdout = std.io.getStdOut().writer();
  const items = [_]u16 { 1, 4, 0, 1 };
  var sum: u16 = 0;

  // "foreach"
  for (items) |value| {
      sum += value;
  }

  stdout.print("Sum: {}\n", .{sum});

  // Old way
  while (i < 3) :(i += 1) {
      stdout.print("i: {}\n", .{i});
  }

  // New way
  for (0..3) |i| {
      stdout.print("i: {}\n", .{i});
  }
Pointers

Zig has 2 different pointers:

  • Single-item pointers: *T
  • Many-item pointers: [*]T

Which can both be optional by adding a ?.

But actually…. there is a third pointer type:

  • The C pointer: [*c]T

This one is to be avoided as much as possible. The only reasons for its existence is for the translation from C code to Zig code, when the translater is not able to know what to convert it to in Zig (eg. sometimes it does not know if it can convert it to a non-optional pointer or not which could cause undefined behaviors).

Type conversions

Since in Zig there is no implicit conversions, depending on the project you might end up having "ugly" code with a lot of explicit type conversions. The code is more verbose but it is also less error-prone. For instance in Zig:

  fn rgb_to_grayscale_1d(img: *imageh.img_1D_t, result: *imageh.img_1D_t) void {
      var i: usize = 0;
      while (i < @as(usize, @intCast(img.height * img.width))) : (i += 1) {
          const index = i * @as(usize, @intCast(img.components));
          const grayscale_value: u8 = @intFromFloat(imageh.FACTOR_R * @as(f64, @floatFromInt(img.data[imageh.R_OFFSET + index])) +
              imageh.FACTOR_G * @as(f64, @floatFromInt(img.data[imageh.G_OFFSET + index])) +
              imageh.FACTOR_B * @as(f64, @floatFromInt(img.data[imageh.B_OFFSET + index])));
          result.data[i] = grayscale_value;
      }
  }

and the C equivalent:

  void rgb_to_grayscale_1D(const struct img_1D_t *img, struct img_1D_t *result) {
    printf("height: %d", img->height);
    for (size_t i = 0; i < img->height * img->width; i++) {
      int index = i * img->components;
      uint8_t grayscale_value = (uint8_t)(FACTOR_R * img->data[R_OFFSET] +
                                          FACTOR_G * img->data[G_OFFSET] +
                                          FACTOR_B * img->data[B_OFFSET]);
      result->data[i] = grayscale_value;
    }
  }

How to call a C function from Zig

Add those lines to your build.zig file:

  exe.addIncludePath(.{ .path = "c-src" }); // Folder containing the C files
  //exe.addIncludePath(b.path("c-src")); // After zig 0.11.0
  exe.linkLibC(); // Link the C standard library (which is zig own libc btw)

Note that linking libc is necessary only if your C code uses the clib itself.

Then you can call the C functions like this from your Zig code:

  const std = @import("std");
  const c_hello = @cImport({
      @cInclude("hello.c");
  });
  
  pub fn main() !void {
      c_hello.hello();
  
      const res = c_hello.add(1, 2);
      std.debug.print("1 + 2 = {d}\n", .{res});
  }

Note that you can only do 1 @cImport per project. So what i recommend you to do is create a file containing all the c libraries you need in a file like so:

        pub const c = @cImport({
            @cInclude("stdio.h");
            @cInclude("stdlib.h");
            @cInclude("image.h");
        });

Then call this zig file in your other zig files.

If you C project is more complex and you want to import your header files you must tweak a few things compared to the previous version where you just import C sources files.

With a project like structure like this:

      • hello.h
      • hello.c
    • build.zig
    • First modify the build.zig file.

        exe.addIncludePath(.{ .path = "c-project/include" });
        exe.addCSourceFile(.{ .file = .{ .path = "c-project/src/hello.c" }, .flags = &.{"-std=c99"} }); // You can add multiples files by using addCSourcesFiles instead

      Then simply switch from calling the source file to the header file.

        const c_project = @cImport({
            @cInclude("hello.h");
        });

      How to call a Zig function from C

      You can continue your C project without using Clang or GCC but by using Zig with all its toolchain.

      In order to have a C file (main.c) as the entry point of your project using the zig build tool you have to modify the following lines to your build.zig file:

        const exe = b.addExecutable(.{
            .name = "c_project",
            // .root_source_file = b.path("src/main.zig"), // THIS LINE IS TO BE DELETED
            .target = target,
            .optimize = optimize,
        });
        exe.root_module.addCSourceFile(.{ .file = .{ .path = "src/main.c" }, .flags = &.{"-std=c99"} }); // THIS LINE IS TO BE ADDED
        exe.linkLibC();

      If you want to have more C files than just main.c you can add them like so:

        exe.addCSourceFile(.{ .file = .{ .path = "c-src/image.c" }, .flags = &.{"-std=c99"} });
      Controlling linking

      export the function to the outside so that the C ABI can see it.

      extern is used to link against an exported variable from an other object.

      Zig documentation for those 2 keywords.

      How is it done under the hood

      When you do @cImport(@cInclude("foo.h")) in your zig code it runs translate-c and exposes the function and type definitions of the header files. The translated code is basically a wrapper around the C code you are using. If you are intersted to see how the code is translated you can use the CLI tool zig translate-c foo.c on almost any C file.

      Util to translate C code to Zig

      zig translate-c is an util built in the zig toolchains that allows you to translate C code to Zig code. You can translate any code but the code is going to be completly unreadable, so I would not recommend this tool if you plan on modifying the code afterwards. You have better time importing the C code in your Zig code. Note that if you want to translate a C file that uses the libc you have to add the -lc flag:

        zig translate-c main.c -lc
      Comparison with other langauges that use C code

      To test if integrating C code in Zig projects is really as seemless as some claims, I have decided to compare the C integration with Python.

      In order to do that I wrote a small C library:

        int add(int a, int b) { return a + b; }

      What I am going to do is test how much time it takes each program to run this function x times. (in this case x = 100'000'000)

      Note: I did not use any optimization in flag in python (because it did not change anything) and neither in Zig because I did not want the compiler to try to optimize the code and not act as intended.

      Then in order to compare the 2 languages I wrote 4 programs:

      1: Zig code that has and add function implementation in Zig

        const std = @import("std");
        
        fn add(a: u32, b: u32) u32 {
            return a + b;
        }
        
        pub fn main() !void {
            var i: usize = 0;
            while (i < 100000000) : (i += 1) {
                _ = add(3, 7);
            }
            std.debug.print("done\n", .{});
        }

      Result: ~0.38sec

      2: Vanilla Python code that has and add function implementation in Python

        def add(a, b):
            return a + b
        
        
        for i in range(100000000):
            add(3, 7)
        print("done!")

      Result: ~10sec

      3: Zig code that imports the C library

        const std = @import("std");
        pub const c = @cImport({
            @cInclude("mylib.c");
        });
        
        pub fn main() !void {
            var i: usize = 0;
            while (i < 100000000) : (i += 1) {
                _ = c.add(3, 7);
            }
            std.debug.print("done!\n", .{});
        }

      Result: ~0.41sec

      4: Python code that imports the C library

        import ctypes
        
        mylib = ctypes.CDLL('./mylib.so')
        
        mylib.add.argtypes = (ctypes.c_int, ctypes.c_int)
        mylib.add.restype = ctypes.c_int
        
        for i in range(100000000):
            result = mylib.add(3, 4)
        
        print("Result of last addition:", result)

      Result: ~50sec

      Conclusion

      First thing that we notice immediately is how much faster the Zig code is compared to the Python code. This is not surprising since Zig is a compiled language and Python is an interpreted language.

      The second interesting thing is that the two Zig codes dont vary that much (if they even do) compared to the two python codes which have a 5x ratio. This is interesting because it shows that the overhead of calling a C function from Zig is not that big.

      Calling C code from Python implies an overhead that can become quite big depending on the situations. There is a great article if you want to dig deeper into the reasons why, but to summarize it comes down to two factors: the function call overhead and the (de)serialization overhead. In order to reduce as much as possible those overheads try to call as little times as possible C function and avoid returning or passing large number of data.

      We can conclude that calling C code from Zig is really seemless, because both respects the C ABI, making the interoperability very easy.

      Note that for some unkown reason yet my LSP becomes very slow when working in a Zig project with C files and sometimes crashes. I have to investigate this further. (ZLS 0.12.0 inside Neovim)

      Sources: