Embedded & Real-Time Patterns
Rust on microcontrollers, bare metal SoCs, and hard real-time workloads demands strict control over memory, timing, and side effects. Without an OS, you must replace the standard library with #![no_std], carefully manage interrupts, and keep predictable execution. This chapter assembles patterns that combine HAL abstractions, interrupt coordination, and deterministic scheduling so experienced Rustaceans can bring the language’s safety guarantees to embedded constraints.
Modern embedded stacks typically follow a split architecture:
- Board Support Package (BSP) initializes clocks, peripherals, and pin mappings.
- Hardware Abstraction Layer (HAL) provides portable traits (
embedded-hal,embedded-io). - Application logic plugs drivers together using zero-allocation data structures, lightweight schedulers, and strict error handling.
Development Setups: Raspberry Pi vs. STM32
Working Directly on a Raspberry Pi
Linux-based SBCs like Raspberry Pi let you run cargo natively, which is great for rapid iteration before jumping to bare metal.
- Install Rust with
rustupon the Pi (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh), then add needed targets such asarmv7-unknown-linux-gnueabihf. - Use crates like
rppalorlinux-embedded-halto access GPIO, SPI, and I2C without needingno_std. - For deterministic services, pin tasks to cores with
tasksetand usesystemdunits to manage startup; for realtime kernels enablePREEMPT_RT. - Deploy by copying binaries or using
cargo run --release --target armv7-unknown-linux-gnueabihf, then supervise withsystemd,tmux, or container runtimes. - You can mock out HAL traits on the Pi while your final firmware targets a microcontroller—this chapter’s HAL patterns show how to keep code portable.
Cross-Compiling for STM32 Boards
Bare-metal STM32 development needs a no_std build, cross toolchain, and a flashing/debug story.
- Install the
thumbv7em-none-eabihf(or appropriate) target withrustup target add thumbv7em-none-eabihf. - Use
probe-rs(cargo install probe-run) oropenocd+gdbfor flashing/debug;cargo embedautomates logging via RTT/defmt. - HAL/BSP crates (e.g.,
stm32f4xx-hal,stm32h7xx-hal) provide clock setup and driver scaffolding—mirror your board layout there. - Configure
.cargo/config.tomlwith runnerprobe-run --chip STM32F401RETxfor seamlesscargo run --release. - For CI, leverage
cargo xtaskscripts orjustrecipes to build both host-mock tests and firmware artifacts, ensuring determinism with--locked --target.
Pattern 1: Layered HAL Drivers
- Problem: Directly touching vendor registers makes code brittle and untestable. Porting across MCUs or even board revisions forces a rewrite.
- Solution: Build drivers against
embedded-hal-style traits and keep board-specific code isolated in a BSP. This separates volatile register fiddling from reusable business logic. - Why It Matters: HAL traits allow unit testing on the host, replaceable mocks, and reuse across Cortex-M, RISC-V, or even Linux-based targets.
- Use Cases: Sensor drivers, communication stacks, PWM motor control, portable display drivers.
Examples
Example: Board Support Layer
Board Support Package initializing hardware peripherals. The BSP owns vendor-specific code, configures clocks and GPIO, and exposes components through HAL traits. Application code receives ready-to-use peripherals without touching registers directly.
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
use stm32f4xx_hal::{pac, prelude::*, timer::CounterHz};
use embedded_hal::digital::v2::OutputPin;
pub struct Board {
pub led: impl OutputPin<Error = core::convert::Infallible>,
pub timer: CounterHz<'static, pac::TIM2>,
}
pub fn init() -> Board {
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(84.MHz()).freeze();
let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
led.set_low().ok();
let mut timer = dp.TIM2.counter_hz(&clocks);
timer.start(1.Hz()).unwrap();
Board { led, timer }
}
}
Example: Driver Consuming HAL Traits
A heartbeat driver using only embedded-hal traits. By depending on OutputPin and CountDown abstractions rather than concrete types, the same driver works on any platform. Replace with mocks for desktop unit testing.
#![allow(unused)]
fn main() {
use embedded_hal::digital::v2::OutputPin;
use embedded_hal::timer::CountDown;
use nb::block;
pub struct Heartbeat<P, T> {
led: P,
timer: T,
}
impl<P, T> Heartbeat<P, T>
where
P: OutputPin<Error = core::convert::Infallible>,
T: CountDown,
{
pub fn new(led: P, timer: T) -> Self {
Self { led, timer }
}
pub fn spin(mut self) -> ! {
loop {
self.led.set_high().ok();
block!(self.timer.wait()).ok();
self.led.set_low().ok();
block!(self.timer.wait()).ok();
}
}
}
}
Example: Sensor Driver Abstracted Over SPI + Delay
Portable IMU driver using SPI, GPIO, and delay traits. The driver manages chip select, transfers bytes, and handles timing delays. Identical code compiles for STM32, Nordic chips, or desktop mocks with fake SPI implementations.
#![allow(unused)]
fn main() {
use embedded_hal::digital::v2::OutputPin;
use embedded_hal::blocking::delay::DelayUs;
use embedded_hal::blocking::spi::Transfer;
pub struct ImuDriver<SPI, CS, DELAY> {
spi: SPI,
cs: CS,
delay: DELAY,
}
impl<SPI, CS, DELAY> ImuDriver<SPI, CS, DELAY>
where
SPI: Transfer<u8>,
CS: OutputPin<Error = core::convert::Infallible>,
DELAY: DelayUs<u16>,
{
pub fn new(spi: SPI, cs: CS, delay: DELAY) -> Self {
Self { spi, cs, delay }
}
pub fn read_whoami(&mut self) -> Result<u8, SPI::Error> {
let mut buf = [0x75, 0];
self.cs.set_low().ok();
self.spi.transfer(&mut buf)?;
self.cs.set_high().ok();
Ok(buf[1])
}
pub fn configure(&mut self) -> Result<(), SPI::Error> {
self.write_reg(0x6B, 0x00)?;
self.delay.delay_us(50);
self.write_reg(0x1C, 0x10)?;
Ok(())
}
fn write_reg(&mut self, reg: u8, value: u8) -> Result<(), SPI::Error> {
let mut buf = [reg & 0x7F, value];
self.cs.set_low().ok();
self.spi.transfer(&mut buf)?;
self.cs.set_high().ok();
Ok(())
}
}
}
Example: Raspberry Pi HAL Wrapper
rppal GPIO as an embedded-hal OutputPin. Linux-based boards like Raspberry Pi can implement the same traits as bare-metal targets. The Heartbeat driver accepts this wrapper, enabling desktop prototyping before deploying to microcontrollers.
#![allow(unused)]
fn main() {
use embedded_hal::digital::v2::OutputPin;
use rppal::gpio::{Gpio, OutputPin as PiPin};
pub struct PiLed {
pin: PiPin,
}
impl PiLed {
pub fn new(pin_id: u8) -> Self {
let pin = Gpio::new().unwrap().get(pin_id).unwrap().into_output();
Self { pin }
}
}
impl OutputPin for PiLed {
type Error = core::convert::Infallible;
fn set_low(&mut self) -> Result<(), Self::Error> {
self.pin.set_low();
Ok(())
}
fn set_high(&mut self) -> Result<(), Self::Error> {
self.pin.set_high();
Ok(())
}
}
}
You can now feed PiLed into the Heartbeat example and run the exact same logic on a Raspberry Pi for desktop prototyping.
Testing tip: replace P and T with fake implementations using std timers to coverage-test logic on the desktop.
Pattern 2: Static Allocation & Zero-Copy Buffers
- Problem: Dynamic allocation (
Vec,Box) is often unavailable or banned in hard real-time systems. Yet peripherals require queues for DMA, networking, or logging. - Solution: Use
heapless,arrayvec, or customstatic mutbuffers guarded by safe wrappers. Favor compile-time capacity, placement in specific memory sections, and DMA-friendly alignment. - Why It Matters: Static buffers make timing predictable and avoid allocator fragmentation. They also ease certification (MISRA, DO-178C) where dynamic memory is disallowed.
- Use Cases: UART ring buffers, telemetry queues, sensor fusion windows, DMA descriptors.
Examples
Example: Lock-Free Telemetry Queue
Heapless SPSC queue for interrupt-safe communication without heap allocation. The producer enqueues packets with atomic IDs, while the consumer dequeues without locks. Splitting into producer/consumer halves enables safe concurrent access from ISR and main loop.
#![allow(unused)]
fn main() {
use heapless::spsc::Queue;
use core::sync::atomic::{AtomicU32, Ordering};
static mut Q: Queue<[u8; 32], 8> = Queue::new();
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
fn producer_task() {
let (mut prod, _) = unsafe { Q.split() }; // Safety: called before RTOS start
let mut packet = [0u8; 32];
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
packet[..4].copy_from_slice(&id.to_le_bytes());
prod.enqueue(packet).ok();
}
fn consumer_task() {
let (_, mut cons) = unsafe { Q.split() };
while let Some(pkt) = cons.dequeue() { process_packet(&pkt); }
}
}
Example: DMA Buffer Placement
DMA buffer in a specific memory section using link_section attribute. DMA controllers require buffers in accessible SRAM regions. Static placement ensures correct alignment and memory domain without modifying linker scripts, while the buffer persists for transfer lifetime.
#![allow(unused)]
fn main() {
#[link_section = ".dma_data"]
static mut ADC_BUFFER: [u16; 128] = [0; 128];
fn start_dma(adc: &mut AdcDma<'static>) {
// Safety: DMA exclusively owns the buffer until transfer completes.
unsafe { adc.start_dma(&mut ADC_BUFFER) }.unwrap();
}
}
Example: Fixed-Capacity Command Log
Command history using heapless Vec with compile-time capacity of 32 entries. Critical sections protect concurrent access. When full, new commands are silently dropped. This pattern suits logging, telemetry buffers, and audit trails without dynamic allocation.
#![allow(unused)]
fn main() {
use core::cell::RefCell;
use critical_section::Mutex;
use heapless::Vec;
#[derive(Clone, Copy)]
pub struct Command {
opcode: u8,
payload: [u8; 4],
}
static COMMAND_LOG: Mutex<RefCell<Vec<Command, 32>>> =
Mutex::new(RefCell::new(Vec::new()));
pub fn append_command(cmd: Command) {
critical_section::with(|cs| {
let mut log = COMMAND_LOG.borrow(cs).borrow_mut();
log.push(cmd).ok(); // drop oldest silently when full
});
}
pub fn latest() -> Option<Command> {
critical_section::with(|cs| COMMAND_LOG.borrow(cs).borrow().last().copied())
}
}
Example: STM32 DMA Double Buffer
Configure a double-buffered DMA for continuous audio streaming. Two pre-allocated buffers alternate: while DMA fills one, the CPU processes the other. This ping-pong technique eliminates gaps in high-rate data streams like audio or software-defined radio applications.
#![allow(unused)]
fn main() {
#[link_section = ".sram_d2"]
static mut AUDIO_BUFFERS: [[i16; 256]; 2] = [[0; 256]; 2];
fn start_audio_dma(dma: &mut stm32h7xx_hal::dma::StreamX<DMA1>) {
let (buf_a, buf_b) = unsafe { (&mut AUDIO_BUFFERS[0] as *mut _, &mut AUDIO_BUFFERS[1] as *mut _) };
unsafe { dma.set_memory0(buf_a as *mut _); dma.set_memory1(buf_b as *mut _); }
dma.enable_double_buffer();
dma.start();
}
}
ISR handlers can then refill whichever half just completed without races or heap usage.
Pattern 3: Interrupt-Safe Shared State
- Problem: ISRs need to communicate with foreground tasks without data races.
static mutvariables are unsafe, andRefCellpanics in interrupts. - Solution: Use synchronization primitives tailored to bare metal:
cortex_m::interrupt::Mutex,critical_section::Mutex, atomics, or lock-free queues. Disable interrupts only around the minimum critical section. - Why It Matters: Predictable interrupt latency, no priority inversion, and analyzable execution times.
- Use Cases: Button debouncing, timer capture/compare, sensor event batching, cross-core mailboxes.
Examples
Example: Critical Section with Mutex
Count button presses using cortex-m Mutex with RefCell. The free() function disables interrupts briefly while accessing shared state. Both ISR and main code use identical access patterns, preventing data races while keeping critical sections minimal for low latency.
#![allow(unused)]
fn main() {
use core::cell::RefCell;
use cortex_m::interrupt::{free, Mutex};
static BUTTON_COUNT: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));
#[interrupt]
fn EXTI0() {
free(|cs| {
let mut count = BUTTON_COUNT.borrow(cs).borrow_mut();
*count += 1;
});
}
fn read_count() -> u32 {
free(|cs| *BUTTON_COUNT.borrow(cs).borrow())
}
}
Example: Atomic Flag for Wake-Ups
AtomicBool for lock-free ISR-to-main communication. The ADC interrupt sets the flag with Release ordering, main loop checks with AcqRel swap. WFI sleeps until interrupts arrive, saving power while maintaining responsiveness without critical section overhead.
use core::sync::atomic::{AtomicBool, Ordering};
static DATA_READY: AtomicBool = AtomicBool::new(false);
#[interrupt]
fn ADC1() {
DATA_READY.store(true, Ordering::Release);
}
fn main_loop() {
loop {
if DATA_READY.swap(false, Ordering::AcqRel) {
handle_sample();
}
cortex_m::asm::wfi(); // sleep until next interrupt
}
}
Example: Sharing Buses with critical_section::Mutex
I2C sensor between contexts using critical_section::Mutex. The portable critical-section crate works across platforms, providing Send+Sync without unsafe blocks. Late initialization with Option allows hardware setup after boot while maintaining safe concurrent access patterns.
#![allow(unused)]
fn main() {
use core::cell::RefCell;
use critical_section::Mutex;
struct EnvSensor<I2C> {
bus: I2C,
}
static SENSOR: Mutex<RefCell<Option<EnvSensor<I2cDriver>>>> =
Mutex::new(RefCell::new(None));
fn init_sensor(bus: I2cDriver) {
critical_section::with(|cs| { *SENSOR.borrow(cs).borrow_mut() = Some(EnvSensor { bus }); });
}
fn read_temperature() -> Option<i16> {
critical_section::with(|cs| SENSOR.borrow(cs).borrow_mut().as_mut().and_then(|s| s.bus.read_temp().ok()))
}
}
Example: Raspberry Pi GPIO Interrupt Counter
GPIO interrupts on Raspberry Pi using rppal’s async callback. AtomicU32 counts button presses lock-free, mirroring bare-metal ISR patterns. The callback triggers on falling edges, enabling responsive input handling while the main thread continues other work.
#![allow(unused)]
fn main() {
use rppal::gpio::{Gpio, Trigger};
use core::sync::atomic::{AtomicU32, Ordering};
static BUTTON_COUNT: AtomicU32 = AtomicU32::new(0);
fn init_button(pin: u8) -> Result<(), rppal::gpio::Error> {
let gpio = Gpio::new()?;
let mut button = gpio.get(pin)?.into_input_pulldown();
button.set_async_interrupt(Trigger::FallingEdge, |_| {
BUTTON_COUNT.fetch_add(1, Ordering::Relaxed);
})?;
Ok(())
}
fn button_presses() -> u32 {
BUTTON_COUNT.load(Ordering::Relaxed)
}
}
Pattern 4: Deterministic Scheduling with RTIC/Embassy
- Problem: Cooperative
loop {}architectures make it hard to guarantee deadlines when peripherals compete for CPU time. - Solution: Use a lightweight real-time framework (RTIC, Embassy) that models tasks as interrupt handlers with explicit priorities and resource locking.
- Why It Matters: Priority-based scheduling gives bounded latency, automatic critical sections, and eliminates the need for a traditional RTOS.
- Use Cases: Motor control loops, sensor fusion pipelines, industrial fieldbus stacks, battery management systems.
Examples
Example: RTIC Task Graph
RTIC framework managing motor control tasks. Shared rpm state is accessed via automatic locking based on task priorities. The sample task captures encoder readings while the higher-priority control task computes PWM duty cycles, all without heap allocation.
#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac, peripherals = true)]
mod app {
use super::*;
#[shared]
struct Shared {
rpm: u16,
}
#[local]
struct Local {
encoder: Encoder,
pwm: PwmDriver,
}
#[init]
fn init(ctx: init::Context) -> (Shared, Local) {
// init hardware ...
(Shared { rpm: 0 }, Local { encoder, pwm })
}
#[task(binds = TIM2, shared = [rpm], local = [encoder])]
fn sample(mut ctx: sample::Context) {
let rpm_measurement = ctx.local.encoder.capture();
ctx.shared.rpm.lock(|rpm| *rpm = rpm_measurement);
}
#[task(priority = 2, shared = [rpm], local = [pwm])]
fn control(mut ctx: control::Context) {
let rpm = *ctx.shared.rpm.lock(|rpm| rpm);
let duty = pid_step(rpm);
ctx.local.pwm.set_duty(duty);
}
}
}
Example: Embassy Async Driver
Embassy’s async/await model for embedded systems. UART writes and timer delays use cooperative scheduling without blocking. The executor automatically enters low-power WFI sleep between async operations, combining ergonomic code with energy efficiency.
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
let mut uart = Uart::new(p.USART2, p.PA2, p.PA3, Irqs, p.DMA1_CH6, p.DMA1_CH7);
spawner.spawn(sample_task()).unwrap();
loop {
uart.write(b"ping\r\n").await.unwrap();
Timer::after_secs(1).await;
}
}
Example: Embassy Channels for Task Isolation
Decouple ADC sampling from filtering using Embassy channels. The sampler task sends readings at 2kHz into a bounded channel, while the filter task processes independently. Backpressure naturally throttles producers when consumers fall behind, preserving timing guarantees.
use embassy_executor::{Spawner, task};
use embassy_sync::channel::Channel;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::{Duration, Timer};
static ADC_SAMPLES: Channel<NoopRawMutex, u16, 8> = Channel::new();
#[task]
async fn adc_sampler() {
loop {
let sample = read_adc_sample();
ADC_SAMPLES.send(sample).await;
Timer::after(Duration::from_micros(500)).await;
}
}
#[task]
async fn filter_task() {
loop {
let sample = ADC_SAMPLES.recv().await;
let filtered = low_pass(sample);
publish(filtered).await;
}
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
spawner.spawn(adc_sampler()).unwrap();
spawner.spawn(filter_task()).unwrap();
embassy_time::Timer::after_secs(1).await;
}
Design tip: Keep ISR work minimal (capture timestamp, enqueue event) and defer heavy computation to lower-priority tasks to maintain deadlines.
Checklist for Embedded Rust Patterns
- Compile with
#![no_std]andpanic_probe/defmtfor meaningful crash info. - Keep unsafe code confined to BSP crates; expose safe APIs upward.
- Measure worst-case execution times (WCET) per task and ensure they fit within interrupt budgets.
- Use hardware timers for scheduling instead of busy loops to save power.