Procedural Programming with C · Professional · Week 50

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.

By the end of this week you can
  • 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.

RegionTypical rangeContains
Flash0x00000000Code and constants
RAM0x20000000Variables, stack
Peripherals0x40000000UART, GPIO, timers, ADC
Core peripherals0xE0000000Interrupt 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 mayConsequence
Hoist a read out of a loopPolling never terminates
Delete a write whose value is overwrittenA command never reaches the device
Merge two reads into oneOnly one byte is taken from a FIFO
Reorder accesses to different registersEnable 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
TypeMeans
RORead only; writes ignored
WOWrite only; reads undefined
RWRead and write
W1CWrite one to clear — writing 0 does nothing
RCCleared 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.elf

Type 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 output

At -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

MistakeWhat happensFix
Omitting volatileReads hoisted; polling hangs at -O2Every register access is volatile.
Read-modify-write on a W1C registerClears every other pending flagWrite only the bit you mean.
Writing zeros to reserved bitsUndocumented behavior changesPreserve them: read-modify-write.
Wrong reserved padding in an overlayEvery register after the gap is wrongVerify with offsetof and static_assert.
Reading a data register twiceLoses a byte — the read is destructiveRead once into a variable.
Configuring after enablingThe peripheral runs with old settingsDisable, configure, enable.
Testing only at -O0The bug appears in the release buildTest at the optimization level you ship.
Assuming volatile orders hardwareReordering by the processorAdd 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.