Cover image for Cross-compile a C/C++ Project with Zig
Loris Cro
Loris Cro

Posted on

Cross-compile a C/C++ Project with Zig

Zig is not just a programming language but also a toolchain that can help you maintain and gradually modernize existing C/C++ projects, based on your needs. In this series we're using Redis, a popular in-memory key value store written in C, as an example of a real project that can be maintained with Zig. You can read more in "Maintain it with Zig".

What is cross-compilation?

In the previous part we saw how to use Zig to produce a build of a C/C++ project for the same target that the compiler runs on. With a working cross-compilation setup you will be able to create ARM Linux executables from x86_64 Windows, for example.

Cross-compilation is especially great when you need to release an application that runs on multiple platforms: with Zig you can create all release artifacts from a single machine!

Cross-compilation support in Zig

For Zig, cross-compilation is a primary use case and a lot of work went into making it a seamless experience, starting from bundling libc implementations for all major platforms.

If you're curious, read this blog post by Andrew Kelley to learn in detail how Zig extends clang to make cross-compilation easy.

When it comes to macOS, Zig even has a custom built linker able to cross-compile for both Intel and Apple Silicon M1, something that not even lld (LLVM's linker) can do. Thanks to that, at the moment of writing, Zig is the only C/C++ compiler able to cross-compile and cross-sign (i.e. perform codesigning from another plaftorm) for Apple Silicon.

On buildscript portability...

C/C++ projects almost never take cross-compilation into consideration, unfortunately. A project might be portable in the sense that it compiles on both macOS and Linux, for example, but almost all build scripts rely on brittle capability checks that require manual intervention to force the build script to cooperate with the cross-compilation process.

More importantly, Makefiles don't list their requirements in a declarative way and so reading everything is the only way to see what other tools a build script depends on. Other build systems might be better in that regard but in general you cannot expect the same seamless experience that we had in the previous post.

As we will see now, this is also true when it comes to Redis.

Cross-compiling Redis

If you followed the steps in the previous post, where we compiled Redis for the native target (i.e. the actual machine that the compilation process is happening on), then you will need to run make distclean to avoid confusing problems caused by stale assets that don't get properly invalidated by Make.

Here are some example cross-compilation invocations:

Targeting Intel macOS

make CC="zig cc -target x86_64-macos" CXX="zig c++ -target x86_64-macos" AR="zig ar" RANLIB="zig ranlib" USE_JEMALLOC=no USE_SYSTEMD=no
Enter fullscreen mode Exit fullscreen mode

Targeting x86-64 Linux (and musl libc)

make CC="zig cc -target x86_64-linux-musl" CXX="zig c++ -target x86_64-linux-musl" AR="zig ar" RANLIB="zig ranlib" USE_JEMALLOC=no USE_SYSTEMD=no
Enter fullscreen mode Exit fullscreen mode

Let's break down what you just saw. The first notable change are the -target switches. This part should be self-explanatory: since we're compiling for another target, we have to specify which. The second notable difference is the presence of a few new overrides, what's up with those?

By reading carefully the Makefiles (yes, there are multiple) involved in building Redis, you can see that redis-cli and redis-server share hiredis, a library that implements a parser for the communication protocol. Because of that, hiredis gets compiled as a static library and included in the compilation process of both executables. The same in fact happens also to lua, another dependency of Redis.

This method of compiling code relies on two tools: ar and ranlib, and in fact this was also happening in the previous post, when we were compiling natively, but since we already needed a bunch of system tooling (e.g., build-essential or Xcode command line tools), we could get away with not having to know that. Now we are building for another target though, so these commands need to play nice with the cross-compilation process, which requires us to use the versions provided by Zig.

Let's talk now about another override, USE_JEMALLOC. Redis by default uses jemalloc on x86-64 Linux. Unfortunately, jemalloc is a C++ library that uses CMake, which complicates the build process by a lot. For the sake of brevity I've forced the use of vanilla malloc from libc, but if you know CMake, it should still be possible to make it cross-compilation aware. Consider it an exercise left to the reader.

So the compilation succeeds, are we done now? Well, not really. There are a few other details in the Makefile that it's important we understand.

Fixing Makefiles

Let's look at the two most important lines in the main Redis Makefile (src/Makefile).

uname_S := $(shell sh -c 'uname -s 2>/dev/null || echo not')
uname_M := $(shell sh -c 'uname -m 2>/dev/null || echo not')
Enter fullscreen mode Exit fullscreen mode

These lines use a shell command to get OS and architecture of the current machine. You can already see how this can be problematic when it comes to cross compilation.

The fix in this case is to just provide overrides on the command line, but first we should take a look at how these variables are being used to better understand what to override them with.

# Default allocator defaults to Jemalloc if it's not an ARM
ifneq ($(uname_M),armv6l)
ifneq ($(uname_M),armv7l)
ifeq ($(uname_S),Linux)

# ...

ifeq ($(USE_JEMALLOC),yes)

ifeq ($(USE_JEMALLOC),no)
Enter fullscreen mode Exit fullscreen mode

This example shows how uname_M contains the CPU architecture, while uname_S the OS name. We also see that the variable is loosely matched with some keywords, which means that we don't need to be too precise with our override. This particular check is not important for us because we are already disabling jemalloc from the command line, but it reveals the usage pattern of two important variables. The way OS name and version get used in a Makefile is not standardized and you will need to learn what's the right way of overriding them on a case by case basis.

The Makefile contains another curious capability check:

# Detect if the compiler supports C11 _Atomic
C11_ATOMIC := $(shell sh -c 'echo "\#include <stdatomic.h>" > foo.c; \
    $(CC) -std=c11 -c foo.c -o foo.o > /dev/null 2>&1; \
    if [ -f foo.o ]; then echo "yes"; rm foo.o; fi; rm foo.c')
ifeq ($(C11_ATOMIC),yes)
Enter fullscreen mode Exit fullscreen mode

You can see here how the Makefile tries to compile a C program that imports stdatomic.h and uses the exit code to test if C11 capabilities are supported or not. In our case we can safely say that Zig supports C11, so can replace this check with a command line override, or we can just leave it there since it will always succeed.

Let's take a look at one last passage from the Makefile:

# If 'USE_SYSTEMD' in the environment is neither "no" nor "yes", try to
# auto-detect libsystemd's presence and link accordingly.
ifneq ($(USE_SYSTEMD),no)
    LIBSYSTEMD_PKGCONFIG := $(shell $(PKG_CONFIG) --exists libsystemd && echo $$?)
# If libsystemd cannot be detected, continue building without support for it
# (unless a later check tells us otherwise)
    LIBSYSTEMD_LIBS=$(shell $(PKG_CONFIG) --libs libsystemd)
Enter fullscreen mode Exit fullscreen mode

If you are compiling for Linux, Redis will try to detect if you want SystemD support, but since we're cross-compiling we need to be explicit in our intent.

For the sake of brevity, I've simply disabled support for SystemD in the build command, but if you wanted to enable it, you'd have to procure the right header files and make them available to the compilation process, which might also require rewriting some of the logic shown in the code snippet above.

There are some more examples of checks that rely on the execution of shell commands, I leave finding them as an exercise to the reader.

Based on what we found in the Makefile, this is an example set of overrides to cross-compile for Linux: uname_S="Linux" uname_M="x86_64" C11_ATOMIC=yes USE_JEMALLOC=no USE_SYSTEMD=no.

The final command

make CC="zig cc -target x86_64-linux-musl" CXX="zig c++ -target x86_64-linux-musl" AR="zig ar" RANLIB="zig ranlib" uname_S="Linux" uname_M="x86_64" C11_ATOMIC=yes USE_JEMALLOC=no USE_SYSTEMD=no
Enter fullscreen mode Exit fullscreen mode

This is the same command you would run to produce a release of Redis regardless of which OS you're on.


Redis doesn't target windows, but if you have make through msys, cygwin, or mingw, you can still use Windows to cross-compile to Linux or Mac using the above commands.

What's next?

Dealing with build scripts can very quickly get out of hand, and even in a fairly disciplined project like Redis you can see how multiple build systems can intertwine, making everything needlessly janky.

In the next post we're going to see how to get rid of all these build systems and just rely on zig build. Not only this makes understanding the build process dramatically easier, but it also makes cross-compilation completely seamless and frees you from all the system dependencies that Make, CMake, etc require.

This means that we'll be able to build Redis without even needing build-essential, XCode, nor MSVC.

Up until now Windows hasn't had much love: Redis can't run on Windows and, while you can use Windows to cross-compile for another OS, the reliance on Make, CMake, shell scripts, etc., doesn't really help. In the next post this is all going to change.

Reproducibility footnote

Zig 0.8.1, Redis commit be6ce8a.

Oldest comments (2)

mrexodia profile image
Duncan Ogilvie

You mentioned:

Consider it an exercise left to the reader.

So here it is: github.com/mrexodia/zig-cross. There's a few hacks (notably zig ar and zig ranlib cannot be set without a script as indirection in CMake according to the devs).

kristoff profile image
Loris Cro

Wow! Thanks for sharing!