Cross-compile Haskell programs for the Raspberry Pi using Nix

Table of contents

  1. Goals

  2. Objectives

  3. Pre-requisites

  4. Overall idea

  5. Step-by-step

    1. Create a common configuration file

    2. Use Haskell.nix for compilation

    3. Configure cross-compilation

    4. Build / Cross-compile

    5. Run the cross-compiled program (QEMU)

    6. Patch for non-Nix OSes

    7. Shell (bonus)

  6. Conclusion

Goals

Our goal is to seamlessly build any Haskell programs to run on a Raspberry Pi, whatever the building platform is: a Raspberry Pi, a classic Desktop computer or any other platform that can run GHC.

Haskell.nix is still evolving but should be quite stable at the time of writing. I’ll try to keep this content up-to-date. Also some of these instructions don’t necessarily reflect the documentation: I’ve added some specific parts to suit my needs.

Objectives

Reading through this post, we will learn to:

  • Produce a Haskell program binary that can be executed on a Raspberry Pi from any building computer
  • Retain the capability of compiling and running the same Haskell program on the building computer (tests…)

The instructions in this post should be enough to achieve our objectives. The sample code used here is available on one of my GitHub repsitory.

Pre-requisites

You only need basic knowledges in Haskell, GHC and Nix to go through this post. More specifically, you need to:

  • Understand how a Stack or Cabal Haskell project basically works, in case something goes wrong
  • Know how to use the basic nix commands such as nix-shell and nix build, in case something goes wrong

To actually build our programs, we will need the following:

  • NixOS
  • An Internet connection (to download the required packages and binaries)
  • An existing Haskell Stack or Cabal project without external libraries (those will be handled in another post)

To test our cross-compiled program, we will need:

  • A Raspberry Pi
  • QEMU (for the targeted platform, in our case qemu-arm)

Overall idea

The compilation process will actually be performed by Nix. The Nix package manager will allow us to build our programs in a consistent and reproducible way using Nix code.

We will also use Haskell.nix to download and run GHC to compile our program for us. Haskell.nix is a Nix build architecture that can automatically convert Stack or Cabal project and its dependencies into Nix code. This code can then be used to produce a Haskell binary, including a cross-compiled one targeting the Raspberry Pi architectures.

x86 64 Program armv6l Program armv7l Program Haskell Project Nix Configuration Haskell.nix Nix Nix code

There are two ARM architectures of interest for the Raspberry Pis:

  • armv6l: Raspberry Pi, Raspberry Pi Zero. Also works with the Raspberry Pi 2, 3…
  • armv7l: Raspberry Pi 2, 3…

Choosing armv6l should work for most people.

We shall configure Nix to achieve the followings:

  • Build the Haskell program to run on the building computer, allowing us to do tests and validation
  • Build the Haskell program to run on the Raspberry Pi

A small note however on cross-compilation with Nix: the cross-compiled program will be built to actually run on the target platform with Nix. Most notably, the program will be hardcoded with a Nix LD interpreter. If you target a non-Nix OS, you will need to patch the generated program to use the default program interpreter. Fortunately, this task can also be automated with Nix code.

Any Linux distro Executable Haskell project Nix Patch Nix targeted Executable

Step-by-step

Create a common configuration file

First, create the nix/pkgs.nix file with the following content:

let
  # (1)
  haskellNix = import (builtins.fetchTarball https://github.com/input-output-hk/haskell.nix/archive/21a6d6090a64ef5956c9625bcf00a15045d65042.tar.gz) {};

  # (2)
  nixpkgsSrc = haskellNix.sources.nixpkgs-2003;
  nixpkgsArgs = haskellNix.nixpkgsArgs;

  # (3)
  native = import nixpkgsSrc nixpkgsArgs;
  crossRpi = import nixpkgsSrc (nixpkgsArgs // {
    crossSystem = native.lib.systems.examples.raspberryPi;
  });
  crossArmv7l = import nixpkgsSrc (nixpkgsArgs // {
    crossSystem = native.lib.systems.examples.raspberryPi;
  });
in {
  # (4)

  inherit haskellNix;

  inherit nixpkgsSrc nixpkgsArgs;

  inherit native crossRpi crossArmv7l;
}

This file serves different purposes:

  1. Centralize the version of Haskell.nix. Here, we are using a pinned version for this post. But you can use the lastest Git version of Haskell.nix with the following URL: https://github.com/input-output-hk/haskell.nix/archive/master.tar.gz.
  2. Centralize the version of Nixpkgs. We will especially use the pinned version by Haskell.nix, in order to use the dedicated binary cache. We will use the 20.03 version of Nixpkgs.
  3. Define the cross-compilation configuration to each target architectures. Here, we are targeting the Raspberry Pi (armv6l) and some of the latest Raspberry Pis (armv7l).
  4. Export those configurations.

You can try and target a new (GHC-compatible) platform by defining in (3) a Nix cross-compilation configuration:

  crossCustom = import nixpkgsSrc (nixpkgsArgs // {
    # Define your target platform below
    crossSystem = anotherPlatform;
  });

Once our basic dependencies have been defined, we can go on and define our Haskell project.

Use Haskell.nix for compilation

Create the default.nix file with the following content:

let
  pkgsNix = import ./nix/pkgs.nix;
in
# (1)
{ pkgs ? pkgsNix.native
}:
# (2)
pkgs.haskell-nix.project {
  src = pkgs.haskell-nix.haskellLib.cleanGit { name = "cross-haskell-app"; src = ./.; };
}

This file defines our current Haskell project (2) in terms of the current environment (1).

In our example, the environment is the building computer by default. Being a parameter, it can be changed, say to a cross-compiling environment.

As it is, we can already use Haskell.nix to build and run our program. However, we are targeting the Raspberry Pi, so there is still one bit of configuration to make.

Configure cross-compilation

Next, create the release.nix file with the following content:

let
  # (1)
  pkgsNix = import ./nix/pkgs.nix;
  pkgsNative = pkgsNix.native;
  pkgsRaspberryPi = pkgsNix.crossRpi;
  pkgsArmv7l = pkgsNix.crossArmv7l;

  # (2)
  hsApp = import ./default.nix;
  appNative = hsApp { pkgs = pkgsNative; };
  appCrossRaspberryPi = hsApp { pkgs = pkgsRaspberryPi; };
  appCrossArmv7l = hsApp { pkgs = pkgsArmv7l; };
in {
  # (3)
  native = appNative.cross-haskell-app.components.exes.cross-haskell-app-exe;
  raspberry-pi = appCrossRaspberryPi.cross-haskell-app.components.exes.cross-haskell-app-exe;
  armv7l = appCrossArmv7l.cross-haskell-app.components.exes.cross-haskell-app-exe;
}

There are several sections defined here:

  1. We start by defining the different building environment we are interested in.
  • pkgsNative represents our building computer.
  • pkgsRaspberryPi represents a Raspberry Pi (armv6l) cross-compiling environment.
  • pkgsArmv7l represents a Raspberry Pi (armv7l) cross-compiling environment.
  1. We define our Haskell project to compile for each building environment.
  2. We define each Haskell executable to build per environment. In our example, we only have a single executable in our project.

If you have added another target platform, you have to add the target environment in (1) and the corresponding Haskell project in (2).

  pkgsCustom = pkgsNix.crossCustom;

  appCrossCustom = hsApp { pkgs = pkgsCustom; };

And then, you have to declare in (3) the buildable executables:

  custom = appCrossCustom.cross-haskell-app.components.exes.cross-haskell-app-exe;

Build / Cross-compile

To build a Haskell executable, we now have different options:

  • Build for the current platform: nix build -f release.nix native
  • Cross-compile for the Raspberry Pi (armv6l): nix build -f release.nix raspberry-pi
  • Cross-compile for the Raspberry Pi (armv7l): nix build -f release.nix armv7l

And to cross-compile to your custom target platform: nix build -f release.nix custom

The compiled executable will then be available in result/bin.

result
├── bin
│   └── cross-haskell-app-exe
└── share
    └── doc
        └── arm-linux-ghc-8.6.5
            └── cross-haskell-app-0.1.0.0
                └── LICENSE

Run the cross-compiled program (QEMU)

Running the compiled program is as just easy as invoking it when native building. When cross-compiling though, the program has to be invoked through the appropriate qemu program. For our Raspberry Pi example, invoking the program can be achieved with the following command:

qemu-arm result/bin/cross-haskell-app-exe

Targeting another platform will require the appropriate emulator.

Patch for non-Nix OSes

As mentionned earlier, compiling (or cross-compiling) a program will generate a new executable targeting a Nix(OS) environment. It is however possible to target another environment, given the right informations written into the generated program.

In our case, the only issue is that the program interpreter for our targeted ARM system is not located wherever Nix would expect. For one of my personal project with a heavily customized Linux distribution, it was located at /lib/ld-linux-armhf.so.3. We must then patch our program to use that path for the interpreter. This can be done with Nix with the following configuration:

let
  pkgsNix = import ./nix/pkgs.nix;
  pkgsNative = pkgsNix.native;
  pkgsRaspberryPi = pkgsNix.crossRpi;

  hsApp = import ./default.nix;
  appCrossRaspberryPi = hsApp { pkgs = pkgsRaspberryPi; };

  # (1)
  patchForNotNixLinux = {app, name}:
    pkgsNative.runCommand "${app.name}-patched" { } ''
      set -eu
      cp ${app}/bin/${name} $out
      chmod +w $out
      ${pkgsNative.patchelf}/bin/patchelf --set-interpreter /lib/ld-linux-armhf.so.3 --set-rpath /lib:/usr/lib $out
      chmod -w $out
    '';

in {
  # (2)
  raspberry-pi-patched = patchForNotNixLinux {
    app = appCrossRaspberryPi.cross-haskell-app.components.exes.cross-haskell-app-exe;
    name ="cross-haskell-app-exe";
  };
}

In the previous code snippet:

  1. This part just instructs Nix how to build a patched program by running a predefined script. This script would call patchelf to change the interpreter path to match the interpreter in our target OS, and then would also change the default path to find any library our program needs.
  2. This part indicates how to build the patched executable (with patchForNotNixLinux) from an existing executable (appCrossRaspberryPi.cross-haskell-app.components.exes.cross-haskell-app-exe).

Shell (bonus)

A development shell is available using the following command: nix-shell -A shellFor

It is especially useful to run an Haskell executable or tests.

Conclusion

We have used Haskell.nix to cross-compile our Haskell source code for the Raspberry Pi. The program can be run on a board running NixOS, or any other distribution when patched.

An example program can be found on one of my GitHub repository.

Next, we can focus on using external libraries, especially libraries that can only run natively on the Raspberry Pi.