Building an embedded mixed language code base

Building an mixed language code base, in my case C and rust can be hard sometimes, for example C does not have a proper module system and you have a lot of platform or even host specific dependencies.

This issue was kind of solved for back end workloads with the widespread adoption of containers. For building development there are devcontainers but they have different down sides, for example using debug adapters from inside the container can be very unstable (shout out to the nrf tooling for breaking all the time). And updating those containers is always a big undertaking.

But I finally found a solution to that problem: nix

What is nix

Nix is the umbrella for multiple things:

  • nix the language of the nix package manager for packaging software
  • nixpkgs a vast repository of prepackaged software, currently the biggest in the world
  • nixos a complete Linux distribution built on and with nix

For our embedded build we will be using nix and for all the dependencies nixpkgs.

Oh, and a nice benefit of using nix for building is mostly reproducible builds independent of the host OS, but we will be focusing on Linux and a occasional MacOS build.

Getting started

I wanted to build a esp32 based rust project, for that I needed the following parts:

  • esp32 compatible toolchain (C/C++, linking, ...)
  • rust compiler supporting esp32
  • esp-idf for all the platform support code
  • Something to tell nix how to use all those

Thanks to the great people on the internet most of it is available as a ready to use component for nix (with some minor changes/updates required).

For the esp32 toolchain I forked an existing repo and added support for naersk (which we will talk about later): madmo/esp32-idf-rust this transforms the espressif/idf-rust container into a nix usable package and gives us access to a esp32 compatible toolchain including a rust compiler.

For the esp-idf there is (mirrexagon/nixpkgs-esp-dev)[https://github.com/mirrexagon/nixpkgs-esp-dev], which also needed a minor patch to make it work with cargo driven esp-idf builds. See my fork with a single extra commit in it: mirrexagon/nixpkgs-esp-dev

As for the whole cargo build step I decided to use naersk as a super convenient way to be able to build a rust crate into a nix package. Definitely recommended for building rust crates in nix! great Tool!

With all the components ready we can start writing our flake.nix file to drive the build.

The flake.nix

A flake is always structured in input and output section, where the input specifies all the required inputs and the outputs describes the results from doing something with the inputs. Think of a function with arguments (inputs) and a return value (output).

{
  inputs = {
    # general packages
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    # helper utils to write nice flakes
    flake-parts.url = "github:hercules-ci/flake-parts";
    # rust build helper
    naersk.url = "github:nix-community/naersk";
    # esp-idf packaged for nix with c compilers
    nixpkgs-esp-dev.url = "github:madmo/nixpkgs-esp-dev";
    # esp32 (xtensa) enabled rust toolchain
    esp32 = {
      url = "github:madmo/esp32-idf-rust";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
  inputs@{ self, nixpkgs, flake-parts, naersk, nixpkgs-esp-dev, esp32, ... }:
  flake-parts.lib.mkFlake { inherit inputs; } {
    systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
    perSystem = { pkgs, system, ... }:
      let
        # get handle to rust toolchain
        toolchain = esp32.packages.x86_64-linux.esp32;

        # instruct naersk to use our custom toolchain
        naersk' = pkgs.callPackage naersk {
          cargo = toolchain;
          rustc = toolchain;
        };

        # specify the esp-idf version to use
        esp-idf = (pkgs.esp-idf-full.override {
          rev = "v5.1.3";
          sha256 = "sha256-0QsIFOcSx1N15t5po3TyOaNvpzBUfKaFdsRODOBoXCI=";
        });

        # hook to fix cargo esp-idf version detection (reads the esp-idf git in store)
        git-safe-dir-hook = pkgs.callPackage ({ }:
          pkgs.makeSetupHook {
            name = "git-safe-dir-hook";
            propagatedBuildInputs = [ pkgs.git esp-idf ];
            substitutions = { shell = "${pkgs.bash}/bin/bash"; };
          } (pkgs.writeScript "git-safe-dir-hook.sh" ''
            export GIT_CONFIG_GLOBAL=$(realpath .gitconfig)
            ${pkgs.git}/bin/git config --global --add safe.directory \
              '${esp-idf}'
          '')) { };

        # hook to set up required variables to make the extensa patched rust toolchain behave
        xtensa-path-hook = pkgs.callPackage ({ }:
          pkgs.makeSetupHook {
            name = "xtensa-path-hook";
            propagatedBuildInputs = [ toolchain ];
            substitutions = { shell = "${pkgs.bash}/bin/bash"; };
          } (pkgs.writeScript "xtensa-path-hook.sh" ''
            export LIBCLANG_PATH="${toolchain}/.rustup/toolchains/esp/xtensa-esp32-elf-clang/esp-17.0.1_20240419/esp-clang/lib"
            export PATH="${toolchain}/.rustup/toolchains/esp/bin:${toolchain}/.rustup/toolchains/esp/xtensa-esp-elf/esp-13.2.0_20230928/xtensa-esp-elf/bin:$PATH"
            export RUST_SRC_PATH="$(rustc --print sysroot)/lib/rustlib/src/rust/src"
          '')) { };
      in rec {
        # Configuring nixpkgs to allow python-2.7.18.8 which is marked insecure but required
        _module.args.pkgs = import inputs.nixpkgs {
          inherit system;
          overlays = [ (import "${nixpkgs-esp-dev}/overlay.nix") ];
          config = { permittedInsecurePackages = [ "python-2.7.18.8" ]; };
        };

        # define a default package pointing to the current directory
        packages.default = naersk'.buildPackage {
          src = ./.;

          # This line is absolutely required beacuse of build-std: We need to
          # download and cache all dependencies for building std for our target
          additionalCargoLock =
            "${toolchain}/.rustup/toolchains/esp/lib/rustlib/src/rust/Cargo.lock";
          copyBins = false;
          copyTarget = true;
          singleStep = true;
          nativeBuildInputs =
            [ git-safe-dir-hook xtensa-path-hook esp-idf pkgs.ldproxy ];
        };

        # This allows us to enter a interactive dev-shell with all the tools installed
        devShells.default = pkgs.mkShell {
          inputsFrom = [ packages.default ];
        };
      };
  };
}

Building the project

Now that we have our flake.nix ready we can simply build the project using nix:

nix build

Which will download and cache all the dependencies, including the ones from cargo.

Interactive development

If you want to hack on the project you can enter a interactive shell with all the tools available with ```nix develop`.

Starting a editor, for example the excellent zed from within the environment gives instant access to auto completion and linting.

Demo Project

See the hello-world demo project here for a full project targeting a esp32c6: https://git.h6t.eu/madmo/flake-test