How Rust Moves Hardware Constraints From Comments Into the Type System
Source: lobsters
The Problem With volatile
In C embedded programming, volatile is the mechanism for telling the compiler not to optimize away hardware register accesses. It’s a type qualifier on a pointer:
#define GPIOA_ODR (*(volatile uint32_t*)0x40020014)
GPIOA_ODR = 0x1;
This works, but it has a well-known failure mode: volatile is part of the pointer’s type, and C allows implicit conversion between pointer types. Cast your volatile uint32_t* to a plain uint32_t*, and the compiler will happily optimize away the access. Accessing a volatile-qualified object through a non-volatile pointer is undefined behavior under the C standard, but the toolchain gives you essentially no help enforcing this. You rely on discipline, code review, and compiler warnings where they exist and are enabled.
C++ doesn’t meaningfully improve this situation. In fact, the C++ committee has deprecated volatile for this use case in newer standards, acknowledging that the memory model doesn’t provide strong enough guarantees for hardware access semantics. std::atomic was designed for multithreading, not memory-mapped I/O, and the two requirements don’t cleanly overlap.
Volatile as Operation, Not Type
Ferrous Systems’ overview of hardware access in Rust lays out a layered model that starts from a different foundation. In Rust, volatile is not a type qualifier. It’s an operation.
The standard library provides two intrinsics:
unsafe {
core::ptr::write_volatile(0x40020014 as *mut u32, 0x1);
let val = core::ptr::read_volatile(0x40020014 as *const u32);
}
These are guaranteed not to be elided or reordered relative to other volatile operations. But a raw *mut u32 has no memory of whether it points at a hardware register or a heap allocation. If you dereference it normally with *ptr = val, the compiler will treat that as a regular store and may optimize it away. The pointer type alone carries no semantic obligation.
The upgrade from C is that there is no accidental downgrade path. In C, you can silently cast away volatile. In Rust, there is no volatile to cast away: the obligation to call write_volatile exists only in documentation. This sounds like it hasn’t improved things, but it sets up the next layer correctly.
The volatile_register Layer
The volatile_register crate wraps raw volatile operations in types that expose only volatile access methods. The key types are RO<T>, RW<T>, and WO<T>:
use volatile_register::{RO, RW, WO};
#[repr(C)]
pub struct UartRegisters {
pub cr1: RW<u32>, // control register 1: read-write
pub cr2: RW<u32>, // control register 2: read-write
pub sr: RO<u32>, // status register: read-only
pub dr: RW<u32>, // data register: read-write
pub brr: RW<u32>, // baud rate register: read-write
}
RO<u32> exposes only a read() method. WO<u32> exposes only a write() method. RW<u32> exposes both, plus modify(), which reads, applies a closure, and writes back. All three internally call read_volatile or write_volatile. You cannot construct an RO<u32> and accidentally perform a non-volatile read, because the type only vends volatile operations.
This encodes what used to live only in a datasheet comment directly into the type. A read-only status register is RO<u32> in Rust; calling write() on it is a compile error because the method doesn’t exist on that type. The modify() method is worth attention:
unsafe { uart.cr1.modify(|v| v | (1 << 13)); } // set UE bit to enable UART
This guarantees a read, then the closure’s transformation, then a write. Nothing in C prevents accidentally writing a stale value that zeros bits you care about. The type encodes the access pattern, not just the access width.
Peripheral Access Crates and Register Fields
Hand-writing volatile_register structs for every peripheral on a microcontroller is tedious and error-prone. The svd2rust tool solves this by consuming an SVD file, ARM’s System View Description format, an XML description of a chip’s peripherals, and generating a Peripheral Access Crate (PAC) with a type-safe API for every register on the chip.
The generated API looks like this:
use stm32f1::stm32f103::Peripherals;
let p = Peripherals::take().unwrap();
p.GPIOA.odr.write(|w| w.odr5().set_bit());
let is_high = p.GPIOA.idr.read().idr5().bit_is_set();
p.RCC.apb2enr.modify(|_, w| w.iopaen().enabled());
Peripherals::take() returns Option<Peripherals> and can only succeed once for the lifetime of the program, enforced by a static atomic flag. The second call returns None. This reflects hardware reality: there is one GPIOA peripheral. Allowing multiple independent mutable handles to it would be unsound, and the type system encodes that constraint.
For register fields with enumerated values, the generated code uses proper enum variants rather than raw integers. Passing an invalid value to iopaen() is a compile error because the method only accepts the generated enum type. The SVD file’s register field definitions, which previously lived only in a PDF datasheet, become part of the type signature.
The Embassy project’s chiptool is a newer alternative to svd2rust that generates more ergonomic code and supports YAML-based register descriptions; Embassy’s stm32-metapac is built on it and underlies the embassy-stm32 HAL.
HAL Crates and Type-State Programming
HAL crates sit above PACs and encode hardware configuration state into types so that illegal state transitions are compile errors, a technique called type-state programming.
A GPIO pin type parameterized by its mode:
struct Pin<MODE> {
port: u8,
pin: u8,
_mode: PhantomData<MODE>,
}
struct Input<PULL>(PhantomData<PULL>);
struct Output<TYPE>(PhantomData<TYPE>);
struct Floating;
struct PushPull;
impl<MODE> Pin<Input<MODE>> {
pub fn is_high(&self) -> bool { /* reads IDR */ }
}
impl<MODE> Pin<Output<MODE>> {
pub fn set_high(&mut self) { /* writes ODR */ }
pub fn into_floating_input(self) -> Pin<Input<Floating>> {
// configure MODER register in hardware
Pin { port: self.port, pin: self.pin, _mode: PhantomData }
}
}
is_high() exists only on Pin<Input<MODE>>. set_high() exists only on Pin<Output<MODE>>. Calling set_high() on an input-configured pin is a compile error. These invariants were always true at the hardware level; the HAL layer makes the hardware constraint a language constraint.
Peripheral initialization follows the same pattern. In stm32f1xx-hal, configuring UART1 requires passing the specific GPIO pins already reconfigured to their alternate function mode:
let tx = gpioa.pa9.into_alternate_push_pull(&mut gpioa.crh);
let rx = gpioa.pa10;
let serial = Serial::new(dp.USART1, (tx, rx), /* ... */);
into_alternate_push_pull() consumes the pin and returns a different type. Passing the wrong pin type to Serial::new() fails to compile. The datasheet’s pin mapping table is expressed as types.
The embedded-hal 1.0 Milestone
The embedded-hal 1.0 release in January 2024 was a significant moment for the ecosystem. The crate defines traits that hardware-agnostic driver crates depend on, so a driver for an SPI temperature sensor written against embedded-hal works on any microcontroller whose HAL implements those traits.
Version 1.0 brought cleaner error handling: each trait gained an associated Error type rather than fixed error types, allowing HALs to surface hardware-specific errors without losing type information. The SpiDevice trait now manages chip-select internally, fixing a longstanding design issue that had caused subtle bugs in driver crates.
The companion crate embedded-hal-async ships with the same release, providing async versions of the same traits. A driver written against embedded_hal_async::spi::SpiDevice can await SPI transfers and works transparently with Embassy’s async runtime on any supporting HAL, with the async state machine generated at compile time and no RTOS overhead.
Where This Points
The architecture described by Ferrous Systems’ layered model is worth examining not just as a how-to for embedded development, but as an example of what becomes possible when a language’s type system is expressive enough to encode domain invariants.
C embedded codebases accumulate conventions: naming prefixes, comment annotations, header file structure, and code review practices that collectively enforce rules the language cannot enforce. Forgetting the volatile qualifier, writing to the wrong field of a bitfield union, or passing a peripheral handle to two functions that both assume exclusive ownership are bugs that happen regularly in production C code.
The Rust PAC and HAL ecosystem moves these conventions into types. The discipline is encoded once, in the crate, and then enforced by the compiler for every user of that crate. The cost is upfront complexity in the HAL implementation; the benefit is that users write substantially less defensive code and receive actionable compiler errors rather than silent misbehavior.
The embedded-hal 1.0 stabilization and the maturation of Embassy’s async HALs suggest that the ecosystem has moved past the phase where the right architecture was still being debated. The layered model is now settled enough to build on with confidence, and the question for new embedded Rust projects is mostly which HAL to choose, not whether the approach is viable.