In C, accessing hardware registers means reaching for volatile. The qualifier tells the compiler that a memory location may change outside the normal control flow, which prevents caching reads or eliding writes. For memory-mapped I/O, this is the essential guarantee. Applied to a UART data register at a fixed address, the pattern looks like this:
#define UART_DR (*(volatile uint32_t *)0x40011004)
#define UART_SR (*(volatile uint32_t *)0x40011000)
void uart_write(char c) {
while (!(UART_SR & (1 << 7)));
UART_DR = c;
}
This works. It also does nothing to prevent writing to UART_SR or reading from UART_DR at the wrong time. Whether SR is a read-only status register and DR is write-only is information that lives in the datasheet and, ideally, in a comment. The compiler has no representation of it. Writing UART_SR = 0xFF compiles silently and corrupts hardware state.
Rust’s approach to hardware access, described in Ferrous Systems’ overview, distributes those semantics across a layered type system. Each layer adds a distinct compile-time safety guarantee. Together, they turn the conventions that C embedded developers follow manually into properties the compiler enforces structurally.
The Foundation: read_volatile and write_volatile
At the bottom, Rust exposes two functions in core::ptr:
pub unsafe fn read_volatile<T>(src: *const T) -> T;
pub unsafe fn write_volatile<T>(dst: *mut T, src: T);
Both are unsafe. Both map directly to LLVM’s volatile load and volatile store instructions. The semantics match C’s volatile: no caching, no elision, no reordering relative to other volatile operations.
The meaningful difference from C is that the volatile property attaches to the operation, not the pointer type. There is no *volatile T in Rust. Accessing a register volatilely means calling the function. Accessing it non-volatilely means dereferencing the pointer. The intent is explicit at every call site:
const UART_SR: *const u32 = 0x4001_1000 as *const u32;
const UART_DR: *mut u32 = 0x4001_1004 as *mut u32;
unsafe fn uart_write(byte: u8) {
while core::ptr::read_volatile(UART_SR) & (1 << 7) == 0 {}
core::ptr::write_volatile(UART_DR, byte as u32);
}
This still enforces nothing about access modes. Writing to UART_SR via write_volatile compiles without complaint. But at least there is no category of bug where a pointer is cast without volatile and silently produces incorrect optimizer behavior. The absence of a type qualifier eliminates a specific C footgun at this layer.
Access Modes as Type Parameters
The next layer up introduces access mode into the type itself. The voladdress crate, widely used in GBA homebrew and other memory-mapped contexts, represents a volatile address with three parameters:
pub struct VolAddress<T, R, W> { /* address */ }
R is the read permission marker and W is the write permission marker. When either is (), the corresponding trait implementation does not exist. When either is Safe, the operation is callable in safe code:
use voladdress::{VolAddress, Safe};
// Read-only: W = (), no write impl
const UART_SR: VolAddress<u32, Safe, ()> =
unsafe { VolAddress::new(0x4001_1000) };
// Write-only: R = (), no read impl
const UART_DR: VolAddress<u32, (), Safe> =
unsafe { VolAddress::new(0x4001_1004) };
let status = UART_SR.read(); // OK
// UART_SR.write(0); // compile error: no write method
UART_DR.write(byte as u32); // OK
// UART_DR.read(); // compile error: no read method
The unsafe block appears once, when declaring the constant. Every subsequent access is safe, and writing to a read-only register is a compile error. One class of silent C bugs becomes structurally impossible.
PACs: Type Safety Generated from Vendor Data
For real microcontrollers, access mode information already exists. Chip vendors distribute SVD (System View Description) files, XML documents that describe every peripheral, every register offset, every bit field, and each field’s access mode (read-only, write-only, read-write, write-once). The svd2rust tool reads these files and generates Peripheral Access Crates (PACs) that encode the complete access model in Rust types.
If a UART status register SR has a TXE field marked read-only in the SVD, svd2rust generates a writer type for SR that has no method for TXE. The read-only constraint falls out directly from the vendor’s description of the hardware:
// Generated PAC usage:
let sr = usart1.sr.read();
if sr.txe().bit_is_set() {
usart1.dr.write(|w| unsafe { w.bits(byte as u32) });
}
// Attempting to write a read-only field:
// usart1.sr.write(|w| w.txe().set_bit()) -- compile error, no such method
Fields with enumerated values become named methods rather than raw integers. A parity control field accepts .none(), .odd(), or .even(). There is no path to a method call with an invalid bit pattern:
usart1.cr1.modify(|_r, w| {
w.ue().enabled()
.te().enabled()
.pce().disabled()
.m().m8bits()
});
PACs also implement the peripheral singleton pattern. Peripherals::take() uses a static atomic flag to return Some exactly once. Ownership then passes through the Rust borrow checker:
let p = stm32f4::Peripherals::take().unwrap();
let usart1 = p.USART1; // moved
// p.USART1; // compile error: value moved
The atomic check runs once at startup. Exclusivity for all subsequent accesses is compile-time. Well-known PAC crates generated via svd2rust cover most popular families: stm32f4, nrf52840, rp2040-pac, esp32, among many others.
HAL Crates and the Typestate Pattern
HAL crates sit above PACs and provide ergonomic configuration APIs. The central technique is the typestate pattern: the configuration of a peripheral is part of its type, not runtime state. In stm32f4xx-hal, a GPIO pin carries port, pin number, and mode as type parameters:
use stm32f4xx_hal::gpio::{Pin, Output, PushPull, Input, PullUp};
// PA5 configured as push-pull output
let pa5: Pin<'A', 5, Output<PushPull>> = gpioa.pa5.into_push_pull_output();
pa5.set_high();
// Reconfigure: consumes the output pin, returns an input pin
let pa5: Pin<'A', 5, Input<PullUp>> = pa5.into_pull_up_input();
let level = pa5.is_high();
// pa5.set_high() -- compile error: set_high belongs to Output<PushPull> only
Reconfiguring a pin consumes the old value and produces a new one with a different type. Calling an output-specific method on an input-configured pin does not fail at runtime with a mode check; it does not compile. The hardware configuration and the Rust type are structurally synchronized.
The embedded-hal Trait Layer
embedded-hal 1.0, released in January 2024, standardizes the interface between HAL implementations and driver crates. A sensor driver written against embedded-hal traits works with any conforming HAL:
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<f32, I2C::Error> {
// ...
}
}
The same Bme280 struct works with stm32f4xx-hal, rp2040-hal, or embassy-stm32 without modification. Companion crates handle more specific protocols: embedded-hal-async (using stable async fn in trait from Rust 1.75) provides async versions of every interface, and embedded-hal-bus provides shared bus abstractions for safely using a single SPI or I2C bus with multiple devices.
Data Races with Interrupt Handlers
On Cortex-M targets, single-core execution eliminates CPU-level data races, but interrupts create logical ones. The critical-section crate provides a portable abstraction where accessing shared state requires holding a CriticalSection token, which is only produced inside a critical section:
use critical_section::with;
use cortex_m::interrupt::Mutex;
use core::cell::RefCell;
static SERIAL: Mutex<RefCell<Option<Serial<USART1>>>> =
Mutex::new(RefCell::new(None));
// Accessing outside a critical section does not compile:
// SERIAL.borrow(...) -- requires a CriticalSection argument
with(|cs| {
SERIAL.borrow(cs).borrow_mut().as_mut().unwrap().write_byte(b'A');
});
RTIC (Real-Time Interrupt-driven Concurrency) applies the Stack Resource Policy at compile time. It analyzes the priority structure of all tasks in the program and generates only the critical sections that the priority ceiling protocol requires, producing formal data-race freedom proofs at compile time. RTIC 2.0, released in 2023, extends this to async tasks and to multi-core targets such as the dual-core RP2040.
What Each Layer Prevents
The layered architecture converts specific classes of hardware bugs from silent runtime errors into compile errors:
| Bug class | C behavior | Rust behavior |
|---|---|---|
| Write to read-only register | Corrupts hardware silently | No write method generated (PAC) |
| Read from write-only register | Returns garbage or corrupts | No read method generated (PAC) |
| Two owners of one peripheral | Silent data corruption | Compile error (borrow checker) |
| Wrong pin mode at call site | Incorrect electrical behavior | Compile error (typestate mismatch) |
| Invalid enumerated field value | Silently sets wrong bits | No such method exists |
| Interrupt handler data race | Undefined behavior | Requires critical section token |
None of these checks carry runtime cost at the application layer. The type information is erased after verification. The generated machine code is what a careful C programmer would produce manually, assuming they always had the datasheet open, never made transcription errors on bit positions, and never passed a peripheral pointer to two functions simultaneously.
In C, the easy path to hardware access is also the path with no enforcement. Writing to a read-only register is a one-character mistake that compiles cleanly. In Rust, the idiomatic path encodes the hardware contract in the type, and circumventing that encoding requires deliberate use of unsafe with a specific justification. For bare-metal code with no operating system and no recovery path from hardware state corruption, the direction of that default matters more than any individual feature.