Introduction
Hardware registers are not ordinary memory — reading a status register may clear a flag, writing a control register triggers an action. The compiler does not know this and may optimize away "redundant" reads or reorder writes. Volatile operations tell the compiler: every access matters, do not optimize it away.
Key Concepts
- Memory-mapped I/O (MMIO): Hardware peripherals are accessed through fixed memory addresses where reads and writes have side effects.
- Volatile: A qualifier that prevents the compiler from optimizing away, reordering, or coalescing memory accesses.
read_volatile/write_volatile: Core library functions for performing volatile memory access through raw pointers.
Real World Context
When polling a UART status register for incoming data, you read the same address repeatedly. Without volatile, the compiler sees multiple reads of the same address and keeps only the first — your firmware hangs forever waiting for data that already arrived.
Deep Dive
The Optimization Problem
The compiler applies dead-store elimination and redundant-load removal:
rust// Without volatile, the compiler may optimize this to a single read let status = unsafe { *(0x4002_0000 as *const u32) }; let status = unsafe { *(0x4002_0000 as *const u32) }; // Compiler thinks: same address, same value — keep only one read // But hardware may have changed the value between reads!
This is dangerous because hardware registers change asynchronously — an interrupt flag might set between reads, but the compiler eliminated the second read.
Volatile: Every Access Counts
The core::ptr module provides volatile counterparts:
rustuse core::ptr::{read_volatile, write_volatile}; unsafe { // Every read actually reaches the hardware let status1 = read_volatile(0x4002_0000 as *const u32); let status2 = read_volatile(0x4002_0000 as *const u32); // Both reads are preserved — status2 may differ from status1 // Every write actually reaches the hardware write_volatile(0x4002_0004 as *mut u32, 0xFF); }
Volatile guarantees the compiler emits a load/store instruction for every call, in the exact order written.
Why PACs Handle This Automatically
PAC-generated code uses volatile internally, so you do not need to think about it:
rust// PAC read/write use volatile under the hood dp.GPIOA.idr.read(); // Uses read_volatile dp.GPIOA.odr.write(|w| w.bits(0xFF)); // Uses write_volatile
This is one of the key benefits of PACs over raw pointer access.
The volatile-register Crate
For custom MMIO layouts without a PAC, the volatile-register crate provides typed wrappers:
rustuse volatile_register::{RO, RW, WO}; #[repr(C)] struct UartRegisters { dr: RW<u32>, // Data register — read/write sr: RO<u32>, // Status register — read-only cr: RW<u32>, // Control register — read/write brr: WO<u32>, // Baud rate — write-only } let uart = unsafe { &*(0x4000_0000 as *const UartRegisters) }; uart.dr.write(0x55); // OK — RW type let status = uart.sr.read(); // OK — RO type // uart.sr.write(0); // Compile error: RO has no write method // let _ = uart.brr.read(); // Compile error: WO has no read method
This combines volatile semantics with compile-time access-permission checks.
Common Pitfalls
- Forgetting volatile on status polling loops — A
while register == 0 {}loop without volatile may compile into an infinite loop because the compiler loads the value once and keeps checking that cached value. - Using volatile for all memory — Volatile is only needed for MMIO. Using it for regular variables prevents legitimate optimizations and slows down code.
- Assuming volatile prevents reordering across peripherals — Volatile only prevents reordering of accesses to the same address. For cross-peripheral ordering, use memory barriers (
core::sync::atomic::fence).
Best Practices
- Use PACs instead of manual volatile — PAC-generated code handles volatile correctly. Only use raw
read_volatile/write_volatilefor custom hardware or registers not in the SVD. - Use
volatile-registerfor custom peripheral structs — When writing a driver for custom hardware,volatile-registergives you type safety and volatile semantics. - Add memory barriers when ordering matters — If you need a write to peripheral A to complete before reading peripheral B, use
core::sync::atomic::compiler_fence(Ordering::SeqCst).
Summary
- Hardware registers need volatile access because reads/writes have side effects.
read_volatileandwrite_volatileprevent the compiler from optimizing away or reordering accesses.- PACs use volatile internally, so prefer them over raw pointers.
volatile-registerprovidesRO,RW,WOwrappers for custom MMIO structures.- Volatile does not replace memory barriers for cross-peripheral ordering.
Code Examples
use volatile_register::{RO, RW, WO};
/// Memory-mapped UART register block for custom hardware
#[repr(C)]
struct UartRegs {
dr: RW<u32>, // Offset 0x00: Data register
sr: RO<u32>, // Offset 0x04: Status register
cr: RW<u32>, // Offset 0x08: Control register
brr: WO<u32>, // Offset 0x0C: Baud rate register
}
const UART_BASE: usize = 0x4000_4400;
fn uart_regs() -> &'static UartRegs {
// SAFETY: UART_BASE is the documented base address for USART2
unsafe { &*(UART_BASE as *const UartRegs) }
}
fn send_byte(byte: u8) {
let regs = uart_regs();
// Volatile read: actually checks hardware each iteration
while regs.sr.read() & (1 << 7) == 0 {
// Wait for TXE (transmit empty) flag
}
// Volatile write: actually sends the byte to hardware
unsafe { regs.dr.write(byte as u32); }
}