Rust has grown on me over the past year. I consumed The Book while on a plane ride in February of last year right before Covid hit. It has basically been all downhill since. 😅
Since then, i’ve designed a (very alpha) CLI based MRP system, e-commerce backend for my static website, CLI based tester and firmware for my nRF9160 Feather test fixture. Yea, all in Rust.
In this article, i’ll be sharing details about how I developed the CLI and ATSAMD based tester for the nRF9160 Feather. By the end this should give you confidence that you can also develop your own firmware and software 100% in Rust!
Let’s get started.
Sprinkle some Rust in your firmware
As mentioned earlier, I designed my tester hardware (pictured above) to use an ATSAMD microcontroller. I had a few reasons why I chose the ATSAMD21:
- It has a ton of pins. (ATSAMD21J18A-MFT has 52 to be exact!) This was going to be important in order to connect to and control everything about the DUT (Device under test) in the tester.
- SAMD21 series is also ubiquitous and plentiful elsewhere. It also happens to
have great support in the form of the
atsamd-hal
crate. (Link)
While there is some test firmware on the DUT itself, there’s a bunch of exciting stuff happening on the tester side. But before we do, let’s chat about the bootloader.
First thing you should do
After lots of tinkering I realized that one of the first things you should do on any SAMD based project is get the UF2 bootloader loaded. Since loading it onto my test board I haven’t used my debug probe. (Rust makes this extremely easy since it eliminates 80% of the stupid mistakes i’d otherwise make in C)
Here’s a quick primer on how I got my board working:
-
I cloned the UF2 repo:
git clone https://github.com/microsoft/uf2-samdx1
-
Created a folder called
circuitdojo_feather_tester
within theboards
directory.cd uf2-samdx1 mkdir boards/circuitdojo_feather_tester
-
I created
board.mk
andboard_config.h
cd boards/circuitdojo_feather_tester touch board.mk touch board_config.h
-
Using the already existing boards in the
boards
folder, updated the contents ofhttp://board.mk
andboard_config.h
. Firstboard.mk
to define what chip I was targeting:CHIP_FAMILY = samd21 CHIP_VARIANT = SAMD21J18A
Then
board_config.h
for all the configuration bits:#ifndef BOARD_CONFIG_H #define BOARD_CONFIG_H #define VENDOR_NAME "Circuit Dojo" #define PRODUCT_NAME "Feather Tester" #define VOLUME_LABEL "BOOT" #define INDEX_URL "https://www.jaredwolff.com/" #define BOARD_ID "SAMD21G18A-Feather-v0" #define USB_VID 0x16c0 #define USB_PID 0x27dd #define LED_PIN PIN_PA22 #endi
-
Then using the instructions in the Readme, build the code:
make BOARD=circuitdojo_feather_tester
I used the toolchain that comes with NCS v1.4.1. I did have to make a change to the Makefile for everything to compile without borking. Turns out my toolchain was newer than expected. Fortunately adding
Wno-deprecated
inside the Makefile to theWFLAGS
variable fixes this problem. -
Once complete it will dump your binary/hex files to
build/<your board name>
-
Then I flashed the bootloader using
pyocd
like so:pyocd flash -t ATSAMD21J18A -f 4000000 bootloader-circuitdojo_feather_tester-v3.4.0-65-gdf89a1f-dirty.elf --format elf
pyocd
is just one of many ways to load firmware. Theatsamd-rs
repo has a ton more options.
Once programmed, hit the reset button twice in quick succession to enable bootloader mode. (I believe this is consistent across all other boards using the UF2 bootloader. Correct me if i’m wrong!) This will cause the bootloader to remain active so you can transfer new firmware.
On a scale from easy to painful, this was defintiely on the easy side of the spectrum. The folks at Microsoft and contributors like Adafruit made this process really simple.
Hey, my name is HAL
As of this writing, every different hardware platform has some type of independently created HAL (hardware abstraction layer). Atmel’s SAMD differs slightly from nRF and that differs from the STM32s of the world. The nice thing is as long as they conform to the higher level APIs, you can use something like the USB crate which supports ATSAMD and STM32.
The fastest way to get started with your own ATSAMD based board to create your
own board definition. You can find a ton of example in the boards
directory. Many off the shelf boards are already supported which makes for one
less thing you need to do!
If you do find yourself with a custom board, you can copy one of the already
existing boards that is closest to yours. For instance I used feather_m0
as
the base for my tester board. I tweaked memory.x
and src/lib.rs
to my
liking.
There’s even a cool way to define pins so you can easily access them later. For
for instance if you have a pin named FLASH_EN
and it’s mapped to pin port B,
pin 8 you can simply reference it later on using the Pins
struct like
pins.flash_en
. (More below..)
The ATSAMD repo is going through some slow and steady improvements. There are even some nice additions to the board support area that i’m excited about and testing. While it’s mostly useable, it is rough in some areas (especially related to documentation).
Big shoutout to Bradley who has been spearheading these improvements. If I were to do any of this, i’d be surprised it would be working by then of it. 😅
Don’t be unsafe
When I first started developing the test firmware, I notice that I was using
unsafe
a lot. While in hardwareland using unsafe
is not uncommon, from a
readability and risk for increased errors perspective, it can get hairy.
There is a cool solution around this and that’s where rtic
enters the picture.
rtic
is a new spin on how to write firmware in Rust. Instead of having the
familiar main
function, it works differently. Here’s the features from
the Github page:
- Tasks as the unit of concurrency [^1]. Tasks can be event triggered (fired in response to asynchronous stimuli) or spawned by the application on demand.
- Message passing between tasks. Specifically, messages can be passed to software tasks at spawn time.
- A timer queue[^2]. Software tasks can be scheduled to run at some time in the future. This feature can be used to implement periodic tasks.
- Support for prioritization of tasks and, thus, preemptive multitasking.
- Efficient and data race free memory sharing through fine grained priority based critical sections [^1].
- Deadlock free execution guaranteed at compile time. This is an stronger
guarantee than what’s provided
by the standard
Mutex
abstraction. - Minimal scheduling overhead. The task scheduler has minimal software footprint; the hardware does the bulk of the scheduling.
- Highly efficient memory usage: All the tasks share a single call stack and there’s no hard dependency on a dynamic memory allocator.
- All Cortex-M devices are fully supported.
- This task model is amenable to known WCET (Worst Case Execution Time) analysis and scheduling analysis techniques. (Though we haven’t yet developed Rust friendly tooling for that.)
While all these features and capabilities seem great, so what gives?
Well, for starters there’s no main
function. 🙀
Here’s what a basic blinky app looks like using some of the new BSP (Board support packages) I mentioned earlier:
#![deny(unsafe_code)]
#![no_main]
#![no_std]
extern crate circuitdojo_tester as hal;
use panic_halt as _;
use hal::clock::GenericClockController;
use hal::delay::Delay;
use hal::prelude::*;
use hal::Pins;
#[rtic::app(device = hal::pac, peripherals = true)]
const APP: () = {
struct Resources {
led_pass: hal::LedPass,
delay: Delay,
}
#[init()]
fn init(cx: init::Context) -> init::LateResources {
let mut peripherals = cx.device;
let mut clocks = GenericClockController::with_external_32kosc(
peripherals.GCLK,
&mut peripherals.PM,
&mut peripherals.SYSCTRL,
&mut peripherals.NVMCTRL,
);
let pins = Pins::new(peripherals.PORT);
let led_pass = pins.led_pass.into_push_pull_output();
let delay = Delay::new(cx.core.SYST, &mut clocks);
init::LateResources { led_pass, delay }
}
#[idle(resources=[led_pass, delay])]
fn idle(cx: idle::Context) -> ! {
loop {
let _ = cx.resources.led_pass.toggle();
cx.resources.delay.delay_ms(500u32);
}
}
};
The init
function is where you would normally put anything you’d normally put
in an Arduino setup
function. We’re setting up pins and peripherals. If you
want to use them later on though, you’ll need to create an entry in Resources
.
This is also where you store any type of static mutable data structures that you
need elsewhere in your firmware.
The idle
function is similar to the loop
function in Arduino. It’s important
though that if you want a loop, you have to implement it. Here’s the warning in
the
rtic
documentation:
Unlike init, idle will run with interrupts enabled and it’s not allowed to return so it must run forever.
You don’t need to use an idle
function though. If you don’t, your
microcontroller will go to sleep. This is ideal for battery powered applications
that need to sleep as much as possible.
In rtic
every work function gets a Context
variable. It allow you to access
resources that are pertinent to that function’s purpose. Access is only granted
though when you add a resource like below:
#[idle(resources=[led_pass, delay])]
If resources
was not set like above, I would not be able to use led_pass
or
delay
within the function!
While this is a simple example, when you start using static resources like fixed
size vectors you’ll be happy you chose to use rtic
. The
heapless
crate has been extremely
useful for setting size contrained elements that you’d normally be able to use
with Rust’s std
lib.
While heapless
implements a few very handy types, the Vec
and spsc
imlementation have been extremely useful. If you’re looking for std
conventions for embedded, no need to look further. Get started with heapless
with their great documentation
here.
The confusion ensues
One thing that may be confusing at first is the deluge of useful but seemly
disparate crates that work along side everything. Here’s the Cargo.toml
for
the example above (which also includes USB support for another example)
[dependencies]
cortex-m = "0.6.4"
embedded-hal = "0.2.4"
[dependencies.cortex-m-rt]
optional = true
version = "0.6.12"
[dependencies.atsamd-hal]
default-features = false
version = "0.11"
path = "../atsamd/hal"
[dependencies.panic-abort]
version = "0.3"
optional = true
[dependencies.panic-halt]
version = "0.2"
optional = true
[dependencies.panic-semihosting]
version = "0.5"
optional = true
[dependencies.panic_rtt]
version = "0.1"
optional = true
[dependencies.usb-device]
version = "0.2"
optional = true
[dependencies.usbd-serial]
version = "0.1"
optional = true
[dev-dependencies]
cortex-m-semihosting = "0.3"
At first glance, that’s a lot of stuff! But then you look deeper and see that
most of them are optional which can be used depending on what features are being
used at the time. I only use panic-halt
,usb-device
and usb-serial
for my
test firmware.
Handy hardware tools
One thing that i’ve held onto since delving into ARM development is my trusty J-Link Lite. While not available for sale on their own anymore, they’re sometimes included in development kits. Nowadays if they are, they’re usually limited to the device it’s paired with in the development kit.
For example, Nordic sells their development kits combined with a J-Link chips on
the same PCB. While you can flash external chips, you can’t use their J-Link to
program, for example, an Atmel chip. If you do jlinkexe
will barf and that
will be the end of that endeavor.
There are other tools out there like the J-Link Edu Mini (limited to non-commercial), Black Magic Probe and more but this was the easiest for me to get started with.
These days I also usually design boards with a Tag Connect. My tester board is no exception. While not necessary, it does make the board slightly cheaper. The main drawback is they only last as long as you treat them well. My last cable purchased in 2016 started having issues not too long ago. So $50 later I have a new one and all is well with the world!
Now for the CLI encore.
One of the cool things I haven’t mentioned yet is the fact that my test firmware and CLI firmware live in the same place. Actually, to be honest, so does the remainder of the project. Yup, same repo. That’s the beauty of Cargo and Rust.
Sharing is caring
Since everything lives together, it’s very easy to share the tester commands between my CLI and firmware. Imagine trying to do this in Python. You’d have to define your commands two places and keep them in sync during your development process. Nightmare. 😵
Here’s a snippit of what the shared porition of the project looks like:
use num_enum::FromPrimitive;
#[derive(Debug, Eq, Copy, Clone, PartialEq, FromPrimitive)]
#[repr(u8)]
pub enum TesterCommand {
ResetTester = 0,
ResetDUT = 1,
PowerVbatOn = 2,
PowerVbatOff = 3,
PowerUsbOn = 4,
PowerUsbOff = 5,
PsEnOn = 6,
PsEnOff = 7,
GpioWalk = 8,
DutEnTest = 9,
DutLatchTest = 10,
#[num_enum(default)]
Unknown,
}
One thing to know about Rust is that you have to explicitly map enums to u8
if
you want to pass them as raw data. The num_enum
crate makes that much easier
since it’s no_std
capable.
Since there are 256 combinations if you’re using u8
, num_enum
allows you to
define a default
value. If you ever get a value that is not expected, it will
be set to the default value. In my case the default value is Unknown
. Here’s
an example of how commands are processed on the tester itself:
if let Some(v) = cx.resources.c.dequeue() {
match v {
TesterCommand::ResetTester => {
let _ = cx.resources.vbat_en.set_low();
let _ = cx.resources.ps_en.set_low();
}
TesterCommand::PowerVbatOn => {
let _ = cx.resources.vbat_en.set_high();
}
TesterCommand::PowerVbatOff => {
let _ = cx.resources.vbat_en.set_low();
}
TesterCommand::PsEnOn => {
let _ = cx.resources.ps_en.set_high();
}
The above is using the heapless::spsc::Consumer
for the raw serial data being
produced int the USB Serial event handler. By the way, this is how I declared it
in the rtic
init
function:
static mut Q: Queue<TesterCommand, U8> = Queue(i::Queue::new());
let (p, c) = Q.split();
Then within the Resources
struct it’s declared like:
p: Producer<'static, TesterCommand, U8>,
c: Consumer<'static, TesterCommand, U8>,
On the CLI side, commands are sent like so:
//Write commands
let c = match writer.write(&[TesterCommand::DutEnTest as u8]) {
Ok(_) => true,
Err(_) => false,
};
You can see that the enum value is easily casted to an u8
. Without num_enum
you’d get a compilation error since there is no default way to convert an enum
to other types.
Listening to sweet DUT music
For connecting to the tester or DUT you’ll have to establish a connection using
the serialport
crate. Since i’m using
this code in several places I ended up creating a get_port
function which
connects and returns a serial port.
pub fn get_port(
port_name: &String,
cursor_line_position: u16,
) -> Option<Box<dyn serialport::SerialPort>> {
if let Ok(ports) = serialport::available_ports() {
// See if it's there
let port_info = ports.into_iter().find(|e| e.port_name.contains(port_name));
// Continue if none
if port_info.is_none() {
print!(
"{}{}Unable to connect to {}. Remove and re-connect!{}",
color::Fg(color::Red),
cursor::Goto(1, cursor_line_position),
port_name,
color::Fg(color::Reset)
);
return None;
}
// Get the acutal object
let port_info = port_info.unwrap();
// Prints out information about the port.
debug!("Port: {:?}", port_info);
// Open with settings
let port = serialport::new(&port_info.port_name, 115_200)
.timeout(time::Duration::from_millis(10))
.open();
// Continue processing if not err
match port {
Ok(p) => Some(p),
Err(_) => {
print!(
"{}{}Unable to connect to {}. Remove and re-connect!{}",
color::Fg(color::Red),
cursor::Goto(1, cursor_line_position),
port_name,
color::Fg(color::Reset)
);
None
}
}
} else {
None
}
}
This function covers the error case that your device is not plugged in and also
if it’s already connected to by another process on your system. Once you have a
SerialPort
created and connected, you can use it with a BufReader
to parse
lines sent by the connected device. I use it to check for reponses to the
nRF9160 AT commands. Here’s how you can set up a BufReader with SerialPort
:
// Create reader from it so I can read each line.
let reader = Rc::new(RefCell::new(BufReader::new(
dut.try_clone().expect("Unable to clone to BufReader!"),
)));
Notice how I wrap it in an Rc
and RefCell
? That way I can use it as a
resource across the test infrastructure without having issues with borrowing.
Once going out of scope the data within the Rc
is released and can be mutated
again (i.e. the serial port can be written to) if need be.
Here’s what it looks like using BufReader
:
// Borrow only once
let mut reader = reader.borrow_mut();
// Loop until we've got the goods
loop {
let mut buf = [0u8; 1];
if let Ok(sz) = reader.read(&mut buf) {
if sz == 0 {
continue;
}
// Check for p or f
match buf[0] as char {
'p' => {
self.passed = Some(true);
break;
}
'f' => {
self.passed = Some(false);
// Read bytes after 'f'
let mut err_buf = [0u8; 3];
if let Ok(_) = reader.read(&mut err_buf) {
self.error = Some(format!("{:?}", err_buf));
}
break;
}
_ => {}
};
}
}
In the example above, the reader
is the one attached to the tester USB Serial
port. You can see i’m sending back a simple ‘p’ or ‘f’ depending on the
outcome of a test. A similar idea can be applied to reading the output of AT
commands:
// Get the current timestamp
let now = time::Instant::now();
// Only once
let mut reader = reader.borrow_mut();
loop {
if now.elapsed().as_secs() > 10 {
self.passed = Some(false);
self.timestamp = Utc::now().to_string();
self.error = Some("Timeout".to_string());
break;
}
if let Ok(line) = reader.read_line() {
// Unrwap
let line = match line {
Some(l) => l,
None => continue,
};
// See if the line contains the start dialog
if line.contains("+CGSN: ") {
self.imei = Some(
line
.strip_prefix("+CGSN: ")
.expect("Should have had a value!")
.trim_matches('\"')
.to_string(),
);
You can see that i’m using the read_line
method on the reader. This parses
text and returns it once it sees a ‘\n’ character. Then using the String
.contains
method you can parse or check if the line contains “+CGSN: " .
In my case i’m using this to detect a pass and fail condition. Shnifty
right?
Speaking of music.. (bonus!)
The rodio
crate is particularly useful
for playback of audio. I added a ding! to let the fixture operator (yea, me
for when I get distracted 😅) that a test has completed. The coolest part is
that i’m importing the .mp3 file as raw data using the import_bytes
macro:
// Static constant ding data
pub const DING: &'static [u8] = include_bytes!("ding.mp3");
This way it gets ‘packaged’ within the application. Who doesn’t love a distributable single binary?
I then set up the rodio
stream once at the beginning of the application. Doing
it within main
appears to be important:
// Stream handle
let (_stream, stream_handle) = rodio::OutputStream::try_default().unwrap();
Then using a shared function play the audio when the need arises:
pub fn alert_play(stream_handle: &rodio::OutputStreamHandle) {
// Sound playback
let source = rodio::Decoder::new(Cursor::new(DING)).unwrap();
let _ = stream_handle.play_raw(source.convert_samples());
}
rodio
can decode several file types without declaring what they are. During
playback though, the ‘source’ needs a BufReader
(or similar) to parse the
file. In this case though there’s no file. So what to do?
If you looked closely above you already know the answer: I used a Cursor
to
wrap the raw data. While it took a while to figure out (I seemed to have a hard
time writing the correct order of words into the search box to accurately
describe what I was trying to do), it was great to finally find it! This allows
access the static const data I created earlier using the include_bytes
macro.
The show must go on
Developing my test infrastructure in Rust has been an awesome challenge.
Compared to the Python based testing infrastructure of the past, i’ve wrangled
this setup in much less time (minus learning the ins-and-outs of embedded Rust
and rtic
)
I’m excited to announce i’ve been using all of this to test version 2 of the nRF9160 Feather. It now has an accelerometer to the back side which helps with asset tracker applications. There’s also some new cuttable traces/jumpers for applications that require battery power or need to be on at all times. This gives you a bit more flexibility when it comes to powering your projects!
Interested in grabbing one? They’re available directly from my store with free shipping in the USA. Want to dive in more? You can find the documentation here.
Thanks for reading along. Have any additional thoughts? Saw something that I could have improved on? Just want to say hi? Leave a comment below.
Last Modified: 2021.12.8