Zig translate-c and linking equal best friends

Jul 14, 2022

We have a weekly one hour interest group for Zig programming going at Recurse this batch. It's called the Zig Zone, which is both descriptive and alliterative. This is a pattern, other examples on my calendar include:

  • Rust Rodeo
  • OCaml Oasis
  • Clojure Conclave
  • Shader Shindig
  • Haskell Hangout
  • Django Discovery

Anyway you get the idea.

We all just show up without an agenda and usually someone has something they've been working on they either want to share or pair on. It's a good time so far!

Last week Veera brought a C project that he was trying to translate into Zig, but was having trouble sorting out the linking step. This exposed a gotcha that I think would be easy to get gotted by if you're new to the tooling, so I thought I'd write about it here.

The Problem: C <-> Zig build process impedence mismatch

Consider a very simple C program consisting of:

  • main.c - the executable's entry point
  • lib.c - a utility function used by main.c
  • lib.h - the declaration of the utility function used by main.c whose definition lives in lib.c

Concretely, that may look like this:

// main.c
#include "lib.h"

int main() {
  return give_me_int_pls();
}
// lib.c
int give_me_int_pls() {
  return 42;
};
// lib.h
int give_me_int_pls();

So, the build process will have to do these things in this order:

  1. Build an object file "lib.o" from lib.c
  2. Build an executable from main.c which is linked with lib.o

A makefile for this process might look like this (using zig as a c compiler):

default: lib
    zig cc main.c lib.o

lib:
    zig cc -c lib.c -o lib.o

Note that I'm being explicit about the named output of the lib step with -o lib.o, but this would also be the default name (or you could name it arbitrarily)

So this file is describing the two steps above in a way make is able to execute.

default: lib

Says that in order to run the default target, you must first have run the lib target.

So where does lib.h come in here?

The C preprocessor literally inlines the contents of the included file. That means that what is actually compiled is this:

// main.c
int give_me_int_pls();

int main() {
  return give_me_int_pls();
}

You actually can just use the preprocessor to inline the actual definition of the function, too, and it will work fine, until it doesn't. So:

#include "lib.c"

int main() {
  return give_me_int_pls();
}

Becomes

int give_me_int_pls() {
  return 42;
};

int main() {
  return give_me_int_pls();
}

Which as you can probably tell at a glance will compile since all references are defined. This is fine:

zig cc main.c

The problem here is reusability. This is the problem header files solve.

This is contrived, but... imagine another file lib2.c that also depends on lib.c, and is used also by main.c

// lib2.c
#include "lib.c"

And

// main.c
#include "lib.c"
#include "lib2.c"

int main() {
  return give_me_int_pls();
}

Now, zig cc main.c yields

In file included from main.c:2:
In file included from ./lib2.c:1:
./lib.c:1:5: error: redefinition of 'give_me_int_pls'
int give_me_int_pls() {
    ^
./lib.c:1:5: note: previous definition is here
int give_me_int_pls() {
    ^
1 error generated.

If you think about how the preprocessor is working, this makes a lot of sense, it's simply inlining the same definition twice! It's as if you copy pasted it in multiple places and then the compiler complains about redefinition.

If #include "lib.c" is changed to #include lib.h in both main.c and lib2.c, and we try to compile zig cc main.c, we get a new error:

error(link): undefined reference to symbol '_give_me_int_pls'
error(link):   first referenced in '~/.cache/zig/o/079b4b7d39fd4b2ae5bb5ebf3607eb03/main.o'
thread 749363 panic: attempt to unwrap error: UndefinedSymbolReference

Given what we know, this is also quite intuitive since the header .h file only declares the signature of the included function, not the definition. This is where the linking step from before comes in. We are only allowed to compile the function body once, we are allowed to declare it as many times as it appears as an included header file. Otherwise, the compiler would be recompiling the same function body over and over again whenever it was included, which is extremely wasteful.

Header files are not free either of course, so one side note I'd like to mention is header guards. These are simple macro definitions that allow the compiler to only ever execute on the header files the first time they are seen. System headers all have these and it's good practice to include them on projects of any significant size, f.ex stdio.h starts with:

#ifndef    _STDIO_H_
#define    _STDIO_H_

// ...
// Lots of header code
// ...

#endif /* _STDIO_H_ */

The next time stdio.h is included in the project, it will skip over all the meat of this file since _STIOD_H_ will be defined. This is very rudimentary but useful!

What does all this have to do with zig translate-c?

Going back to the original project structure of main.c, lib.c, lib.h...

As you may have guessed by the title, Zig has functionality to automatically translate C code to Zig code. This mostly works pretty well, but of course it is an automated process, so it will never be perfect. In addition, zig is still very much unstable so it is extremely possible to run into bugs at this point still, which we did, but that's another story!

Veera was translating main.c to main.zig like:

zig translate-c main.c > main.zig

main.zig then, has some standard include cruft in it (but not too much) and the meat of the direct translation looks like:

pub extern fn give_me_int_pls(...) c_int;
pub export fn main() c_int {
    return give_me_int_pls();
}

Maybe this looks familiar! It has basically translated and inlined the declaration that was present in the lib.h file.

Attempting to compile this directly with zig will yield a familiar error:

error(link): undefined reference to symbol '_give_me_int_pls'
error(link):   first referenced in 'translate-c-ex/zig-cache/o/99916c6db8742334208eb2229c3eccad/main.o'
thread 756003 panic: attempt to unwrap error: UndefinedSymbolReference

You might expect the zig translate-c to zig build-exe steps to be more magical, but they require the same linking step as the bare C version.

So we need to have built the lib.o file and passed it into the linker:

zig build-exe main.zig lib.o

Which will work!

One more thing:

If you have run zig translate-c on both main.c and lib.c, you will end up with basically this:

// main.zig
pub extern fn give_me_int_pls(...) c_int;
pub export fn main() c_int {
    return give_me_int_pls();
}
// lib.zig
pub export fn give_me_int_pls() c_int {
    return 42;
}

Now if you're not looking closely, it is reasonable to assume that zig build-exe main.zig will "just work" in this case, since they are both zig files, right? But again, it's translating the C code directly, and the C code is simply including the header files to be linked up later. If you try zig build-exe main.zig you will hit the same familiar undefined symbol error.

What I would recommend instead, if you're working with files you control and want them to all be zig, is to use the output of zig translate-c as a starting point, and hand tune them to be more idiomatic.

To whit:

// main.zig
const give_me_int_pls = @import("./lib.zig").give_me_int_pls;

pub export fn main() c_int {
    return give_me_int_pls();
}
// lib.zig
pub export fn give_me_int_pls() c_int {
    return 42;
}

This works because it's all just zig code now! The zig compiler on its own is smart enough to do the guarding and linking steps with using the @import builtin, but with only the forward declarations, the linking step is necessary since the compiler and linker really won't have any idea of where to look for the definitions unless you specify them in the linking step.

Also, though I'm using the translate-c command in the makefile target, I really don't think it's intended to be integrated into a build process like that, and is more designed for one time use and fine-tuning as I suggest above. This is in fact the plan for maintaining the C++ stage 1 compiler in perpetuity post bootstrapping!

You can see some of these examples here.

Addenda

  1. I forgot all about this but I basically wrote this whole post before realizing I had kind of already written it before. Double content.

  2. The next step here would be to use the zig build system directly instead of make. I haven't learned to work with that yet, and this post is just a linking example.