Building and flashing Rust-firmware to an STM MCU with Nix

The goal is to make a reproducible environment for both my laptop and desktop. You can assume that both my systems are x86-64 based and both are running NixOS. I will be developing for the STM32L0 target which is programmed by a ST-Link V2/1.

I don't like C++ and C requires too much typing, so i settled on developing mainly on Rust. This may bite me in the ass later because i don't know how Rust can be configured to compile outside of the std library.

We will be reading the following guide: https://docs.rust-embedded.org/

By the end of this, I will have the following commands

$ nix run .#firmware // builds the firmware
$ nix run .#flash // flashes the MCU

$ nix run // default target (builds + flash) 

The tools

Just to be clear, i don't have any experience creating my own tool chains so this will be a first, but I gather I will need a compiler/linker, and a flasher/debugger.

The compiler

For the compiler i don't have too much of a choice, i will be using rusc to target thumbv6m-none-eabi. https://doc.rust-lang.org/rustc/platform-support/thumbv6m-none-eabi.html. This specific compiler targets the arm-m0 (actually thumb) architecture . While looking at the documentation of this platform I noticed that it's considered a Tier-2 target. I have never heard of that before, but it just means that the rustc team guarantees building/compilation but binaries may not pass tests. As opposed to Tier-3 which does not guarantee compilation.

The flasher/debugger

For flashing and debugging I will use stlink and openocd.

flake.nix

Note the following

{
  description = "Simple embedded development for STM32L0538";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, rust-overlay }:
    let
      lib = nixpkgs.lib;
      systems = [ "x86_64-linux" ];
      forAllSystems = lib.genAttrs systems;
      rustTarget = "thumbv6m-none-eabi";

      mkPkgs = system:
        import nixpkgs {
          inherit system;
          overlays = [ rust-overlay.overlays.default ];
        };

      mkFirmwareBundle = system:
        let
          pkgs = mkPkgs system;

          rustToolchain = pkgs.rust-bin.stable.latest.default.override {
            extensions = [
              "clippy"
              "llvm-tools-preview"
              "rust-src"
              "rustfmt"
            ];
            targets = [ rustTarget ];
          };

          rustLinker = "${rustToolchain}/bin/rust-lld";

          rustPlatform = pkgs.makeRustPlatform {
            cargo = rustToolchain;
            rustc = rustToolchain;
          };

          hasCargoToml = builtins.pathExists ./Cargo.toml;
          hasCargoLock = builtins.pathExists ./Cargo.lock;

          missingFirmware = pkgs.runCommand "stm32l0-firmware-missing" { } ''
            echo "Firmware source is incomplete." >&2
            ${lib.optionalString (!hasCargoToml) "echo 'Missing Cargo.toml at the repository root.' >&2"}
            ${lib.optionalString (!hasCargoLock) "echo 'Missing Cargo.lock at the repository root.' >&2"}
            echo "Add the firmware crate, then run: nix build .#firmware" >&2
            exit 1
          '';

          firmware =
            if hasCargoToml && hasCargoLock then
              rustPlatform.buildRustPackage {
                pname = "stm32l0-firmware";
                version = "0.1.0";

                src = lib.cleanSource ./.;
                cargoLock.lockFile = ./Cargo.lock;

                strictDeps = true;
                doCheck = false;
                CARGO_BUILD_TARGET = rustTarget;
                CARGO_TARGET_THUMBV6M_NONE_EABI_LINKER = rustLinker;

                nativeBuildInputs = with pkgs; [
                  findutils
                  llvmPackages.bintools
                ];

                installPhase = ''
                  runHook preInstall

                  mkdir -p "$out"

                  artifact_count="$(find "target/${rustTarget}/release" -maxdepth 1 -type f -executable | wc -l)"

                  if [ "$artifact_count" -eq 0 ]; then
                    echo "No firmware artifact found under target/${rustTarget}/release" >&2
                    exit 1
                  fi

                  if [ "$artifact_count" -gt 1 ]; then
                    echo "Multiple firmware artifacts found under target/${rustTarget}/release" >&2
                    echo "Set up a single binary target or specialize the flake install phase." >&2
                    find "target/${rustTarget}/release" -maxdepth 1 -type f -executable >&2
                    exit 1
                  fi

                  artifact="$(find "target/${rustTarget}/release" -maxdepth 1 -type f -executable | head -n 1)"

                  cp "$artifact" "$out/firmware.elf"
                  llvm-objcopy -O binary "$artifact" "$out/firmware.bin"

                  runHook postInstall
                '';
              }
            else
              missingFirmware;

          flashTool = pkgs.writeShellApplication {
            name = "flash-firmware";

            runtimeInputs = with pkgs; [
              probe-rs-tools
              stlink
            ];

            text = ''
              tool="''${FLASH_TOOL:-probe-rs}"

              case "$tool" in
                probe-rs)
                  chip="''${PROBE_RS_CHIP:-STM32L053C8Tx}"
                  exec probe-rs download --chip "$chip" ${firmware}/firmware.elf
                  ;;
                st-flash)
                  address="''${FLASH_ADDRESS:-0x08000000}"
                  exec st-flash write ${firmware}/firmware.bin "$address"
                  ;;
                *)
                  echo "Unsupported FLASH_TOOL: $tool" >&2
                  echo "Use FLASH_TOOL=probe-rs or FLASH_TOOL=st-flash." >&2
                  exit 1
                  ;;
              esac
            '';
          };
        in
        {
          packages = {
            default = firmware;
            firmware = firmware;
            flash = flashTool;
          };

          apps = {
            flash = {
              type = "app";
              program = "${flashTool}/bin/flash-firmware";
            };
          };

          devShells = {
            default = pkgs.mkShell {
              packages = with pkgs; [
                rustToolchain
                llvmPackages.bintools
                probe-rs-tools
                stlink
              ];

              CARGO = "${rustToolchain}/bin/cargo";
              RUSTC = "${rustToolchain}/bin/rustc";
              RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
              CARGO_TARGET_THUMBV6M_NONE_EABI_LINKER = rustLinker;

              shellHook = ''
                echo "STM32L0 Rust development shell"
                echo "Target: ${rustTarget}"
                echo "Cargo:  $CARGO"
                echo "Rustc:  $RUSTC"
                echo "Linker: $CARGO_TARGET_THUMBV6M_NONE_EABI_LINKER"
                echo "Init:   cargo init --bin ."
                echo "Build:  nix build .#firmware"
                echo "Flash:  nix run .#flash"
              '';
            };
          };
        };
    in
    {
      packages = forAllSystems (system: (mkFirmwareBundle system).packages);
      apps = forAllSystems (system: (mkFirmwareBundle system).apps);
      devShells = forAllSystems (system: (mkFirmwareBundle system).devShells);
    };
}

Reference

See the HAL I will be using:

https://github.com/stm32-rs/stm32l0xx-hal