Memory-Mapped I/O and Hardware Registers
On a microcontroller, hardware is an address. Writing to 0x4000C000 transmits a character; reading it receives one. That is the entire interface — and it is also where volatile stops being a curiosity from week 38 and becomes the difference between a working driver and one that hangs.
- Read a reference manual's register table and turn it into correct C.
- Explain precisely what the compiler does to register access without
volatile. - Choose between macro and struct-overlay styles for register definitions.
- Write the read-modify-write sequence correctly.
- Implement a working UART driver against an emulated peripheral.
1The memory map
A microcontroller's address space is divided between flash, RAM, and peripherals. A peripheral occupies a block of addresses whose bits are wired to hardware rather than to memory cells.
| Region | Typical range | Contains |
|---|---|---|
| Flash | 0x00000000– | Code and constants |
| RAM | 0x20000000– | Variables, stack |
| Peripherals | 0x40000000– | UART, GPIO, timers, ADC |
| Core peripherals | 0xE0000000– | Interrupt controller, systick |
Three properties distinguish a register from a variable, and each has a consequence in C:
- It can change without your code writing it. A received byte appears; a timer counts. The compiler cannot know this.
- Reading can have an effect. On many UARTs, reading the data register removes the byte from the receive FIFO. A "redundant" read is not redundant.
- Writing can have an effect beyond storing. A bit may be write-one-to-clear, or trigger a transmission.
Every one of those violates an assumption the optimizer is otherwise entitled to make.
2Why volatile is mandatory
#define UART_FR (*(uint32_t *)0x4000C018) /* MISSING volatile */
while (UART_FR & (1u << 5)) { } /* wait while FIFO full */The compiler reasons: the loop body is empty, nothing in it modifies UART_FR, so the value cannot change. It loads the register once into a register and tests that copy forever. At -O0 this often works by accident; at -O2 the program hangs on the first full FIFO and never recovers.
#define UART_FR (*(volatile uint32_t *)0x4000C018) /* correct */With volatile, every read in the source is a read of memory, every write is a write, and their order is preserved. That is exactly the guarantee hardware needs — and exactly what week 38 said volatile does and does not provide.
Without volatile, the compiler may | Consequence |
|---|---|
| Hoist a read out of a loop | Polling never terminates |
| Delete a write whose value is overwritten | A command never reaches the device |
| Merge two reads into one | Only one byte is taken from a FIFO |
| Reorder accesses to different registers | Enable happens after use |
volatile is necessary, not sufficient. It orders accesses as the compiler sees them; it does not prevent the processor reordering them, which matters on a chip with a write buffer or out-of-order execution. Cortex-M is mostly strongly ordered, but where a peripheral requires a completed write before the next access, a memory barrier — __DSB() — is required in addition.
3Two styles of register definition
Macros
#define UART0_BASE 0x4000C000u
#define UART_DR (*(volatile uint32_t *)(UART0_BASE + 0x000))
#define UART_FR (*(volatile uint32_t *)(UART0_BASE + 0x018))
#define UART_CTL (*(volatile uint32_t *)(UART0_BASE + 0x030))
#define UART_FR_RXFE (1u << 4) /* receive FIFO empty */
#define UART_FR_TXFF (1u << 5) /* transmit FIFO full */Simple, and what most vendor headers use. The drawbacks are week 28's: no type checking, no scope, and invisible in a debugger.
Struct overlay
typedef struct {
volatile uint32_t DR; /* 0x000 data */
volatile uint32_t RSR; /* 0x004 receive status*/
uint32_t reserved0[4]; /* 0x008-0x014 gap */
volatile uint32_t FR; /* 0x018 flags */
uint32_t reserved1; /* 0x01C */
volatile uint32_t ILPR; /* 0x020 */
volatile uint32_t IBRD; /* 0x024 baud integer */
volatile uint32_t FBRD; /* 0x028 baud fraction */
volatile uint32_t LCRH; /* 0x02C line control */
volatile uint32_t CTL; /* 0x030 control */
} UART_Type;
#define UART0 ((UART_Type *)0x4000C000u)
UART0->DR = 'A';
while (UART0->FR & UART_FR_TXFF) { }This is what CMSIS and every modern vendor header do. It is type-checked, groups related registers, and shows up in a debugger as a structure whose fields you can inspect. The reserved members are load-bearing: they place each register at the offset the manual specifies, and week 22's padding rules are why they must be written explicitly rather than assumed.
Note also that volatile goes on each member, not on the pointer. volatile UART_Type * would also work, but marking the members documents that every one of them is hardware.
4Reading a reference manual
A register is documented as a table of bit fields:
UARTFR — UART Flag Register offset 0x018, reset 0x90
Bit Name Type Reset Description
7 TXFE RO 1 Transmit FIFO empty
6 RXFF RO 0 Receive FIFO full
5 TXFF RO 0 Transmit FIFO full
4 RXFE RO 1 Receive FIFO empty
3 BUSY RO 0 UART busy
2:0 — RO 0 Reserved| Type | Means |
|---|---|
| RO | Read only; writes ignored |
| WO | Write only; reads undefined |
| RW | Read and write |
| W1C | Write one to clear — writing 0 does nothing |
| RC | Cleared by reading |
Two of those deserve care. W1C inverts the usual read-modify-write: to clear a flag you write a 1 to it, and the read-modify-write idiom below would clear every other pending flag as a side effect — so W1C registers are written directly with just the bit you mean.
Reserved bits must usually be preserved. Writing zeros to them can change undocumented behavior, which is the other reason read-modify-write exists.
/* Read-modify-write: change one field, preserve the rest. */
uint32_t v = UART0->LCRH; /* read */
v &= ~LCRH_WLEN_MASK; /* clear the field */
v |= LCRH_WLEN_8; /* set the new value */
UART0->LCRH = v; /* write */
/* Write-one-to-clear: do NOT read-modify-write. */
UART0->ICR = ICR_RXIC; /* clears only this flag */5Worked example: a UART driver, and volatile removed
Building on week 49's QEMU setup. Same linker script and startup code; only main.c changes.
/* uart.c — a driver written against the register map */
#include <stdint.h>
#include <stdbool.h>
/* ---------- register overlay ---------- */
typedef struct {
volatile uint32_t DR; /* 0x000 data */
volatile uint32_t RSR_ECR; /* 0x004 receive status */
uint32_t reserved0[4];
volatile uint32_t FR; /* 0x018 flags */
uint32_t reserved1;
volatile uint32_t ILPR; /* 0x020 */
volatile uint32_t IBRD; /* 0x024 baud, integer part */
volatile uint32_t FBRD; /* 0x028 baud, fraction */
volatile uint32_t LCRH; /* 0x02C line control */
volatile uint32_t CTL; /* 0x030 control */
volatile uint32_t IFLS; /* 0x034 */
volatile uint32_t IM; /* 0x038 interrupt mask */
volatile uint32_t RIS; /* 0x03C raw interrupt status*/
volatile uint32_t MIS; /* 0x040 masked status */
volatile uint32_t ICR; /* 0x044 interrupt clear W1C */
} UART_Type;
#define UART0 ((UART_Type *)0x4000C000u)
/* Flag register bits, from the manual's table. */
#define FR_BUSY (1u << 3)
#define FR_RXFE (1u << 4) /* receive FIFO empty */
#define FR_TXFF (1u << 5) /* transmit FIFO full */
/* Line control bits. */
#define LCRH_FEN (1u << 4)
#define LCRH_WLEN_MASK (3u << 5)
#define LCRH_WLEN_8 (3u << 5)
/* Control bits. */
#define CTL_UARTEN (1u << 0)
#define CTL_TXE (1u << 8)
#define CTL_RXE (1u << 9)
void uart_init(void)
{
UART0->CTL = 0; /* disable before configuring */
UART0->IBRD = 10; /* 115200 baud at 18.432 MHz */
UART0->FBRD = 54;
/* Read-modify-write: set the word length, preserve everything else. */
uint32_t lcrh = UART0->LCRH;
lcrh &= ~LCRH_WLEN_MASK;
lcrh |= LCRH_WLEN_8 | LCRH_FEN;
UART0->LCRH = lcrh;
UART0->CTL = CTL_UARTEN | CTL_TXE | CTL_RXE; /* enable last */
}
void uart_putc(char c)
{
while (UART0->FR & FR_TXFF) { } /* poll: needs volatile */
UART0->DR = (uint32_t)(unsigned char)c;
}
bool uart_getc(char *out)
{
if (UART0->FR & FR_RXFE) {
return false; /* nothing waiting */
}
*out = (char)(UART0->DR & 0xFFu); /* reading REMOVES it from the FIFO */
return true;
}
void uart_flush(void)
{
while (UART0->FR & FR_BUSY) { } /* wait for the shift register */
}
void uart_puts(const char *s)
{
for (; *s != '\0'; s++) {
if (*s == '\n') uart_putc('\r');
uart_putc(*s);
}
}/* main.c */
#include <stdint.h>
#include <stdbool.h>
void uart_init(void);
void uart_putc(char c);
bool uart_getc(char *out);
void uart_puts(const char *s);
void uart_flush(void);
static void put_hex(uint32_t v)
{
static const char d[] = "0123456789ABCDEF";
uart_puts("0x");
for (int s = 28; s >= 0; s -= 4) uart_putc(d[(v >> s) & 0xFu]);
}
/* Deliberately WITHOUT volatile, to be inspected in the disassembly. */
#define UART_FR_UNSAFE (*(uint32_t *)0x4000C018u)
static void demonstrate_without_volatile(void)
{
uart_puts("reading FR without volatile three times:\n");
uint32_t a = UART_FR_UNSAFE;
uint32_t b = UART_FR_UNSAFE;
uint32_t c = UART_FR_UNSAFE;
uart_puts(" "); put_hex(a);
uart_puts(" "); put_hex(b);
uart_puts(" "); put_hex(c);
uart_puts("\n disassemble this function: at -O2 there is likely\n");
uart_puts(" ONE load, not three\n");
}
int main(void)
{
uart_init();
uart_puts("\n=== memory-mapped I/O ===\n");
uart_puts("UART base : "); put_hex(0x4000C000u); uart_puts("\n");
uart_puts("FR offset : 0x018\n");
uart_puts("FR value : "); put_hex(*(volatile uint32_t *)0x4000C018u);
uart_puts("\n bit 4 RXFE set means the receive FIFO is empty\n");
uart_puts(" bit 7 TXFE set means the transmit FIFO is empty\n\n");
demonstrate_without_volatile();
uart_puts("\ntype characters; they are echoed. 'q' quits to the loop.\n");
for (;;) {
char c;
if (uart_getc(&c)) {
if (c == 'q') {
uart_puts("\nbye\n");
break;
}
uart_puts("got '");
uart_putc(c);
uart_puts("' = ");
put_hex((uint32_t)(unsigned char)c);
uart_puts("\n");
}
}
uart_flush();
for (;;) { }
}arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb \
-ffreestanding -nostdlib -O2 -Wall -Wextra -g \
-T firmware.ld -o firmware.elf startup.c uart.c main.c
qemu-system-arm -M lm3s6965evb -nographic -kernel firmware.elfType characters and watch them echoed with their hex codes. Quit with q, then Ctrl-A X.
Prove that volatile changes the generated code
This is the experiment that makes the rule concrete rather than memorized.
arm-none-eabi-objdump -d firmware.elf \
| sed -n '/<demonstrate_without_volatile>:/,/^$/p'Count the load instructions. Three source-level reads of the same address typically produce one ldr, with the result reused — the compiler correctly concluded that a non-volatile object cannot change between reads. On real hardware that means a receive loop takes one byte and then believes the FIFO is permanently in whatever state it saw first.
Now add volatile to the macro, rebuild, and disassemble again: three loads, in order.
Break the polling loop
Edit uart_putc to read a non-volatile copy:
void uart_putc(char c)
{
uint32_t *fr = (uint32_t *)0x4000C018u; /* no volatile */
while (*fr & FR_TXFF) { }
UART0->DR = (uint32_t)(unsigned char)c;
}arm-none-eabi-gcc … -O0 … && qemu-system-arm … # usually still works
arm-none-eabi-gcc … -O2 … && qemu-system-arm … # hangs, or garbles outputAt -O0 every access goes to memory anyway, so the bug is invisible. At -O2 the load is hoisted out of the loop and the driver waits forever on a stale value. This is the single most common embedded bug, and the reason it is so confusing is that the debug build is fine.
Inspect the peripheral from the debugger
# terminal 1
qemu-system-arm -M lm3s6965evb -nographic -kernel firmware.elf -S -s
# terminal 2
gdb-multiarch firmware.elf
(gdb) target remote :1234
(gdb) break uart_putc
(gdb) continue
(gdb) print/x *(UART_Type *)0x4000C000 # the whole peripheral
(gdb) print/x UART0->FR
(gdb) print/t UART0->FR # binary: read the bits
(gdb) set var UART0->DR = 0x41 # write 'A' by hand
The struct overlay pays for itself here: GDB prints the peripheral as a named structure rather than a block of hex. Setting DR from the debugger transmits a character — the hardware responds to a write from any source, which is the clearest possible demonstration that a register is not a variable.
Check the offsets are right
An overlay with a wrong reserved array silently addresses the wrong registers. Verify it rather than trusting it:
/* add to main, temporarily */
uart_puts("offsetof FR : "); put_hex((uint32_t)offsetof(UART_Type, FR));
uart_puts("\noffsetof CTL : "); put_hex((uint32_t)offsetof(UART_Type, CTL));
uart_puts("\nsizeof : "); put_hex((uint32_t)sizeof(UART_Type));FR must be 0x018 and CTL 0x030, exactly as the manual states. Week 22's offsetof, used as a correctness check rather than a curiosity — and a static_assert on each offset, as week 27 suggested, turns this into something the compiler verifies on every build.
6Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Omitting volatile | Reads hoisted; polling hangs at -O2 | Every register access is volatile. |
| Read-modify-write on a W1C register | Clears every other pending flag | Write only the bit you mean. |
| Writing zeros to reserved bits | Undocumented behavior changes | Preserve them: read-modify-write. |
Wrong reserved padding in an overlay | Every register after the gap is wrong | Verify with offsetof and static_assert. |
| Reading a data register twice | Loses a byte — the read is destructive | Read once into a variable. |
| Configuring after enabling | The peripheral runs with old settings | Disable, configure, enable. |
Testing only at -O0 | The bug appears in the release build | Test at the optimization level you ship. |
Assuming volatile orders hardware | Reordering by the processor | Add a barrier where the manual requires one. |
7Check yourself
Why does a polling loop without volatile hang at -O2 but work at -O0?
Because at -O0 every access goes to memory anyway, while at -O2 the compiler sees that nothing in the empty loop body modifies the object and hoists the load out, testing a cached copy forever. volatile tells it the object can change by means it cannot see, so the load must stay inside the loop.
Why must a write-one-to-clear register not be read-modify-written?
Because the read returns every pending flag, and writing that value back writes a 1 to each of them — clearing flags you never intended to acknowledge. W1C registers are written directly with only the bit being cleared; the usual preserve-the-others reasoning is exactly backwards for them.
What are the reserved members in a register overlay for?
To place each subsequent register at the byte offset the reference manual specifies. The compiler lays members out consecutively with padding rules of its own, so gaps in the hardware map must be declared explicitly. Getting one wrong silently shifts every register after it, and offsetof with static_assert is how you prove it is right.
Why can reading a register twice be a bug?
Because reads can have side effects. On many UARTs, reading the data register removes a byte from the receive FIFO, so a second read consumes a second byte or returns garbage. Registers are not variables: read once into a local and use that.
Is volatile enough to guarantee hardware ordering?
No. It constrains the compiler, not the processor. On a core with a write buffer or out-of-order execution, accesses can still be reordered or delayed relative to what the device requires. Where the manual demands a completed write before the next access, a memory barrier such as __DSB() is needed in addition.
8Where this leads
Polling a flag in a loop wastes the whole processor waiting. Week 51 replaces it with interrupts — the hardware tells you when it is ready — which brings back week 39's async-signal-safety problem in a harsher form, because an ISR can interrupt any instruction and volatile sig_atomic_t is no longer sufficient.