» Reproducible development environment for Teensy

» October 11, 2021 | cpp development english nix | Adrian Kummerländer

So for a change of scenery I recently started to mess around with microcontrollers again. Since the last time that I had any real contact with this area was probably around a decade ago — programming an ASURO robot — I started basically from scratch. Driven by the goal of building and programming a fancy mechanical keyboard (as it seems to be the trendy thing to do) I chose the Arduino-compatible Teensy 4.0 board. While I appreciate the rich and accessible software ecosystem for this platform, I don't really want to use some special IDE, applying amongst other things1 weird non-standard preprocessing to my code. In this vein it would also be nice to use my accustomed Nix-based toolchain which leads me to this article.

Roughly following what others did for Teensy 3.1 while adapting it to Teensy 4.0 and Nix flakes it is simple to build and flash some basic C++ programs onto a USB-attached board. The adapted version of the Arduino library is available on Github and can be compiled into a shared library using flags

MCU     = IMXRT1062
MCU_DEF = ARDUINO_TEENSY40

OPTIONS  = -DF_CPU=600000000 -DUSB_SERIAL -DLAYOUT_US_ENGLISH
OPTIONS += -D__$(MCU)__ -DARDUINO=10813 -DTEENSYDUINO=154 -D$(MCU_DEF)

CPU_OPTIONS = -mcpu=cortex-m7 -mfloat-abi=hard -mfpu=fpv5-d16 -mthumb

CPPFLAGS = -Wall -g -O2 $(CPU_OPTIONS) -MMD $(OPTIONS) -ffunction-sections -
fdata-sections
CXXFLAGS = -felide-constructors -fno-exceptions -fpermissive -fno-rtti -Wno-
error=narrowing -I@TEENSY_INCLUDE@

included into a run-of-the-mill Makefile and relying on the arm-none-eabi-gcc compiler. Correspondingly, the derivation for the core library core.nix is straight forward. It clones a given version of the library repository, jumps to the teensy4 directory, deletes the example main.cpp file to exclude it from the library and applies a Makefile adapted from the default one. For the result only headers, common flags and the linker script IMXRT1062.ld are exported.

As existing Arduino sketches commonly consist of a single C++ file (ignoring some non-standard stuff for later) most builds can be handled generically by a mapping of *.cpp files into flashable *.hex files. This is realized by the following function based on the teensy-core derivation and a default makefile:

build = name: source: pkgs.stdenv.mkDerivation rec {
  inherit name;

  src = source;

  buildInputs = with pkgs; [
    gcc-arm-embedded
    teensy-core
  ];

  buildPhase = ''
    export CC=arm-none-eabi-gcc
    export CXX=arm-none-eabi-g++
    export OBJCOPY=arm-none-eabi-objcopy
    export SIZE=arm-none-eabi-size

    cp ${./Makefile.default} Makefile
    export TEENSY_PATH=${teensy-core}
    make
  '';

  installPhase = ''
    mkdir $out
    cp *.hex $out/
  '';
};

The derivation yielded by build "test" ./test results in a result directory containing a *.hex file for each C++ file contained in the test directory. Adding a loader function to be used in convenient nix flake run commands

loader = name: path: pkgs.writeScript name ''
  #!/bin/sh
  ${pkgs.teensy-loader-cli}/bin/teensy-loader-cli --mcu=TEENSY40 -w ${path}
'';

a reproducible build of the canonical blink example2 is realized using:

nix flake clone git+https://code.kummerlaender.eu/teensy-env --dest .
nix run .#flash-blink

Expanding on this, the teensy-env flake also provides convenient image(With) functions for building programs that depend on additional Arduino libraries such as for controlling servos. E.g. the build of a program test.cpp placed in a src folder

#include <Arduino.h>
#include <Servo.h>

extern "C" int main(void) {
  Servo servo;
  // Servo connected to PWM-capable pin 1
  servo.attach(1);
  while (true) {
    // Match potentiometer connected to analog pin 7
    servo.write(map(analogRead(7), 0, 1023, 0, 180));
    delay(20);
  }
}

is fully described by the flake:

{
  description = "Servo Test";

  inputs = {
    teensy-env.url = git+https://code.kummerlaender.eu/teensy-env;
  };

  outputs = { self, teensy-env }: let
    image = teensy-env.custom.imageWith
      (with teensy-env.custom.teensy-extras; [ servo ]);

  in {
    defaultPackage.x86_64-linux = image.build "servotest" ./src;
  };
}

At first I expected the build of uLisp3 to proceed equally smoothly as this implementation of Lisp for microcontrollers is provided as a single ulisp-arm.ino file. However, the *.ino extension is not just for show here as beyond even the replacement of main by loop and setup — which would be easy to fix — it relies on further non-standard preprocessing offered by the Arduino toolchain. I quickly aborted my efforts towards patching in e.g. the forward-declarations which are automagically added during the build (is it really such a hurdle to at least declare stuff before referring to it… oh well) and instead followed a less pure approach using arduino-cli to access the actual Arduino preprocessor.

arduino-cli core install arduino:samd
arduino-cli compile --fqbn arduino:samd:arduino_zero_native --preprocess ulisp-
arm.ino > ulisp-arm.cpp

The problematic line w.r.t. to reproducible builds in Nix is the installation of the arduino:samd toolchain which requires network access and wants to install stuff to home. Pulling in arbitrary stuff over the network is of course not something one wants to do in an isolated and hopefully reproducible build environment which is why this kind of stuff is heavily restricted in common Nix derivations. Luckily it is possible to misuse (?) a fixed-output derivation to describe the preprocessing of ulisp-arm.ino into a standard C++ ulisp-arm.cpp compilable using the GCC toolchain.

The relevant file ulisp.nix pulls in the uLisp source from Github and calls arduino-cli to install its toolchain to a temporary home folder followed by preprocessing the source into the derivation's output. The relevant lines for turning this into a fixed-output derivation are

outputHashMode = "flat";
outputHashAlgo = "sha256";
outputHash = "mutVLBFSpTXgUzu594zZ3akR/Z7e9n5SytU6WoQ6rKA=";

to declare the hash of the resulting file. After this point building and flashing uLisp using the teensy-env flake works the same as for any C++ program. The two additional SPI and Wire library dependencies are added easily using imageWith:

teensy-ulisp = let
  ulisp-source = import ./ulisp.nix { inherit pkgs; };
  ulisp-deps   = with teensy-extras; [ spi wire ];
in (imageWith ulisp-deps).build
  "teensy-ulisp"
  (pkgs.linkFarmFromDrvs "ulisp" [ ulisp-source ]);

So we are now able to build and flash uLisp onto a conveniently attached Teensy 4.0 board using only:

nix flake clone git+https://code.kummerlaender.eu/teensy-env --dest .
nix run .#flash-ulisp

Connecting finally via serial terminal screen /dev/ttyACM0 9600 we end up in a LISP environment where we can play around with the microcontroller at our leisure without reflashing.

59999> (* 21 2)
42

59999> (defun blink (&optional x)
         (pinmode 13 t)
         (digitalwrite 13 x)
         (delay 1000)
         (blink (not x)))

59966> (blink)

As always, the code of everything discussed here is available via Git on code.kummerlaender.eu. While I only focused on Teensy 4.0 it should be easy to adapt to other versions by changing the compiler flags using PaulStoffregen/cores as a reference.

  1. e.g. forcing me to patch my XMonad config to even get a usable UI…↩︎

  2. Simply flashing the on-board LED periodically↩︎

  3. Interactive development using a Lisp REPL on a microcontroller, how much more can you really ask for?↩︎