Testing Hardware Using Rust Firmware and Rust Based CLI

Jared Wolff · 2021.1.10 · 16 Minute Read · rust · nrf9160 feather · embedded

nRF9160 Feather Test CLI

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

nRF9160 Feather tester hardware render

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:

  1. 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.
  2. 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:

  1. I cloned the UF2 repo:

    git clone https://github.com/microsoft/uf2-samdx1
    
  2. Created a folder called circuitdojo_feather_tester within the boards directory.

    cd uf2-samdx1
    mkdir boards/circuitdojo_feather_tester
    
  3. I created board.mk and board_config.h

    cd boards/circuitdojo_feather_tester
    touch board.mk
    touch board_config.h
    
  4. Using the already existing boards in the boards folder, updated the contents of http://board.mk and board_config.h. First board.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
    
  5. 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 the WFLAGS variable fixes this problem.

  6. Once complete it will dump your binary/hex files to build/<your board name>

  7. 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. The atsamd-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

Compiling Rust firmware

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 Mutexabstraction.
  • 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

Holding J-Link LITE

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

nRF9160 Feather

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

Subscribe here!

You may also like

CBOR for Embedded C and Rust

When sending data theres a few ways you can go about it. In the embedded world, it’s not uncommon to serialize data so it can be efficently sent through the ether. On the other end…