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:
- Writing
DATAtwice sends two bytes; reading it twice may return two different received bytes. STATEchanges on its own as the hardware sends and receives.- Writing
INTSTATUSdoesn'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:
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 lrThe 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 stillldr/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")) orstd::atomic_signal_fence, plus admbon cores with write buffers. - Not a tearing guarantee for wide types. A
volatile uint64_ton 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:
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.
#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:
u.ctrl = u.ctrl | 2; // enable RX, keep the other bits
u.ctrl = u.ctrl & ~2u; // disable RXThree things go wrong with RMW on hardware:
- Forgetting it.
u.ctrl = 2;enables RX — and disables TX and both interrupts. Always ask "what are the other bits of this register?" - 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). - Write-1-to-clear (W1C) registers.
INTSTATUSreads back every pending interrupt; writing a 1 clears that one. Sot.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;.
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:
// Set a 3-bit field at bits 4–6 to v.
r = (r & ~(0x7u << 4)) | ((v & 0x7u) << 4);& 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:
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.
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:
#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
volatileso each read and write happens as written. volatilegives 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.