CodeRigor

FREE LESSON

Memory-mapped I/O and volatile

Talk to hardware through memory-mapped registers: what volatile guarantees (and doesn't), register structs, read-modify-write and write-1-to-clear registers, why bitfields and hardware don't mix, and a polling UART driver.

On a microcontroller, peripherals aren't reached through system calls or special I/O instructions: each one is a block of registers at fixed addresses, and you read and write them with ordinary loads and stores. Writing a byte to address 0x4000_4000 sends it out of UART0; reading 0x4000_0004 gives the current count of TIMER0. That's memory-mapped I/O (MMIO), and the one C++ keyword that makes it work is volatile.

1. Registers are not memory

A RAM location keeps what you last wrote. A register doesn't have to:

UART0 at 0x4000_4000: five registers, five behaviours
Each 4-byte slot is a register, not storage. Hover one to see what a read or a write actually does.
UART0 at 0x4000_4000: five registers, five behaviours
Each 4-byte slot is a register, not storage. Hover one to see what a read or a write actually does.
  • Writing DATA twice sends two bytes; reading it twice may return two different received bytes.
  • STATE changes on its own as the hardware sends and receives.
  • Writing INTSTATUS doesn't store anything: each 1 bit clears a pending interrupt.

The compiler knows none of this. To the optimiser, *p = 1; *p = 0; is one store (the first is dead), while (*state & 1) {} reads *state once, and a store nobody reads back can be deleted. volatile tells the compiler that every access is an observable side effect: it must perform each read and write the program performs, in program order, with the declared width.

Here is the difference at -O2 for Cortex-M4:

Assembly
twice(uint32_t* r)              twice(volatile uint32_t* r)
    movs  r3, #0                    movs  r2, #1
    str   r3, [r0]                  movs  r3, #0
    bx    lr                        str   r2, [r0]     @ the 1 really goes out
                                    str   r3, [r0]
                                    bx    lr

poll(uint32_t* s)               poll(volatile uint32_t* s)
    bx    lr                    .L9: ldr  r3, [r0]     @ re-read every time
                                     lsls r3, r3, #31  @ bit 0 → N flag
                                     bmi  .L9
                                     bx   lr

The non-volatile poll vanished entirely: a loop with no side effects is assumed to terminate (C++'s forward-progress rule), and since it can't change anything, it's removed. That's the classic "my driver works at -O0 and hangs/does nothing at -O2" bug.

2. What volatile does not do

volatile is about the compiler not optimising the accesses away. It gives no other guarantee:

  • Not atomic. *counter = *counter + 1; on a volatile is still ldr/adds/str — an interrupt between the load and the store loses an update. (Critical sections and atomics come in Level 17.)
  • Not ordered against non-volatile memory. The compiler may move ordinary loads and stores across a volatile access. Writing a buffer and then a volatile "go" register doesn't guarantee the buffer writes happen first — a DMA engine could read stale data. You need a compiler barrier (asm volatile("" ::: "memory")) or std::atomic_signal_fence, plus a dmb on cores with write buffers.
  • Not a tearing guarantee for wide types. A volatile uint64_t on a 32-bit core is two 32-bit accesses.
  • Not "thread-safe". It's for memory that changes outside the program's control (hardware, a signal handler's flag), not for sharing between threads.

3. Describing a peripheral: register structs

Scattering *reinterpret_cast<volatile uint32_t*>(0x40004008) through the code is unreadable. The usual pattern is a struct whose members are laid out exactly like the hardware, overlaid on the base address. CodeRigor's <crucible/mcu/mps2.hpp> does that for this board:

C++
namespace mps2 {
using reg = volatile std::uint32_t;

struct Uart {
    reg data;       // 0x00
    reg state;      // 0x04
    reg ctrl;       // 0x08
    reg intstatus;  // 0x0C
    reg bauddiv;    // 0x10
};
inline Uart& uart0() { return *reinterpret_cast<Uart*>(0x40004000); }
}

Every member is volatile, all are 4 bytes, so offsets fall out naturally (a static_assert(offsetof(Uart, bauddiv) == 0x10) in real code pins that down). Accessor functions keep the casts in one place. Taking the peripheral by reference (void init(mps2::Uart& u)) instead of reaching for the global also makes the driver testable: a test can pass a plain mps2::Uart living in RAM and inspect what the driver wrote.

C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

int main() {
    auto& u = mps2::uart0();
    u.bauddiv = 16;
    u.ctrl = 1;  // TX enable
    for (const char* p = "hello from UART0\n"; *p; ++p) {
        while (u.state & 1) {}  // TX buffer full: wait
        u.data = *p;
    }
    std::printf("bauddiv=%lu ctrl=%lu\n", (unsigned long)u.bauddiv, (unsigned long)u.ctrl);
}

(UART0's output and the semihosting printf both appear in the program's output here.)

4. Read-modify-write and its hazards

To change one bit you must read the register, change the bit, and write it back — three steps:

C++
u.ctrl = u.ctrl | 2;          // enable RX, keep the other bits
u.ctrl = u.ctrl & ~2u;        // disable RX

Three things go wrong with RMW on hardware:

  1. Forgetting it. u.ctrl = 2; enables RX — and disables TX and both interrupts. Always ask "what are the other bits of this register?"
  2. Interrupts in the middle. If an interrupt handler modifies the same register between the read and the write, its change is lost. Registers shared with handlers need a critical section (Level 17) — or hardware that offers separate set/clear registers, like the NVIC's ISER/ICER (write 1 to enable, write 1 to disable; zeros do nothing).
  3. Write-1-to-clear (W1C) registers. INTSTATUS reads back every pending interrupt; writing a 1 clears that one. So t.intstatus = t.intstatus | 1; reads all pending bits and writes them back — clearing interrupts you haven't handled yet. For W1C, write exactly the bits you mean: t.intstatus = 1;.
A read-modify-write interrupted
The handler's change to CTRL lands between main's read and main's write — and main's write puts the old bit back. Shared registers need a critical section.
A read-modify-write interrupted
The handler's change to CTRL lands between main's read and main's write — and main's write puts the old bit back. Shared registers need a critical section.
Write-1-to-clear: never OR into INTSTATUS
Two interrupts are pending and you've handled only bit 0. t.intstatus = 1 clears exactly that one; t.intstatus |= 1 reads both and writes both back — clearing bit 1 unhandled.
Write-1-to-clear: never OR into INTSTATUS
Two interrupts are pending and you've handled only bit 0. t.intstatus = 1 clears exactly that one; t.intstatus |= 1 reads both and writes both back — clearing bit 1 unhandled.

Fields wider than a bit need masking on both sides:

C++
// Set a 3-bit field at bits 4–6 to v.
r = (r & ~(0x7u << 4)) | ((v & 0x7u) << 4);
Setting a 3-bit field at bits 4–6 to v = 13
13 doesn't fit in 3 bits. With & 0x7 only 101 lands in the field; without it, the extra 1 spills into bit 7 — a neighbouring field.
Setting a 3-bit field at bits 4–6 to v = 13
13 doesn't fit in 3 bits. With & 0x7 only 101 lands in the field; without it, the extra 1 spills into bit 7 — a neighbouring field.

The & 0x7 matters: without it, a too-large v spills into bit 7 and up.

5. Why not bitfields?

It's tempting to describe CTRL as a struct of bitfields:

C++
struct Ctrl { std::uint32_t tx : 1, rx : 1, txirq : 1, rxirq : 1, : 28; };
volatile Ctrl& ctrl = *reinterpret_cast<volatile Ctrl*>(0x40004008);
ctrl.rx = 1;   // looks like one write…

It compiles to an RMW (ldr/orr/str) hidden behind syntax that looks like a single write, with all the hazards above, invisible at the call site. Worse, the standard leaves bitfield layout, padding, and access width implementation-defined: a compiler may touch a byte or a halfword of a register that only accepts 32-bit accesses (some peripherals fault or misbehave on narrow accesses). Masks and shifts on a full-width volatile uint32_t say exactly what happens. (The next skill builds type-safe, zero-cost field accessors on top of them.)

6. Polling drivers

The simplest driver polls: it loops reading a status register until the hardware is ready.

C++
void putc(mps2::Uart& u, char c) {
    while (u.state & 1) {}   // TX buffer full → wait
    u.data = static_cast<unsigned char>(c);
}

Polling is simple and predictable, but it burns CPU and, written like this, hangs forever if the hardware never becomes ready (unclocked peripheral, wrong address, a fault on the other end). Production drivers bound the wait — a spin count or a timer — and report the timeout. The build task asks for exactly that.

Timers are polled the same way. A CMSDK timer counts VALUE down from RELOAD while enabled:

C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

int main() {
    auto& t = mps2::timer0();
    t.reload = 1'000'000;
    t.value = 1'000'000;
    t.ctrl = 1;  // enable, no interrupt
    for (volatile int i = 0; i < 10'000; i = i + 1) {}
    std::uint32_t now = t.value;
    t.ctrl = 0;
    std::printf("elapsed ticks: %s\n", t.reload - now > 0 ? "some" : "none");
}

Elapsed time is reload - value (the counter goes down). On this board qemu runs with -icount, so the count is deterministic from run to run.

Summary

  • MMIO registers are memory addresses with side effects; access them through volatile so each read and write happens as written.
  • volatile gives no atomicity, no ordering against ordinary memory, no multi-word tearing protection.
  • Overlay register structs on base addresses; take peripherals by reference so drivers can be tested against RAM fakes.
  • Read-modify-write to change bits, mask fields on both sides, and never RMW a write-1-to-clear register.
  • Avoid bitfields for hardware registers; use full-width masks and shifts.
  • Polling loops must re-read a volatile, and should time out.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

How a CPU drives the outside world through memory-mapped registers — blinking an LED by writing an address.

volatile explained on a real MCU: why the compiler may remove or merge register accesses without it.

Dan Saks on modelling memory-mapped devices as C++ objects: placement, layout, volatile, initialisation.

Bit-wise operators for setting, clearing and testing register bits — and read-modify-write hazards.