· 6 min read ·

How Rust Moves the Hardware Contract Out of the Comments

Source: lobsters

C’s volatile keyword solves a precise and narrow problem: it tells the compiler not to cache a memory location, forcing every read and write to touch the actual address. For hardware-mapped registers, that is necessary but nowhere close to sufficient. A read-only status register and a write-only control register both live at addresses in memory, and volatile treats them identically. The constraints that distinguish them exist only in the datasheet, replicated at best into header file comments.

The Ferrous Systems article on hardware access in Rust walks through how Rust’s embedded ecosystem approaches this differently. The interesting part is not just the API surface; it is what problem space Rust is actually solving compared to what volatile solves.

What volatile Actually Guarantees

In C, accessing a memory-mapped peripheral register looks like this:

#define GPIOA_IDR (*(volatile uint32_t*)0x40020010)
uint32_t pins = GPIOA_IDR;

The volatile qualifier prevents the compiler from caching the value or eliminating the access. That is the entire guarantee. It says nothing about whether reading this address is valid, whether writing to it is permitted, who owns the peripheral, or whether the peripheral has been configured. Those constraints live in documentation and convention.

Rust provides core::ptr::read_volatile and core::ptr::write_volatile, both stable since Rust 1.9. They map to LLVM volatile load and volatile store instructions, producing the same machine code. Both are unsafe, which at minimum makes the access visible at the call site rather than hidden inside a cast. But that is still just the foundation. What the Rust embedded ecosystem builds on top is where it gets interesting.

One important caveat both languages share: neither volatile nor Rust’s volatile intrinsics imply memory barriers. On ARM Cortex-M, you still need an explicit dmb() before DMA transfers. Volatile guarantees program-order access to a specific address, not ordering with respect to other bus masters.

The Peripheral Access Crate Layer

ARM microcontrollers ship with SVD files, an XML schema describing the complete register map: peripheral base addresses, register offsets, field widths, access types (read-only, write-only, read-write), enumerated values for fields, and reset values. The svd2rust tool, maintained by the rust-embedded working group and currently at version 0.33.x, parses these files and generates a typed Rust API for the entire chip. The result is a Peripheral Access Crate, or PAC.

The generated API looks like this:

let dp = stm32f4::stm32f407::Peripherals::take().unwrap();
let gpioa = &dp.GPIOA;

// Every .read() compiles to read_volatile; zero overhead
let pin5_high = gpioa.idr.read().idr5().bit_is_set();

// Read-modify-write: read_volatile then write_volatile
gpioa.odr.modify(|_, w| w.odr5().set_bit());

// Fields with no SVD enumeration require unsafe;
// the compiler enforces the acknowledgment
gpioa.moder.write(|w| unsafe { w.moder5().bits(0b01) });

The SVD marks IDR (Input Data Register) as read-only. svd2rust generates no .write() method for it. Attempting to write it is a compile error, not a runtime crash, not a silent no-op. The datasheet constraint is now a type constraint. svd2rust 0.33.x extended this pattern beyond Cortex-M to RISC-V and Xtensa, covering the ESP32 ecosystem as well.

Peripheral Ownership and the Singleton Pattern

Peripheral aliasing is a real class of bug in embedded C. Two pieces of code each hold a pointer to the same peripheral and drive it concurrently without coordination. The correct approach is documented convention: one owner, passed around explicitly. Nothing enforces it.

Rust’s PACs use a singleton pattern. Peripherals::take() returns Option<Peripherals>, returning Some exactly once using a static AtomicBool internally. Every subsequent call returns None. You unwrap on first call and pass ownership through the type system from there:

let dp = Peripherals::take().unwrap();

let gpioa = dp.GPIOA;   // moved out of dp, which no longer holds it
let usart1 = dp.USART1; // moved out separately

// Any other code attempting to access dp.GPIOA now fails to compile

Move semantics make peripheral aliasing a category of program that does not compile. The escape hatch is unsafe fn steal(), which bypasses the singleton. This exists for legitimate cases such as pre-main initialization and fatal error handlers, and the unsafe annotation is the appropriate boundary for that scope.

Configuration State in Types

Hardware peripherals are state machines. A GPIO pin starts as an input and must be explicitly configured before you can drive it as an output. Calling a write method on an unconfigured pin in C produces no error; you may or may not be writing to a register that does anything useful, depending on reset state.

The typestate pattern encodes configuration state as phantom type parameters, which carry zero runtime size:

use core::marker::PhantomData;

pub struct Floating;
pub struct PullUp;
pub struct PushPull;

pub struct Input<MODE>  { _mode: PhantomData<MODE> }
pub struct Output<MODE> { _mode: PhantomData<MODE> }

pub struct PC13<MODE> {
    _mode: PhantomData<MODE>,
}

impl PC13<Input<Floating>> {
    pub fn into_push_pull_output(self) -> PC13<Output<PushPull>> {
        // configure MODER bits here
        PC13 { _mode: PhantomData }
    }
    pub fn is_high(&self) -> bool { /* read IDR */ true }
}

impl PC13<Output<PushPull>> {
    pub fn set_high(&mut self) { /* write BSRR */ }
    pub fn set_low(&mut self)  { /* write BSRR */ }
}

PC13<Output<PushPull>> is a zero-byte struct at runtime. Calling set_high() on a PC13<Input<Floating>> is a compile error. The into_push_pull_output() method consumes the pin and returns a new type, enforcing a linear configuration chain with no branching on configuration state at runtime. HAL crates across the ecosystem use this pattern: stm32f4xx-hal, nrf52840-hal, rp2040-hal, and others all structure GPIO and peripheral configuration this way.

Driver Portability with embedded-hal

The PAC layer is chip-specific by design. Drivers for external devices, a BME280 sensor, an SSD1306 display, an SD card controller, should not need to be rewritten for every silicon vendor’s SPI or I2C implementation.

embedded-hal 1.0, released in January 2024 after six years of development, settled the stable trait API for this portability layer:

use embedded_hal::i2c::I2c;

pub struct Bme280<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C: I2c> Bme280<I2C> {
    pub fn read_temperature(&mut self) -> Result<i32, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.address, &[0xFA], &mut buf)?;
        Ok(i32::from_be_bytes([0, 0, buf[0], buf[1]]))
    }
}

This driver compiles unchanged for STM32, nRF52840, RP2040, ESP32, or a host-side simulation environment. The hardware difference lives entirely in the HAL crate implementing the trait. The 1.0 release also ships embedded-hal-async, providing async fn variants of every trait, which connects directly to runtimes like Embassy, where GPIO waits are interrupt-driven rather than busy-polling. The transition from 0.2.x to 1.0 broke ecosystem compatibility during migration, and the embedded-hal-compat bridge crate carried the ecosystem across that gap, but the current state is stable.

What the Final Binary Contains

None of the above adds overhead. The typestate structs have zero size. The PAC register access methods inline completely to read_volatile and write_volatile calls against hard-coded addresses. The singleton check is a one-time AtomicBool swap at startup. A release build through this full stack produces the same machine code a careful C programmer would write by hand.

The difference is entirely in what can be caught before the binary exists:

ProblemC approachRust approach
Compiler elides MMIO accessvolatile qualifierread_volatile / write_volatile
Access permission violationsCommentsRO<T> / WO<T> types; missing PAC methods
Register address errorsCMSIS conventionsvd2rust named register structs
Peripheral aliasingCode reviewSingleton + move semantics
Using hardware in unconfigured stateDocumentationTypestate pattern, compile error
Driver lock-in to one siliconifdefs, copy-pasteembedded-hal trait abstraction

The catalog of supported hardware in awesome-embedded-rust now covers a wide range of production-relevant microcontrollers, and RTIC 2.0 adds compile-time scheduling of interrupt-driven tasks with statically enforced resource ownership on top of this stack.

If you are writing firmware in C, none of this is magic. The constraints that Rust enforces at compile time exist in your C code too; they live in comments, naming conventions, and code review checklists. Rust’s embedded ecosystem is what happens when you decide those constraints belong in the type system instead of in the documentation.

Was this interesting?