C Part II · Module 16 · Registers and Memory-Mapped I/O
How Software Reaches a Hardware Register
One assignment in C becomes one store instruction to one address, and from there it leaves the processor entirely — through a bus transaction, an address decoder, and a peripheral's register logic that may accept it, ignore it, or treat it as a command. This chapter traces that path end to end, then spends the rest of its length on the half that causes the bugs: a register is reached by exactly the same instruction as memory and behaves like memory in almost none of the ways that matter. It also measures what happens to a polling loop written without volatile, which turns out to be two separate defects rather than one.
Advanced23 min readCPart IIHardwareRegisters
Part II · Module 16 · Chapter 16.1 · How Software Reaches a Hardware Register
1. The Statement
Here is the whole of memory-mapped I/O, in one line:
*(volatile uint32_t *)(uintptr_t)(BASE + CTRL) = value;Chapter 14.6 built most of that line already — a base and a byte offset added as integers, converted once to a pointer, with the conversion recognised as implementation-defined rather than a hack. What is new is the volatile, which §5 motivates and Module 17 explains, and what is new is that something on the other end is listening.
The question this chapter answers is what happens between that semicolon and a flip-flop changing state.
2. The Software Half, Measured
Compile it and look:
#include <stdint.h>
#define BASE UINT32_C(0x40021000)
#define CTRL 0x04u
void write_ctrl(uint32_t v) {
*(volatile uint32_t *)(uintptr_t)(BASE + CTRL) = v;
}$ cc -std=c17 -O2 -S gen16.c -o -
_write_ctrl:
mov w8, #4100 ; =0x1004
movk w8, #16386, lsl #16 ; w8 = 0x40021004
str w0, [x8]
retTwo instructions to materialise the address 0x40021004 — arm64 builds a 32-bit constant from two 16-bit halves — and then one store. str is a 32-bit word store, because the pointer type was uint32_t *.
That is the entire software contribution, and it is worth noticing how little of it there is. Three facts leave the processor: an address, a width, and a direction, plus the data. Nothing else about your program is visible on the other side — not the variable's name, not the field you were setting, not whether you meant it.
The consequence runs through the whole module: the pointer type is not documentation, it is the instruction. A uint32_t * produces a 32-bit transaction and a uint8_t * produces an 8-bit one, and the peripheral may well distinguish them. Chapter 16.3 is about getting that right deliberately; Chapter 16.9 measures what the wrong choice looks like from the outside.
3. The Rest of the Path
Stage 3, the load/store unit. The processor does not know or care that 0x40021004 is a peripheral. It issues the same kind of transaction it would for a variable on the stack.
Stage 4, the interconnect. A decoder compares the address against the ranges assigned to each slave and routes the transaction to exactly one of them. This is why an address map exists and why a wrong base address does not fault — it quietly reaches a different peripheral, which is Chapter 16.9's first symptom.
Stage 5, the register logic. A write arrives carrying an address, a width and data, and what the RTL does with it is whatever the designer wrote. Storing the value is the common case and not the only one.
Stage 6. Flip-flops change, and the device does something different — a clock divider changes, a transmitter starts, an interrupt is deasserted.
4. Running It
Stages 3 to 6 are real hardware and cannot be executed on a laptop — Chapter 14.6 measured exactly that, with a datasheet address producing exit 139 and nothing else. So this module uses a software model of a peripheral: a small C file whose dev_read and dev_write implement each register's documented access type.
It is a model and it is labelled as one everywhere it appears. It is also precisely the artefact a verification engineer writes — a reference model of a device, in C, that a testbench can drive — so building one is not a teaching compromise.
#define DEV_BASE UINT32_C(0x40021000)
#define DEV_ID 0x00u /* RO : constant identity */
#define DEV_CTRL 0x04u /* RW : control */
#define DEV_STATUS 0x08u /* RO : live status, changes on its own */
#define DEV_IRQ 0x0Cu /* W1C : write 1 to clear a pending bit */
#define DEV_DATA 0x10u /* RC : reading POPS the receive FIFO */
#define DEV_TXD 0x14u /* WO : write-only; reads return 0 */ID = 0xa5c30001
CTRL = 0x00000008 (reset value; bit 3 is reserved and SET)
STATUS = 0x00000211 (rx_count 2)
DATA = 0x00000041 <- read POPS
DATA = 0x00000042 <- again
STATUS = 0x00000010 (fifo now empty)
IRQ = 0x00000005
after W1C of bit0: IRQ = 0x00000004Five behaviours in eight lines, and not one of them is how a variable works. Reading DATA twice gave two different values and changed STATUS as a side effect. Writing 1 to IRQ cleared a bit rather than setting it. CTRL comes out of reset with bit 3 set — the reserved bit from Chapter 15.1's running register, carried forward deliberately, and Chapter 15.4 already measured what a careless rebuild does to it.
Each of these is a chapter later in this module. What matters here is that they are all reached by the same ldr and str as a local variable.
5. Why volatile Is in the Line
The one-line rule is: every memory-mapped access goes through a volatile-qualified pointer. Here is the measurement that makes it a rule rather than a convention.
Two polling loops, identical except for one qualifier:
void wait_ready_volatile(void) {
while ((*(volatile uint32_t *)(uintptr_t)(BASE + STATUS) & 0x10u) == 0u) { }
}
void wait_ready_plain(void) {
while ((*(uint32_t *)(uintptr_t)(BASE + STATUS) & 0x10u) == 0u) { }
}At -O2, with the compiler's own loop annotations left in:
_wait_ready_volatile:
mov w8, #4104 ; =0x1008
movk w8, #16386, lsl #16
LBB2_1: ; =>This Inner Loop Header: Depth=1
ldr w9, [x8]
tbz w9, #4, LBB2_1
ret
_wait_ready_plain:
mov w8, #4104 ; =0x1008
movk w8, #16386, lsl #16
ldrb w8, [x8]
tbnz w8, #4, LBB3_2
LBB3_1: ; =>This Inner Loop Header: Depth=1
b LBB3_1
LBB3_2:
retThe volatile version reloads inside the loop, exactly as written.
The plain version has two separate defects, and it is worth separating them because they have different consequences.
The load was hoisted out of the loop. Look at LBB3_1: the loop body is b LBB3_1 — an unconditional branch to itself, containing no memory access at all. The register is read once, before the loop. If bit 4 is clear at that moment, the program spins forever and never looks at the device again. Not slowly, not intermittently — forever, with the status bit visibly set in a debugger.
The access width changed. ldr became ldrb. The compiler saw that only bit 4 of the loaded value was used, so it narrowed a 32-bit load to an 8-bit one. For ordinary memory that is a free and correct optimisation. For a peripheral that requires 32-bit access — and many do — it is a protocol violation that produces a bus fault or a wrong value, and it is caused by a line of source that mentions no width at all.
Both are legitimate optimisations on an ordinary object, because the compiler is entitled to assume the value cannot change on its own and that a load has no effect worth preserving. Those two assumptions are precisely what a register breaks.
6. The Misconception: A Register Is Memory at a Funny Address
"Memory-mapped means it is memory. The address is unusual; the rules are the same."
The belief is reasonable because the instruction is identical. Stage 2 of Figure 1 emits an str for a register and an str for a local variable, and nothing distinguishes them. That is the entire point of memory-mapped I/O: the processor needs no special instructions, no I/O port space, no separate addressing mode.
What differs is everything on the other end.
The useful way to hold it is not "a register is not memory" — that is true and unhelpful. It is:
Memory is a place that stores. A register is a device that responds. The instruction is the same; the thing on the other end is not.
Every chapter in this module is a consequence of that sentence, and so is the entire justification for volatile: the optimiser's model of memory is the left-hand column of Figure 2, and it is correct about memory.
7. What You Should Be Able to Say Now
- One assignment becomes one store at one width to one address. Measured:
str w0, [x8]withx8=0x40021004. Three facts leave the processor — address, width, direction — plus data. - The pointer type is the instruction, not documentation.
uint32_t *produces a 32-bit transaction. - The path has six stages and only the first two are visible to C. A wrong base address does not fault; it reaches a different peripheral.
- Real MMIO cannot be run here (14.6 measured
exit 139), so this module uses an executable peripheral model whose registers implement their documented access types. - Measured on that model: reading
DATAtwice gave two different values and changedSTATUS; writing1toIRQcleared a bit;CTRLcame out of reset with reserved bit 3 set. volatileon every memory-mapped access — and the measurement behind the rule is two separate defects: the load was hoisted out of the loop (body becameb LBB3_1, no access at all) and narrowed fromldrtoldrb.- Both are valid optimisations on ordinary memory, because a value cannot change on its own and a load has no effect. A register breaks both assumptions.
volatileis not atomicity and not synchronisation — stated now, explained in 17.5.- Memory stores; a register responds. The instruction is identical, which is why the difference must be carried by the programmer.
8. Exercises
- Compile §2's
write_ctrlon your machine at-O2 -Sand identify the store instruction and how the address constant is built. If your target is not arm64, report what it emitted. - Change the pointer type in
write_ctrltouint8_t *and touint16_t *, and record the instruction in each case. State the rule you have just confirmed in one sentence. - Reproduce §5's two loops and confirm the hoist. Then explain, in terms of Figure 2, which two assumptions the compiler made and why each is correct for ordinary memory.
- In
wait_ready_plain, the compiler emittedldrbrather thanldr. Explain what information let it narrow the access, and why that is a second defect rather than a detail of the first. - Write the six stages of Figure 1 for a read rather than a write. Which stage changes most, and which register from §4's model would make stage 5 interesting?
- For each row of Figure 2, name the register in §4's model that demonstrates it. One row has no demonstration there — identify it and say which chapter owns it.
- A colleague says their device "ignores" a setting they write. Using Figure 1, list the stages where that symptom could originate, in the order you would check them, and justify the order.
9. Summary
- One assignment → one store, one width, one address. Measured
str w0, [x8],x8 = 0x40021004. - The pointer type chooses the instruction and therefore the bus transaction's width.
- Six stages; C sees two. A wrong base reaches a different peripheral rather than faulting.
- This module runs against an executable peripheral model, because real MMIO on this machine is
exit 139(14.6). - Measured on it: a read that pops a FIFO, a write of 1 that clears a bit, a reset value with a reserved bit set.
volatileon every memory-mapped access. Without it, measured: the load was hoisted out of the loop and narrowed to a byte. Two defects, one omission.- Memory stores; a register responds. Same instruction, different thing on the other end.
The store in §2 went to 0x40021004, and that number was built from a base and an offset that this chapter simply asserted. Getting it right is not difficult and it is the single most common way register code fails, because every way of getting it wrong produces a perfectly valid address that reaches something else.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
