· 6 min read ·

Volatile as Operation: How Rust Rethought Hardware Access

Source: lobsters

In C, volatile is a type qualifier. You write volatile uint32_t *reg = (volatile uint32_t*)0x40020014;, and the compiler promises that every dereference through that pointer is a real memory access, not a cached register read or an eliminated store. The model is straightforward, and embedded C programmers have relied on it for decades.

Rust discarded this model. There is no volatile keyword in the language. Instead, core::ptr exposes two functions: read_volatile::<T>(src: *const T) -> T and write_volatile::<T>(dst: *mut T, src: T). Volatile is a property of the access operation, not the pointer type. A *mut u32 pointing at a hardware register looks identical in the type system to a *mut u32 pointing at stack memory.

The Ferrous Systems post on hardware access in Rust builds from this foundation upward through the full embedded stack: raw volatile pointers, PAC crates generated from SVD files, HAL implementations, and portable driver crates. The architecture it describes is a solid overview. What is worth unpacking further is why each design decision was made and what problems it solves over the equivalent C approach.

Why Volatile-as-Operation Matters

C’s volatile model has a practical failure mode: a function that takes uint32_t* will silently strip volatile-ness from a volatile uint32_t* argument in many contexts. The optimizer then decides that repeated reads through the now-non-volatile pointer are redundant and eliminates them. The resulting behavior looks correct in debug builds and fails subtly in release builds. This class of bug is rare but difficult to localize.

Rust’s model removes this failure mode by making volatile access explicit at the call site. The read_volatile and write_volatile functions are unsafe, so every hardware register access is visible in the source as an unsafe operation. The compiler has no volatile type attribute to strip.

In practice, application code rarely calls read_volatile directly. The vcell crate provides VolatileCell<T>, which wraps the raw calls behind a safe interface. The volatile-register crate goes further, providing RO<T>, WO<T>, and RW<T> types that encode read/write permissions at the type level. You cannot call .write() on an RO<T>; the compiler rejects it. These are the building blocks that svd2rust uses internally when generating PAC crates.

PACs and Peripheral Ownership

In practical terms, peripheral ownership matters more to embedded correctness than the volatile model. Consider a C codebase with two driver modules that both touch GPIOA. Nothing in the language prevents them from doing so simultaneously, from misconfiguring each other’s state, or from corrupting a register in a concurrent interrupt context. The conventional solution is documentation, naming conventions, and careful code review.

svd2rust generates PAC (Peripheral Access Crate) code from vendor-supplied SVD (System View Description) XML files. SVD is an ARM-standardized format that describes a microcontroller’s complete register map: peripheral base addresses, register offsets and sizes, bitfield names, access permissions, and reset values. Every major Cortex-M vendor publishes SVD files for their devices, and svd2rust turns them into Rust code.

The generated Peripherals::take() method returns Option<Peripherals> and internally sets a flag to prevent subsequent calls from succeeding. Once you move p.GPIOA into your GPIO driver, the compiler ensures no other code can obtain a second handle:

let p = stm32f4::stm32f401::Peripherals::take().unwrap();
// p.GPIOA moved into gpio_driver
let gpio_driver = GpioDriver::new(p.GPIOA);
// p.USART1 still available; p.GPIOA is not
let uart = UartDriver::new(p.USART1);

The generated register access API replaces manual bit manipulation with named methods derived from SVD field names. The modify() method handles the read-modify-write cycle, read() returns a proxy with field accessors, and write() takes a closure starting from the reset value:

// C: manual bit manipulation, no names, no safety
GPIOA->MODER = (GPIOA->MODER & ~(3 << 10)) | (1 << 10);

// Rust PAC: named fields from the authoritative SVD source
dp.GPIOA.moder.modify(|_, w| w.moder5().output());

svd2rust is at version 0.35.x as of 2025, with accumulated improvements including --atomics for generating atomic access methods on supported targets, better handling of register clusters, and support for non-ARM architectures including RISC-V and Xtensa. The chiptool alternative, used by Embassy’s own HALs, generates a different API style with somewhat more ergonomic patterns for bitfield-heavy peripherals.

The HAL Layer and Portable Drivers

PACs are chip-specific. A sensor driver written against the stm32f4 PAC is useless on a Nordic nRF52840. The embedded-hal crate solves this by defining trait abstractions for common hardware interfaces, so driver crates can target the traits rather than any specific chip.

embedded-hal 1.0.0 shipped in January 2024 after several years of deliberate design iteration. The 0.2.x series was widely adopted but had accumulated design compromises that required a breaking redesign. The 1.0 release stabilized traits for digital I/O, SPI, I2C, UART, ADC, PWM, and delay:

// A driver that works on any MCU implementing SpiDevice
pub struct Max31865<SPI> {
    spi: SPI,
}

impl<SPI: embedded_hal::spi::SpiDevice> Max31865<SPI> {
    pub fn read_temperature(&mut self) -> Result<f32, SPI::Error> {
        // identical code runs on STM32, nRF52, RP2040, ESP32...
        todo!()
    }
}

The companion embedded-hal-async crate provides async versions of the same traits. embedded-hal-bus adds shared-bus adapters for cases where multiple devices sit on the same SPI or I2C bus, a common scenario that 0.2.x handled poorly. Major HAL crates, including stm32f4xx-hal, nrf-hal, rp-hal, and Espressif’s esp-hal, updated to the 1.0 API during 2024.

Encoding State in Types

One pattern the layered stack enables is using type parameters to encode peripheral configuration state. A UART in disabled state and a UART in enabled state become different types, and methods that require a specific state only compile when the peripheral is in that state:

use core::marker::PhantomData;

struct Uart<State> {
    _state: PhantomData<State>,
    regs: &'static RegisterBlock,
}

struct Disabled;
struct Enabled;

impl Uart<Disabled> {
    fn set_baud(&mut self, baud: u32) { /* configure registers */ }
    fn enable(self) -> Uart<Enabled> {
        // set enable bit
        Uart { _state: PhantomData, regs: self.regs }
    }
}

impl Uart<Enabled> {
    fn write_byte(&mut self, byte: u8) { /* transmit */ }
    fn disable(self) -> Uart<Disabled> {
        // clear enable bit
        Uart { _state: PhantomData, regs: self.regs }
    }
}

A call to write_byte on a Uart<Disabled> is a compile error. PhantomData has no runtime representation, so the type parameter disappears after compilation. The constraint it enforced was real; the cost was zero.

HAL crates use this pattern throughout. stm32f4xx-hal encodes GPIO pin modes, Input<Floating>, Output<PushPull>, Alternate<AF7>, as type parameters, so passing an output pin to a function expecting an alternate-function UART pin fails at compile time rather than silently misconfiguring hardware.

The Current Ecosystem

The embedded Rust ecosystem crossed a meaningful stability threshold in 2024. embedded-hal 1.0 anchored the trait layer. Embassy, which provides an async executor and HALs for STM32, nRF, RP2040, and ESP32 families, is the default choice for new projects that want async I/O without an RTOS. defmt and probe-rs have become the standard logging and debugging stack, replacing the OpenOCD and semihosting workflows common before 2022.

Ferrous Systems maintains Ferrocene, a qualified Rust toolchain for ISO 26262 (automotive) and IEC 62443 (industrial) contexts, supporting Cortex-M4F and M7F targets. The certification artifacts Ferrocene provides matter for industries where C has historically been the default not for technical reasons but for process requirements.

The layered architecture the article describes has analogues in mature C-based embedded frameworks. Peripheral abstraction layers, volatile wrappers, and HAL traits exist in ecosystems like ChibiOS and Zephyr. What Rust provides at each layer is compiler enforcement: ownership singletons replace documentation agreements about peripheral sharing, typed register accessors replace careful manual bit manipulation, and typestate patterns encode state machines in the type checker rather than in comments. Experienced C programmers follow these same disciplines by convention. Rust builds the conventions into the type system.

Was this interesting?