Skilling Up with Embedded Rust: 2024-07-25


Over the last few years, I have been focused on embedded software development using Rust.

Getting a handle on how the Rust ecosystem approaches embedded development has been an interesting challenge.

I’ve been programming with Rust since 2016, in varying levels of professional use, but am much newer to embedded development.

Mapping the programming style present in most of the C embedded code I’ve read (Linux / U-Boot / Coreboot) onto the approach used in embedded-hal was somewhat mind-bending.

Becoming familiar with the ecosystem

Most of the C code directly reads/writes to memory-mapped registers, using bit-shifts and -masks for field access.

For example, a UART peripheral may have an ien register for enabling interrupts, with an rx field for receiver interrupts.

The C code may look something like:

uint32_t ien = readl(UART_REG, IEN_RX);;
if ((ien & (1 << IEN_RX)) == 0) {
    writel(UART_REG, (1 << IEN_RX) | ien);
}

The above first reads the current register value, checks the rx field, and conditionally enables the rx interrupt with all other fields unmodified.

Rust code from a PAC generated with svd2rust looks more like:

let periph = pac::Peripherals::take().unwrap();
periph.uart().ien().modify(|r, w| if r.rx().bit_is_clear() {
    w.rx().set_bit();
});

The modify closure is saying: "first read the values currently in the register, only modify fields written by the closure".

Essentially, the C and Rust code achieve the same goal: read the current register value, conditionally modify it.

However, the Rust code is able to achieve that goal with a minimal amount of internal unsafe code (mostly for converting pointers to Rust types).

In the C code, those readl and writel calls can be made to almost any memory address, so each invocation needs to be inspected to make sure the proper arguments are supplied.

As long as the input to svd2rust is correct, the subsequent calls to access functions can be made safely. Meaning the verification task is restricted to a much smaller set of information.

Levels of abstraction

Embedded Rust project architecture is split up into roughly the following:

The PAC is the lowest level of abstraction in the ecosystem, and is usually generated with a tool like svd2rust.

The quality of the input SVD file largely determines the quality of the resulting PAC, and a number of tools exist for modifying vendor SVDs to improve results.

HALs use PACs to provide higher-level functionality, like proper device initialization, configuration, and common operations.

BSPs are the highest abstraction level, and typically use HALs and/or PACs to provide an OS-like experience. They often stop short of being a full-blown OS/RTOS, as they focus on a single piece of hardware.

Continued learning

Getting the above mental model of how the pieces fit together didn’t really click until implementing a HAL myself.

I’m sure there are pieces that are still missing, any mistakes are my own.

However, implementing a HAL for a board you want to support is an excellent way to become familiar with the tools Rust has to offer.

The rust-embedded community has been very welcoming and helpful, and provide great resources like the discovery book for a more guided tour through the Rust embedded environment.