Skip to content
rusthacks.dev
Systems

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.

3 min read
Summarize in AI
Flat illustration for the post “Memory Mapped Registers in Rust and Why PACs Exist”, composed around memory.

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

rust
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:

rust
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:

rust
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

LayerYou getYou give up
Raw pointersnothing hiddenall safety and names
PACnamed registers and fields, singletonknowing which writes are valid
HALdrivers and type-state pinsfeatures 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.

Taggedpac
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.