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:
-
PAC (peripheral access crate)
- low-level register access
-
HAL (hardware abstraction layer)
- common traits with functions useful to driver implementations
-
BSP (board support package)
- drivers and high-level functions for interacting with specific hardware
- may or may not target embedded-hal traits
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.