Cross-Compiling and packaging C, Go and Zig projects with Nix

Part 1: C

The C source code we’re gonna compile is available at GitHub and not reproduced here for brevity.

Setting up the Build System

Let’s have a trivial Makefile:

bindir=$(DESTDIR)/bin

all: zicross_demo_c
	
%.o: %.c $(DEPS)
	$(CC) -c -o $@ $< $(CFLAGS)
	
zicross_demo_c: main.o
	$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

$(bindir):
	mkdir -p $@

install: zicross_demo_c $(bindir)
	cp -t $(bindir) zicross_demo_c

.PHONY: install

As you can see, we’re simply telling the system’s C compiler CC to compile our source file main.c to an object file main.o and then link it into an executable zicross_demo_c. We’re relying on the variables CFLAGS and LDFLAGS to provide the necessary arguments to specify our target architecture/system and link against SDL.

In the source code, we’re also including resources.h, a header that doesn’t exist. This will provide the constant resources_data which will be the path to our resource files. In this case, we want to bundle the resource file logo.txt which contains the Zicross logo in ASCII-art.

To generate resources.h, inject the necessary CFLAGS and LDFLAGS, and provide the resource file, we’ll need a build system. We’re going to use Nix Flakes, for which Zicross has been written, so you need the nix utility installed with the experimental flakes feature enabled. You can instead follow the article’s descriptions and adopt them for whatever build system you prefer.

Let’s write a flake.nix:

{
  inputs = {
    zicross.url = github:flyx/Zicross;
    nixpkgs.url = github:NixOS/nixpkgs/nixos-22.05;
    utils.url   = github:numtide/flake-utils;
  };
  outputs = {self, zicross, nixpkgs, utils}:
      with utils.lib; eachSystem allSystems (system: let
    pkgs = import nixpkgs {
      inherit system;
      overlays = [
        zicross.overlays.zig
        zicross.overlays.debian
        zicross.overlays.windows
      ];
    };
    
    pname = "zicross_demo_c";
    version = "0.1.0";
  in rec {
    packages = rec {
      demo = pkgs.zigStdenv.mkDerivation {
        nativeBuildInputs = [ pkgs.pkg-config ];
        buildInputs = with pkgs; [ SDL2 libiconv ];
        inherit pname version;
        makeFlags = [ "DESTDIR=${placeholder "out"}" ];
        targetSharePath="${placeholder "out"}/share";
        src = ./.;
        postConfigure = ''
          cat <<EOF >resources.h
          static const char *resources_data = "$targetSharePath/logo.txt";
          EOF
        '';
        preBuild = ''
          export CFLAGS="$CFLAGS $(pkg-config --cflags sdl2)"
          export LDFLAGS="$LDFLAGS $(pkg-config --libs sdl2)"
        '';
        preInstall = ''
          mkdir -p $out/share
          cp ${zicross.lib.logo_data} $out/share/logo.txt
        '';
        meta = {
          maintainers = [ "Felix Krause <contact@flyx.org>" ];
          description = "Zicross Demo App (in C)";
        };
      };
    };
  };
}

We’re building our pkgs from Nixpkgs with zicross.overlays.zig, which provides us with the Zig compiler. We’ll be needing zicross.overlays.debian and zicross.overlays.windows later on for cross-compiling. pkgs.zigStdenv is an stdenv that uses Zig as C compiler by setting up CC appropriately. Generally, this is the CC script:

#!/bin/bash
ADDITIONAL_FLAGS=
if ! [ -z ''${ZIG_TARGET+x} ]; then
  ADDITIONAL_FLAGS="$ADDITIONAL_FLAGS -target $ZIG_TARGET"
fi
zig cc $ADDITIONAL_FLAGS $@

So what we do is to check whether the variable ZIG_TARGET is set and if so, hand it over to the compiler via -target. This is our hook to do cross-compiling. The actual script does some additional Nix-specific things and can be inspected here.

Back to our flake: Our buildInputs are SDL2 and libiconv, a dependency of SDL2 on some systems. We give DESTDIR in makeFlags to tell Make to write the result into the out directory, the default target directory for Nix derivations.

targetSharePath is a path into the share subdirectory of out, where we will put our logo file. This expands to an absolute path into the Nix store (let’s remember that for later when we cross-package and need to set this up differently). In postConfigure, we write our resources.h file and build the path to the logo file based on targetSharePath.

In preBuild, we use pkg-config to set up our CFLAGS and LDFLAGS for linking against SDL2. Finally, in preInstall, we copy the logo file to the share subdirectory. Now, we can natively build and run our application via

$ nix build .#demo
$ result/bin/zicross_demo_c

Cross-Compiling for Debian on Raspberry Pi

Now that we can compile natively, let’s cross-compile the application for Debian on a Raspberry Pi. To do that, add the following package in flake.nix:

      rpiDeb = pkgs.packageForDebian demo {
        targetSystem = "armv7l-hf-multiplatform";
        pkgConfigPrefix = "/usr/lib/arm-linux-gnueabihf/pkgconfig";
        includeDirs = [ "/usr/include" "/usr/include/arm-linux-gnueabihf" ];
        name = "zicross-demo-c";
        inherit version;
        deps = {
          sdl2 = {
            path = "debian/pool/main/libs/libsdl2/libsdl2-2.0-0_2.0.14+dfsg2-3_armhf.deb";
            sha256 = "1z3bcjx225gp6lcbcd7h15cvhjik089y5pgivl2v3kfp61zm9wv4";
            dev = {
              path = "debian/pool/main/libs/libsdl2/libsdl2-dev_2.0.14+dfsg2-3_armhf.deb";
              sha256 = "17d8qms1p7961kl0g7hgmkn0qx9avjnxwlmsvx677z5xb8vchl3y";
            };
            packageName = "libsdl2-2.0-0";
            minVersion = "2.0.0";
          };
          libcrypt = {
            path = "debian/pool/main/libx/libxcrypt/libcrypt1_4.4.18-4_armhf.deb";
            sha256 = "0mcr0s5dwcj8rlr70sf6n3271pg7h73xk6zb8r7xvhp2fm51fyri";
            packageName = "libcrypt1";
            minVersion = "1:4.4.18";
          };
        };
      };

We call packageForDebian, a function provided by Zicross, on our demo derivation, and supply some additional information. For people not familiar with Nix, demo is a description of how a package is built, and packageForDebian can produce a modified description based on this one, which can then be evaluated to build a different package.

Let’s discuss the information we provide:

$ nix-hash --type sha256 --to-base32 <hex input>

Zicross will download the specified packages. In their original state, they are not usable because their pkg-config descriptions assume they are unpacked into the system root. But we’re certainly not putting them into the host’s /usr. Zicross will put them into the Nix store, but you can put them anywhere. The important thing is that we need to patch the *.pc files so that they point to the directory we unpacked into. For example, these are the first lines of the original /usr/lib/arm-linux-gnueabihf/pkgconfig/sdl2.pc file:

# sdl pkg-config source file

prefix=/usr
exec_prefix=${prefix}
libdir=${prefix}/lib/arm-linux-gnueabihf
includedir=${prefix}/include

Since it is neatly organized, we only need to change the prefix=/usr line to point to wherever we unpacked the package. Zicross uses a shellscript for this, resulting in:

# sdl pkg-config source file

prefix=/nix/store/j11idzdglij4maxaz7jw0f2z2wd49wb2-dpkg-sdl2/usr
exec_prefix=${prefix}
libdir=${prefix}/lib/arm-linux-gnueabihf
includedir=${prefix}/include

pkg-config actually has the capability to do

$ pkg-config --define-variable=prefix=<store-path>/usr …

which overrides the given prefix. However, this wouldn’t be transparent to the build system anymore, as we’d need a different pkg-config invocation for each dependency. This is why we go with modifying the .pc files instead.

There is also a PKG_CONFIG_SYSROOT_DIR variable we could set. Zicross makes each dependency into a standalone derivation (so it can be re-used), hence we do not have a single sysroot, which makes PKG_CONFIG_SYSROOT_DIR ill-equipped for our purposes.

Now we need to set up pkg-config to consume our .pc files instead of the ones of the host system. We do not want to link against any native libraries that might be available on the host. For this, we must append the directories containing .pc files to PKG_CONFIG_PATH. Zicross does this automatically by replacing the original buildInputs with the provided deps. As long as all packages queried are in PKG_CONFIG_PATH, pkg-config will not search the host system’s packages.

Let us now cross-compile our application:

$ nix build .#rpiDeb
$ readlink result
/nix/store/g7mync647ilrcm8xqa510rlvfmlikyxq-zicross-demo-c-0.1.0.deb

So besides cross-compiling, Zicross also packaged our application. What it did was to use the meta information on the package to write a Debian control file, and then package the compiled binary with dpkg (see this derivation). Let’s see what it looks like:

$ dpkg -I result
 new Debian package, version 2.0.
 size 3756 bytes: control archive=280 bytes.
     233 bytes,     9 lines      control              
 Package: zicross-demo-c
 Version: 0.1.0
 Section: base
 Priority: optional
 Architecture: armhf
 Depends: libcrypt1 (>= 1:4.4.18), libsdl2-2.0-0 (>= 2.0.0)
 Maintainer: Felix Krause <contact@flyx.org>
 Description: Zicross Demo App (in C)
 

Note how libcrypt was not in the dependencies specified by our build script, we only added this for cross-compiling. The dependencies have minimal versions specified as given in flake.nix. As long as there are no API breaks, this package works with any newer version of libsdl2 and does not require the version we used for linking.

You can now copy this .deb package to a Debian on a Raspberry Pi and install it via

$ sudo apt install ./zicross-demo-c-0.1.0.deb

Currently, Zicross does not implement signing of the package so it is only useful locally.

Cross-Compiling for x86_64 Windows

Unlike Debian, Windows does not have a primary, default package manager. Usually, Windows applications are spread via an installer or just a .zip file which contains the application and all its dependencies. Zicross allows us to build the latter.

Thankfully, MSYS2 provides pacman-based repositories with packages that provide .pc files for pkg-config. We’ll be using the clang64 repository to query dependencies, similarly to what we did for Debian.

This is what we’ll add to our Flake’s packages:

      win64Zip = pkgs.packageForWindows demo {
        targetSystem = "x86_64-windows";
        appendExe = [ "zicross_demo_c" ];
        deps = {
          sdl2 = {
            tail = "SDL2-2.0.22-1-any.pkg.tar.zst";
            sha256 = "13v4wavbxzdnmg6b7qrv7031dmdbd1rn6wnsk9yn4kgs110gkk90";
            postPatch = ''
              ${pkgs.gnused}/bin/sed -i "s:-lSDL2main:$out/clang64/lib/libSDL2main.a:g" upstream/clang64/lib/pkgconfig/sdl2.pc
            '';
          };
          iconv = {
            tail = "libiconv-1.16-2-any.pkg.tar.zst";
            sha256 = "0kwc5f60irrd5ayjr0f103f7qzll9wghcs9kw1v17rj5pax70bxf";
          };
          vulkan = {
            tail = "vulkan-loader-1.3.211-1-any.pkg.tar.zst";
            sha256 = "0n9wnrcclvxj7ay14ia679s2gcj5jyjgpg53j51yfdn48wlqi40l";
          };
          libcpp = {
            tail = "libc++-14.0.3-1-any.pkg.tar.zst";
            sha256 = "1r73zs9naislzzjn7mr3m8s6pikgg3y4mv550hg09gcsjc719kzz";
          };
          unwind = {
            tail = "libunwind-14.0.3-1-any.pkg.tar.zst";
            sha256 = "1lxb0qgnl9fbdmkmj53zjg8i9q5hv0pa83bkmraf2raflpm2yrs5";
          };
        };

Somewhat similar to what we did before, with some differences:

Let’s build it:

$ nix build .#win64Zip
$ unzip -Z1 result # list content
zicross_demo_c-0.1.0-win64/
zicross_demo_c-0.1.0-win64/bin/
zicross_demo_c-0.1.0-win64/bin/libcharset-1.dll
zicross_demo_c-0.1.0-win64/bin/libc++.dll
zicross_demo_c-0.1.0-win64/bin/libiconv-2.dll
zicross_demo_c-0.1.0-win64/bin/zicross_demo_c.exe
zicross_demo_c-0.1.0-win64/bin/libvulkan-1.dll
zicross_demo_c-0.1.0-win64/bin/SDL2.dll
zicross_demo_c-0.1.0-win64/bin/libunwind.dll
zicross_demo_c-0.1.0-win64/share/
zicross_demo_c-0.1.0-win64/share/logo.txt

Since we bundle the .dll files with our application, we need to keep the dependencies up-to-date – unlike with Debian, where we need the library files only for linking and then let the package manager fetch the actually used versions on the target system.

Resource Files

What we didn’t discuss yet is how the resource path is handled. Zicross automatically overrides targetSharePath when cross-compiling, and puts the share files there: When targeting Debian, the path will become /usr/share/<name> where <name> is the name of the Debian package. When targeting Windows, the path will become ../share, which works because the working directory of an .exe file, when run via double-click, is the file’s parent directory.

So what happens is that the path to our share folder is hardcoded into our binary, and is an absolute path for native Nix compiling and Debian cross-compiling, but a relative path for Windows cross-compiling.

Conclusion

With some modifications in the right places, we can consume pkg-config configuration from foreign package repositories to cross-compile our code. Due to the consistency of pkg-config configurations, this can be automated, which is what Zicross does, among other things. Lots of tools use pkg-config, so this is a good foundation for more complex projects. For example, CMake allows you to use pkg-config to search for your dependencies.

In the next part, we will apply this knowledge to Go projects.