Ferrous Systems published a detailed walkthrough of how Rust handles hardware register access, tracing from raw pointer reads all the way up to typed HAL abstractions. The layered model they describe is genuinely elegant, but the most interesting thing about it isn’t any individual layer. It’s what the stack as a whole achieves: it takes every informal rule that C embedded developers enforce through naming conventions, code organization, and review comments, and encodes those rules into types the compiler verifies automatically.
To understand why that matters, start with what C actually does.
The C Reality
C embedded code typically accesses hardware registers through vendor-provided CMSIS headers, which are essentially large collections of volatile struct pointer casts:
typedef struct {
volatile uint32_t SR;
volatile uint32_t DR;
volatile uint32_t BRR;
volatile uint32_t CR1;
volatile uint32_t CR2;
volatile uint32_t CR3;
} USART_TypeDef;
#define USART1 ((USART_TypeDef *) 0x40011000)
// Enable UART, transmitter, and receiver
USART1->CR1 |= USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
This works, and the volatile qualifier does prevent the compiler from caching register reads in registers or eliminating writes it considers dead. But the type system does almost nothing for you here. Any code with access to the USART_TypeDef pointer can modify it. There is no mechanism preventing two subsystems from concurrently owning USART1. Bitfield access through C structs has implementation-defined layout, so vendors who use C bitfield structs for registers introduce silent portability bugs. And volatile in C is more limited than many developers assume: it prevents the compiler from eliding individual volatile accesses, but it says nothing about ordering relative to non-volatile memory or to other volatile accesses across interrupt boundaries.
The informal rules around all of this (“don’t use USART1 in two places”, “always disable interrupts before modifying CR1”, “check the reference manual for read-only fields before writing”) live entirely in developer heads, READMEs, and code review conventions. They are not checkable by any tool.
The Foundation: Volatile in Rust
Rust’s baseline for hardware access is core::ptr::read_volatile and core::ptr::write_volatile, stable since Rust 1.9. Their semantics are identical to C volatile: the compiler will not elide the access, reorder it with other volatile accesses, or assume the value is unchanged between reads.
use core::ptr;
const UART_DR: *mut u32 = 0x4001_1004 as *mut u32;
unsafe {
ptr::write_volatile(UART_DR, b'A' as u32);
let status = ptr::read_volatile(UART_DR);
}
Both functions are unsafe. This is intentional: dereferencing an arbitrary address is genuinely dangerous, and Rust makes you acknowledge it explicitly. But at this level, you have the same situation as C: a raw pointer, no access permission encoding, no aliasing protection. The unsafe block is not a solution; it is a starting point.
The volatile crate (currently at 0.5.x) improves on this by wrapping raw pointers in a VolatilePtr<'a, T, A> type, where A is a type-level access marker (ReadOnly, WriteOnly, or ReadWrite). Construction is still unsafe, but once you have a VolatilePtr, reads and writes are safe methods, and attempting to write through a ReadOnly pointer is a compile error. The lifetime parameter prevents use-after-free. The map operation enables projecting into struct fields without manual pointer arithmetic.
For the generated code in Peripheral Access Crates, a simpler primitive called vcell is used instead. VCell<T> is an UnsafeCell<T> where all reads and writes go through read_volatile/write_volatile. It is !Sync (cannot be shared across threads without additional synchronization) and provides simple get()/set() methods:
use vcell::VCell;
#[repr(C)]
pub struct UsartRegisters {
pub sr: VCell<u32>,
pub dr: VCell<u32>,
pub brr: VCell<u32>,
pub cr1: VCell<u32>,
}
This struct can be placed at a hardware address, and every field access will be volatile. The #[repr(C)] attribute ensures the layout matches the hardware register map.
PACs: Where the Type System Starts Doing Real Work
Manually writing VCell structs for every peripheral on a microcontroller is impractical. ARM’s System View Description (SVD) format solves the source-of-truth problem: chip vendors publish XML files describing every peripheral, register, bitfield, reset value, and access permission for their microcontrollers.
svd2rust (currently 0.33.x) parses these SVD files and generates typed Rust crate. The result is a Peripheral Access Crate (PAC), and it changes what you can express:
// Generated PAC usage for an STM32F4 USART
let dp = stm32f4::stm32f405::Peripherals::take().unwrap();
dp.USART1.cr1.modify(|_r, w| {
w.ue().enabled()
.te().enabled()
.re().enabled()
});
let tx_ready = dp.USART1.sr.read().txe().bit_is_set();
dp.USART1.dr.write(|w| unsafe { w.bits(b'A' as u32) });
Several things are happening here that are impossible in C. The .modify() method performs a read-modify-write on CR1: it reads the current value, applies the closure’s modifications, and writes the result back. More importantly, the bitfield accessors like .ue().enabled() are generated from the SVD’s enumerated values. If the SVD marks a field as read-only, no .write() method is generated for it. Attempting to write a read-only register produces a compile error, not a silent no-op or a hardware fault.
The SVD’s access attributes (read-only, write-only, read-write) map directly onto which methods get generated. C CMSIS headers describe read-only fields in comments; Rust PACs enforce them in types.
The Singleton: The Genuinely New Idea
The most important line in the PAC example above is Peripherals::take(). It returns Option<Peripherals>, and the Option contains Some exactly once in the lifetime of the program. Every subsequent call returns None.
This is implemented with a static boolean and a critical section, but the effect is architectural: the type system now enforces the peripheral ownership rule that C embedded code enforces only by convention. Once you have called take(), you hold the only Peripherals in the program. When you destructure it into individual peripherals and hand USART1 to a UART driver, Rust’s ownership rules guarantee that no other code has a simultaneous mutable reference to USART1. You cannot accidentally configure USART1 from two places, because the compiler will not allow it.
In C, the equivalent guarantee requires that everyone agrees to only touch USART registers through a designated owner module. That agreement has no machine enforcement. In Rust, it is not an agreement; it is a type constraint.
HAL Crates and Portability
Between the PAC layer and application code sits the Hardware Abstraction Layer. HAL crates like stm32f4xx-hal, nrf52840-hal, and rp2040-hal take ownership of PAC peripheral structs and return safe, configured peripheral handles. Configuring a UART becomes a series of method calls on a builder, not a manual read-modify-write sequence on raw registers.
The embedded-hal crate defines the trait interface that makes this portable. Version 1.0 shipped in January 2024 after more than six years of iteration and broke the ecosystem’s long dependency on the 0.2.x series. The key traits cover SPI, I2C, UART, GPIO, delay, and ADC. A driver crate written against these traits runs on any microcontroller with a compliant HAL implementation:
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]]))
}
}
The same Bme280 driver compiles for an STM32, a Nordic nRF52840, a Raspberry Pi RP2040, or a simulated host environment. The hardware difference is entirely in the HAL implementation, not in the driver.
The 1.0 release also shipped embedded-hal-async, which provides async versions of the same traits. This integrates with the Embassy async executor to enable async/await firmware without a traditional RTOS. DMA transfers and multi-peripheral coordination that previously required hand-rolled state machines can now be expressed as straightforward async functions.
What Quarantined Unsafe Actually Means
The layering has a practical consequence that is easy to understate. In a typical embedded Rust project, unsafe code lives in the PAC (generated by svd2rust) and the low-level parts of the HAL that touch raw addresses. The driver crates above the HAL are safe Rust. Application code is safe Rust. The boundary between unsafe and safe is not a comment boundary or a module boundary enforced by convention; it is the type system, checked at compile time.
Compare this with C, where unsafe hardware access is syntactically identical to safe computation. A volatile dereference and an integer addition look the same. The compiler has no way to track which functions are “allowed” to touch hardware registers, because the type system has no concept of it.
The Embedded Rust Book describes this stack in detail, and the ecosystem around it has matured considerably since embedded-hal 1.0 landed. The Awesome Embedded Rust list catalogs PAC and HAL crates for hundreds of microcontrollers, and svd2rust 0.33 extended support beyond Cortex-M to RISC-V and Xtensa targets, which matters for ESP32 development.
The Ferrous Systems article frames this as a progression from raw unsafe access to high-level abstraction. That framing is correct, but the more precise description is: it is a progression from conventions that humans are asked to follow toward constraints that the compiler enforces without being asked.