Rust has become increasingly popular for embedded systems development due to its focus on safety, performance, and low-level control. As an embedded systems engineer, I’ve found that Rust offers a compelling set of features that make it well-suited for creating reliable and efficient firmware. In this article, I’ll share six key techniques that I’ve found invaluable when developing embedded systems with Rust.
No-std Crates
One of the first challenges in embedded Rust development is working without the standard library. Many embedded systems lack an operating system or have limited resources, making the full std library unsuitable. Fortunately, Rust provides the core library and a rich ecosystem of no-std crates that enable development without std.
The core library offers essential language features and types, while no-std crates provide additional functionality tailored for embedded environments. To create a no-std project, we start by specifying the appropriate target and disabling the standard library in our Cargo.toml:
[package]
name = "my_embedded_project"
version = "0.1.0"
edition = "2021"
[dependencies]
cortex-m = "0.7.6"
cortex-m-rt = "0.7.2"
[profile.release]
opt-level = "s"
lto = true
[[bin]]
name = "my_embedded_project"
test = false
bench = false
In our main.rs file, we declare that we’re not using the standard library:
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[cortex_m_rt::entry]
fn main() -> ! {
loop {}
}
This setup provides a foundation for our embedded Rust project, allowing us to work within the constraints of our target hardware.
Embedded HALs
Hardware Abstraction Layers (HALs) are crucial for writing portable embedded code. Rust’s embedded-hal crate defines a set of traits that abstract common peripherals and interfaces. By coding against these traits, we can write device-agnostic code that works across different microcontrollers.
Let’s look at an example of using an LED abstraction:
use embedded_hal::digital::v2::OutputPin;
struct Led<T: OutputPin> {
pin: T,
}
impl<T: OutputPin> Led<T> {
fn new(pin: T) -> Self {
Led { pin }
}
fn on(&mut self) -> Result<(), T::Error> {
self.pin.set_high()
}
fn off(&mut self) -> Result<(), T::Error> {
self.pin.set_low()
}
}
fn main() -> ! {
let mut led = Led::new(board::USER_LED.into_push_pull_output());
loop {
led.on().unwrap();
// Wait for some time
led.off().unwrap();
// Wait for some time
}
}
This code defines an LED abstraction that works with any pin implementing the OutputPin trait. We can easily port this code to different boards by changing the specific pin used.
Interrupt Handling
Safe interrupt handling is critical in embedded systems. Rust’s type system and ownership model help prevent common pitfalls associated with interrupts, such as data races and deadlocks. The cortex-m-rt crate provides macros for defining interrupt handlers safely.
Here’s an example of setting up a timer interrupt:
use cortex_m::peripheral::NVIC;
use stm32f4xx_hal::{pac, prelude::*};
static mut COUNTER: u32 = 0;
#[cortex_m_rt::entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let mut syscfg = dp.SYSCFG.constrain();
let mut nvic = dp.NVIC;
// Configure timer
let mut timer = dp.TIM2.counter_ms(&mut syscfg);
timer.start(1.seconds()).unwrap();
timer.listen(Event::Update);
// Enable TIM2 interrupt
unsafe {
NVIC::unmask(pac::Interrupt::TIM2);
}
loop {
cortex_m::asm::wfi();
}
}
#[cortex_m_rt::interrupt]
fn TIM2() {
unsafe {
COUNTER += 1;
if COUNTER % 1000 == 0 {
// Do something every 1000 interrupts
}
}
}
This code sets up a timer interrupt that increments a counter every millisecond. The #[cortex_m_rt::interrupt] attribute ensures that the interrupt handler is properly registered and called when the TIM2 interrupt occurs.
Memory-mapped I/O
Embedded systems often interact with hardware through memory-mapped registers. Rust provides the volatile_register crate for safe and efficient access to these registers. Using volatile reads and writes ensures that the compiler doesn’t optimize away seemingly redundant accesses to hardware registers.
Here’s an example of using memory-mapped I/O to control an LED:
use volatile_register::{RW, RO};
#[repr(C)]
struct GpioRegisters {
moder: RW<u32>, // Mode register
otyper: RW<u32>, // Output type register
ospeedr: RW<u32>, // Output speed register
pupdr: RW<u32>, // Pull-up/pull-down register
idr: RO<u32>, // Input data register
odr: RW<u32>, // Output data register
}
const GPIO_BASE: usize = 0x40020000;
fn main() -> ! {
let gpio = unsafe { &mut *(GPIO_BASE as *mut GpioRegisters) };
// Configure pin as output
gpio.moder.modify(|r| r & !(0b11 << 10) | (0b01 << 10));
loop {
// Toggle LED
gpio.odr.modify(|r| r ^ (1 << 5));
// Wait for some time
}
}
This code directly manipulates GPIO registers to control an LED. The volatile_register crate ensures that these operations are performed correctly, even in the presence of compiler optimizations.
Real-time Constraints
Many embedded systems have real-time requirements, necessitating predictable timing and low-latency responses. Rust’s zero-cost abstractions and fine-grained control over memory layout help in meeting these constraints. Additionally, the core::sync::atomic module provides atomic operations that are crucial for lock-free programming in real-time systems.
Here’s an example of using atomic operations for a real-time task scheduler:
use core::sync::atomic::{AtomicU32, Ordering};
static TASK_COUNTER: AtomicU32 = AtomicU32::new(0);
struct Task {
id: u32,
priority: u8,
execute: fn(),
}
impl Task {
fn new(priority: u8, execute: fn()) -> Self {
let id = TASK_COUNTER.fetch_add(1, Ordering::Relaxed);
Task { id, priority, execute }
}
}
struct Scheduler {
tasks: [Option<Task>; 16],
}
impl Scheduler {
fn new() -> Self {
Scheduler { tasks: [None; 16] }
}
fn add_task(&mut self, task: Task) {
if let Some(slot) = self.tasks.iter_mut().find(|slot| slot.is_none()) {
*slot = Some(task);
}
}
fn run(&self) {
loop {
let highest_priority_task = self.tasks.iter()
.filter_map(|task| task.as_ref())
.max_by_key(|task| task.priority);
if let Some(task) = highest_priority_task {
(task.execute)();
}
}
}
}
fn main() -> ! {
let mut scheduler = Scheduler::new();
scheduler.add_task(Task::new(1, || {
// Low priority task
}));
scheduler.add_task(Task::new(10, || {
// High priority task
}));
scheduler.run();
}
This simple scheduler uses atomic operations to generate unique task IDs and manages task priorities to ensure that high-priority tasks are executed promptly.
Firmware Updates
Implementing reliable over-the-air (OTA) updates is crucial for maintaining and improving embedded systems in the field. Rust’s strong type system and memory safety guarantees help in creating robust update mechanisms.
Here’s a basic example of an OTA update process:
use core::slice;
use cortex_m::asm;
use flash::{FlashWriter, Error as FlashError};
const UPDATE_BUFFER_SIZE: usize = 1024;
const UPDATE_START_ADDRESS: u32 = 0x08040000;
struct OtaUpdater {
buffer: [u8; UPDATE_BUFFER_SIZE],
writer: FlashWriter,
current_address: u32,
}
impl OtaUpdater {
fn new(writer: FlashWriter) -> Self {
OtaUpdater {
buffer: [0; UPDATE_BUFFER_SIZE],
writer,
current_address: UPDATE_START_ADDRESS,
}
}
fn write_chunk(&mut self, data: &[u8]) -> Result<(), FlashError> {
let mut offset = 0;
while offset < data.len() {
let chunk_size = core::cmp::min(UPDATE_BUFFER_SIZE, data.len() - offset);
self.buffer[..chunk_size].copy_from_slice(&data[offset..offset + chunk_size]);
self.writer.write(self.current_address, &self.buffer[..chunk_size])?;
self.current_address += chunk_size as u32;
offset += chunk_size;
}
Ok(())
}
fn finalize(&mut self) -> Result<(), FlashError> {
self.writer.flush()?;
Ok(())
}
}
fn perform_update(updater: &mut OtaUpdater, update_data: &[u8]) -> Result<(), FlashError> {
updater.write_chunk(update_data)?;
updater.finalize()?;
Ok(())
}
fn main() -> ! {
let flash_writer = FlashWriter::new();
let mut updater = OtaUpdater::new(flash_writer);
let update_data = [/* ... update payload ... */];
match perform_update(&mut updater, &update_data) {
Ok(_) => {
// Update successful, reboot the device
asm::bootload(UPDATE_START_ADDRESS as *const u32);
}
Err(_) => {
// Handle update failure
}
}
loop {}
}
This example demonstrates a basic OTA update process, including writing update chunks to flash memory and finalizing the update. In a real-world scenario, you’d need to add error handling, verification of the update payload, and a robust bootloader to manage the update process.
These six techniques form a solid foundation for embedded systems development with Rust. By leveraging Rust’s safety features, performance optimizations, and ecosystem of embedded-focused crates, we can create reliable and efficient firmware for a wide range of devices.
As I’ve worked on various embedded projects, I’ve found that Rust’s strong type system and ownership model catch many potential bugs at compile-time, significantly reducing the time spent debugging in the field. The ability to write high-level abstractions without sacrificing performance has also been a game-changer, allowing for more maintainable and reusable code across different hardware platforms.
Moreover, the growing ecosystem of Rust tools and crates for embedded development has made it easier to tackle common challenges in firmware development. From hardware abstraction layers to real-time operating systems, the Rust community has been actively creating and improving resources for embedded developers.
As embedded systems become increasingly complex and interconnected, the importance of writing secure and reliable firmware cannot be overstated. Rust’s focus on safety and performance makes it an excellent choice for modern embedded systems development, and I’m excited to see how the language and its ecosystem continue to evolve in this space.