Debugging Embedded Rust

Setting up a debug workflow from scratch

Mattia Fiumara
Better Programming

--

Photo by Christopher Gower on Unsplash

In my last article, I explored what it’s like to code a simple IoT application for the ESP32 series using rust bindings for the ESP-IDF. This time, I wanted to dive deeper into an aspect I did not discuss: debugging, specifically on Bare Metal.

Since it might have looked easy to code Rust on embedded targets with support for the standard library, I’d like to show what it’s like programming Rust on a target that does not support the standard library and how to set up such a project from scratch. I set the following goals:

  • Navigate the Rust bare metal ecosystem and determine what we need to set up a project from scratch when using no_std
  • Set up a logging system that can print logs over JTAG / SWD
  • Attach a debugger to the application and step through the code using VSCode

Hardware setup

I will use an nRF91 Thingy targeting the nrf52840 in combination with a Black Magic Probe to run and debug the code. Here’s what that looks like:

NRF91 thingy with Black Magic Probe
NRF91 Thingy with Black Magic Probe

If you want to follow along with the article, you can find the associated code here. Just make sure you have Rustup installed on your system, and you’re good to go.

Navigating Bare Metal

For a bit of context, let’s look at the definition of Bare Metal according to Wikipedia:

In computer science, bare machine (or bare metal) refers to a computer executing instructions directly on logic hardware without an intervening operating system.

Normally, when you write a program in Rust, the standard library will call upon the OS when, for instance, you try to read and write files, open network sockets, or perform other kinds of I/O (like logging to a console).

When programming for embedded devices with small memory specifications, code is typically run directly on the CPU. In these cases, Rust allows you to compile your application, excluding the standard library. This is called no_std and the implications for this are the following:

  • You need to bring your own runtime
  • You’ll have to use some kind of HAL (Hardware Abstraction Layer) or PAC (Peripheral Access Crate) to control the hardware
  • If you want to use dynamic memory, you need to bring your own allocator

Starting off

To start, let’s initialize a new project using cargo init, then use the following code listing as a starting point:

#![no_std]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

fn main() -> ! {
loop {}
}

We’re specifying at the top that we are running in a bare-metal environment without an OS using #![no_std]. This ensures we can compile our code for our target hardware, for which there is no standard library support (see Platform Support for a list of targets that do or do not have support for the Rust standard library).

When we use #[no_std], we also have to specify a panic handler ourselves using the attribute #![panic_handler]. This is required because the regular panic macro relies on some features of the standard library, which means our program won’t compile if we do not override this behavior.

Runtime

When compiling executable code, we need to tell our processor the entry point of our application and set up the CPU registers like the Program Counter and other registers. For this, we need to include a runtime. By far, the most popular choice for cortex-m targets is cortex-m-rt. Add the crate to your project using cargo add:

$ cargo add cortex-m-rt
Updating crates.io index
Adding cortex-m-rt v0.7.3 to dependencies.
Features:
- device
- set-sp
- set-vtor
Updating crates.io index

The only thing we need to change now is to annotate our main function using #[entry] to indicate this is the entry point of the application:

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
loop {}
}

HAL and PAC

To control the peripherals of our Thingy91 and link our binary, we’re using a high-level Hardware Abstraction Layer (HAL), but for some less well-supported targets, you’d have to resort to using a Peripheral Access Crate (PAC). Luckily, the Nordic series of microcontrollers are well supported by the embedded rust community (check the nrf-hal GitHub), and we do not have to bang on registers directly. For my specific chip, I will add the nrf52840-hal:

$ cargo add nrf52840-hal
Updating crates.io index
Adding nrf52840-hal v0.16.0 to dependencies.
Features:
+ rt
- doc
Updating crates.io index

To specify how to link everything to the target, we can copy the memory.x file from the HAL into the root of our project. This linker script tells the compiler where to store our code in the flash and where our statically defined variables get stored in RAM.

Adding logging

To get some logging over Real Time Transfer (RTT), I found two well-supported crates to choose from:

If you have a bigger project, I would highly recommend looking into it, as its logging capabilities really shine in larger projects with multiple modules. Since we only have a single source file and defmt needs some extra set-up in combination with a BMP, I will use rtt-target.

You get the drill by now: cargo add rtt-target to add the crate to your project. In addition, you have to provide rtt-target with a critical-section implementation. This is required to ensure different threads can access the same logging instance without the risk of memory corruption, even though we only have one thread to worry about. For the cortex-m CPU architecture, the easiest is to use the feature of the cortex-m crate. Manually add the crate to your Cargo.toml file to include the feature:

cortex-m = { version = "0.7.7", features = ["critical-section-single-core"] }

A simple program

Now that we have managed to navigate that, we can finally write some code! The code we’ll be looking at is a simple application that scans the I2C bus of the Thingy91 and returns a table of available devices on the bus (similar to i2cdetect on Linux). Here’s the new content of our main function:

#[entry]
fn main() -> ! {
rtt_init_print!();

// Acquire a reference to the GPIO
let p = pac::Peripherals::take().unwrap();
let port1 = hal::gpio::p1::Parts::new(p.P1);
// The I2C of the nrf52840 on the thingy91, replace if you're using different hardware
let sda = port1.p1_08.into_floating_input();
let scl = port1.p1_09.into_floating_input();
// Instantiate and enable the Two-Wire Interface Peripheral (I2C)
let mut twim = hal::Twim::new(
p.TWIM0,
hal::twim::Pins {
sda: sda.degrade(),
scl: scl.degrade(),
},
hal::twim::Frequency::K400,
);
twim.enable();

rprintln!("Scanning I2C bus...\r");
// Print I2C table the header
rprintln!(" 0 1 2 3 4 5 6 7 8 9 a b c d e f\r");
rprint!("00: ");
// Loop over all addresses on the I2C bus
for i in 1..0xFF {
if i % 0x10 == 0 {
rprint!("\r\n{:X}: ", i);
}
// We're issuing a simple scan to check if there's an ACK
// We do not care about the result in the buffer but we need to
// provide a non-empty one
let mut buffer: [u8; 1] = [0xFF];
match twim.read(i, &mut buffer) {
Ok(_) => {
rprint!("{:X} ", i);
}
Err(hal::twim::Error::AddressNack) => {
rprint!("-- ");
}
Err(err) => {
// Handle other error types if needed
rprintln!("Error reading from TWIM: {:?}\r", err);
break;
}
}
}
rprintln!("\r\nDone!\r");
loop {}
}

A short summary of what’s happening here:

  1. First, RTT logging is initialized using the rtt_init_print macro. This ensures we can print to our logging console (in our case, the serial device of the BMP)
  2. The TWIM Peripheral of the NRF52840 is initialized and enabled with the pins corresponding to the I2C bus on the Thingy91
  3. Next, we print some logs and a table of all the I2C addresses that are present on the bus

Compiling

To compile the app, we must specify the target we’re compiling for. The most convenient way is to create a .cargo/config file where we specify this; it saves us from specifying it each time as a command line parameter to cargo. Here’s the code:

[build]
target = "thumbv7em-none-eabihf"

Make sure you install the required toolchain using Rustup, after which you can compile your application and check the binary size to ensure everything gets linked correctly. It should look like this:

$ rustup target add thumbv7em-none-eabihf
info: downloading component ‘rust-std’ for ‘thumbv7em-none-eabihf’
info: installing component ‘rust-std’ for ‘thumbv7em-none-eabihf’
$ cargo build
Compiling rust-baremetal-debug v0.1.0 (/Users/mfiumara/repos/rust-debug-2023)
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
$ arm-none-eabi-size target/thumbv7em-none-eabihf/debug/rust-baremetal-debug
text data bss dec hex filename
20056 0 1092 21148 529c target/thumbv7em-none-eabihf/debug/rust-baremetal-debug

Note that this is an unoptimized build. When building with cargo build --release, the binary size is more than halved:

$ arm-none-eabi-size target/thumbv7em-none-eabihf/release/rust-baremetal-debug
text data bss dec hex filename
8092 0 1092 9184 23e0 target/thumbv7em-none-eabihf/release/rust-baremetal-debug

Flashing and debugging the program

Interestingly, Rust does not ship with a debugger. It relies on already existing debuggers out there, like GDB or LLDB. If you are using a development kit and a JLink probe, I would highly recommend taking a look at probe-rs. This is an amazing package that makes setting up the debugger and flashing process very easy with built-in commands like cargo flash and cargo embed to start debugging. It’s a great debug workflow.

Since I am using a Black Magic Probe, it’s slightly different as it hosts a full GDB server on the probe itself, which, unfortunately, is not compatible with probe-rs. Instead of probe-rs, we will be hooking up VS Code to GDB using the cortex-m debug extension, which does support the Black Magic Probe. The following .vscode/launch.json file launches the application code (note that we enable the RTT using mon rtt after attaching to GDB in the postLaunchCommands option):

{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Cortex Debug",
"cwd": "${workspaceFolder}",
"executable": "target/thumbv7em-none-eabihf/debug/rust-baremetal-debug",
"request": "launch",
"type": "cortex-debug",
"BMPGDBSerialPort": "/dev/cu.usbmodem98B724951",
"servertype": "bmp",
"interface": "swd",
"runToEntryPoint": "main",
"postLaunchCommands": ["mon rtt"]
}
]
}

Now, it’s finally time to press the magic ‘debug’ button in VS Code and see if we set everything up correctly. Just make sure you have a window open to monitor the RTT output. In my case, screen /dev/tty.usbmodem98B724953:

The final debuggin set-up
The final debugging setup in VS Code

Looks like we finally made it through! Our app launches and breaks at the entry point we defined using #[entry]. We can step through the code and even step into library code to explore how the HAL is setting up the registers for the TWIM Peripheral or how cortex-m-rt is handling the initialization of the CPU.

Once the program finishes, we can see the RTT gets flushed to our serial device, showing the output below in the terminal window. Here’s what that looks like:

Scanning I2C bus...
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- 46 -- -- -- -- -- -- -- -- --
50: 50 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- 76 -- -- -- -- -- -- -- -- --
80: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
90: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
A0: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
B0: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
C0: -- -- -- -- -- -- C6 -- -- -- -- -- -- -- -- --
D0: D0 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
E0: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
F0: -- -- -- -- -- -- F6 -- -- -- -- -- -- -- --
Done!

Our program successfully scans six devices on the I2C bus and then enters the infinite loop at the end of our program.

Concluding Remarks

This illustrates everything that is required to set up a no_std project in Rust from scratch with some basic debugging capabilities. It’s always a bit painful to set things up in any embedded project, so it’s good to walk through one of these project setups in Rust once to really understand how everything works together and what is necessary or not.

In case you want to set up your future project, I would recommend looking at some project templates to use instead of going through the whole exercise of setting everything up from scratch again. A great example for cortex-m targets is the cortex-m-quickstart project.

This sets up the basic runtime, although you still need to set up a logging system yourself and add a HAL. Also, check out if probe-rs is an option for you, especially if you are going to pair it with defmt.

Thanks for reading!

Resources

--

--

I’m Tech Lead at Agurotech, where we create innovative solutions for the agriculture industry. I’m interested in embedded systems, IoT and Rust.