» 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.