Memory Mapped Registers in Rust and Why PACs Exist
Memory mapped registers are fixed addresses you read and write with volatile access. See how a PAC generated by svd2rust wraps them safely, and how HALs build on top.

A microcontroller peripheral is a block of addresses. Writing a word to 0x4002_0014 might set a pin high, start a transfer or reset a timer. Rust cannot know that, so the lowest layer of embedded Rust is about making raw addresses safe to use. This post follows the layers up, using the structure of the svd2rust and embedded-hal ecosystems.
The raw version
use core::ptr::{read_volatile, write_volatile};
const GPIOA_ODR: *mut u32 = 0x4002_0014 as *mut u32;
fn set_pin5_high() {
unsafe {
let v = read_volatile(GPIOA_ODR);
write_volatile(GPIOA_ODR, v | (1 << 5));
}
}read_volatile and write_volatile tell the compiler not to elide, merge or reorder these accesses. An ordinary *ptr = v could be dropped as a dead store if nothing reads it later, and the pin would never change. The address comes from the chip's reference manual, and mistakes (wrong address, wrong width) are silent. This is the unsafe core of every embedded driver (five unsafe superpowers).
What a PAC gives you
Vendors publish SVD files describing every peripheral, register and bit field. svd2rust generates a crate from one. Using it looks like:
use stm32f4::stm32f411 as pac;
fn blink_setup() {
let dp = pac::Peripherals::take().unwrap();
dp.RCC.ahb1enr().modify(|_, w| w.gpioaen().set_bit());
dp.GPIOA.moder().modify(|_, w| w.moder5().output());
dp.GPIOA.odr().modify(|_, w| w.odr5().set_bit());
}Each of those calls compiles to the same load, modify and store as the raw code, but with named fields instead of magic numbers. modify reads the register, lets the closure change selected fields and writes the result back. write replaces the whole register with a value starting from its reset state. .gpioaen() and .moder5() exist only because the SVD defines those fields, and the enum-like .output() helper prevents writing an invalid bit pattern. The exact method style (dp.RCC.ahb1enr() versus a field access) changed in recent svd2rust major versions, so follow your PAC's docs.
The singleton
Peripherals::take() returns Some(peripherals) the first time and None after that. Ownership of a register block moves into whoever holds it, so the borrow checker, not discipline, prevents two drivers from configuring the same UART. Inside a critical section you can still reach for steal(), an unsafe escape hatch that bypasses the check (sharing state with interrupts).
HAL on top
A HAL (stm32f4xx-hal, rp2040-hal, embassy-stm32) consumes the PAC and exposes drivers:
use stm32f4xx_hal::{pac, prelude::*};
fn main_hal() {
let dp = pac::Peripherals::take().unwrap();
let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
led.set_high();
}into_push_pull_output changes the pin's type, so using a pin as an output before configuring it, or twice, becomes a compile error. This type-state design is the payoff of the layers. The HAL also implements embedded-hal traits so a sensor driver works across chips (portable drivers).
Choosing the layer
| Layer | You get | You give up |
|---|---|---|
| Raw pointers | nothing hidden | all safety and names |
| PAC | named registers and fields, singleton | knowing which writes are valid |
| HAL | drivers and type-state pins | features it has not implemented |
Use a HAL by default and the PAC for the gaps. Where addresses also appear in layout concerns, see alignment and padding. Linking RAM and flash regions to those addresses is covered in linker scripts.


