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

Part 3: Zig

Once again, our Zig code available at GitHub is functionally the same as the C code.

Setting up the Build System

Zig provides a native build system that uses a build.zig file. In that file, you basically assemble a directed graph with nodes being build steps and edges being dependencies.

This build system currently does not provide an official package manager. But Zicross is built upon Nix, which we can use as package manager. To facilitate this, Zicross provides a function buildZig. It uses Nix, instead of Zig, to describe the build steps, and generates a build.zig file from them. Here’s how we use it:

{
  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
      ];
    };
    zig-sdl = pkgs.fetchFromGitHub {
      owner = "MasterQ32";
      repo = "SDL.zig";
      rev = "bf72bbef8c1c113b2862ff2fab33b1fedbf159f6";
      sha256 = "9M1cBs4hY4cFp6woqYofyzgCVogAotVKp6n+Hla3w48=";
    };
    zigPackages = let
      build_options = {
        name = "build_options";
        src = ./.;
        main = "zig-sdl-build-options.zig";
        dependencies = [];
      };
      sdl-native = {
        name = "sdl-native";
        src = zig-sdl;
        main = "src/binding/sdl.zig";
        dependencies = [ build_options ];
      };
      sdl2 = {
        name = "sdl2";
        src = zig-sdl;
        main = "src/wrapper/sdl.zig";
        dependencies = [ sdl-native ];
      };
    in [ sdl2 ];
  in rec {
    packages = rec {
      demo = pkgs.buildZig {
        buildInputs = [ pkgs.SDL2 pkgs.libiconv ];
        pname = "zicross_demo_zig";
        version = "0.1.0";
        src = ./.;
        zigExecutables = [
          {
            name = "zicross_demo_zig";
            file = "main.zig";
            dependencies = zigPackages;
            install = true;
          }
        ];
        zigTests = [
          {
            name = "loadTest";
            description = "tests loading the logo";
            file = "main.zig";
            src = ./.;
            dependencies = zigPackages;
          }
        ];
        postConfigure = ''
          cat <<EOF >resources.zig
          pub const data = "$targetSharePath/logo.txt";
          EOF
        '';
        # use upstream logo file for testing
        preCheck = ''
          cat <<EOF >resources.zig
          pub const data = "${zicross.lib.logo_data}";
          EOF
        '';
        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";
        };
      };
    };
  };
}

First, we fetch zig-sdl from GitHub. This shows how Nix can depend on a library even if it is not explicitly set up to be consumed by Nix – other unofficial Zig package managers require some information in the repository. This SDL2 wrapper does have an SDK which is designed to be consumed by build.zig. This currently does not play nice with buildZig so we supply a file zig-sdl-build-options.zig that would be generated by the SDK. Then, we set up the wrapper package named sdl2, depending on the native API in sdl-native and build_options which links to our file.

Our buildInputs will create linkSystemLibrary calls in the build.zig we generate. linkSystemLibrary calls, you guessed it, call pkg-config.

In zigExecutables, we give our main file and set it up to be installed. zigTests sets up the tests we want to run. In preCheck, we change resources.zig to point to the upstream path of the logo, instead of the copy we put into the share directory when installing. This allows us to run the tests without building the output directory tree.

Let’s build and test our application:

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

If we want to have a build.zig file so that we can call zig on it directly, we can do

$ nix develop
$ eval "$configurePhase"

This gives us a build.zig file that can be used with the zig command.

Cross-Compiling for Debian on Raspberry Pi

There is nothing happening here that has not been discussed before:

      rpiDeb = pkgs.packageForDebian demo {
        targetSystem = "armv7l-hf-multiplatform";
        pkgConfigPrefix = "/usr/lib/arm-linux-gnueabihf/pkgconfig";
        name = "zicross-demo-zig";
        version = "0.1.0";
        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";
          };
        };

Test it:

$ nix build .#rpiDeb
$ dpkg -I result
 new Debian package, version 2.0.
 size 198128 bytes: control archive=276 bytes.
     228 bytes,     9 lines      control              
 Package: zicross-demo-zig
 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

Cross-Compiling for x86_64 Windows

We’re removing -lSDL2main from sdl2.pc because it does not work well with Zig. Otherwise, it’s just what we have seen before:

      win64Zip = pkgs.packageForWindows demo {
        targetSystem = "x86_64-windows";
        deps = {
          sdl2 = {
            tail = "SDL2-2.0.22-1-any.pkg.tar.zst";
            sha256 = "13v4wavbxzdnmg6b7qrv7031dmdbd1rn6wnsk9yn4kgs110gkk90";
            postPatch = ''
              ${pkgs.gnused}/bin/sed -i "s/-lSDL2main//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";
          };
        };
      };

Build and test:

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

Conclusion

With Zig at the heart of Zicross, it is not surprising that cross-compiling boils down to „specify the target system and the dependencies“. There is no requirement to use Zicross’es buildZig function; you can instead have a build.zig and setup PKG_CONFIG_PATH just like we did for C.

Whether you prefer to write your building steps in Zig or Nix is probably personal preference. Having the build script written in the implementation language does have its appeal. On the other hand, Zicross allows you to pin a certain compiler version for your project, which may be helpful since Zig is not stable yet and projects tend to track its master branch. Also you can depend on any publicly available Zig libraries without worrying about whether they support the package manager you use.