Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
*(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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#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;
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -O2 -S gen16.c -o -

_write_ctrl:
    mov   w8, #4100                 ; =0x1004
    movk  w8, #16386, lsl #16       ; w8 = 0x40021004
    str   w0, [x8]
    ret

Two 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

Six stages of a single register write. First, the C statement assigns a value through a volatile pointer to an address computed from a base and an offset. Second, the compiler emits one store instruction at one width; measured here as str w0 into the address 0x40021004. Third, the processor's load/store unit issues a bus transaction carrying an address, a width, a direction and the data. Fourth, the interconnect decodes the address and routes the transaction to whichever peripheral owns that range. Fifth, the peripheral's register logic accepts the write according to that register's access type, which may be to store it, to ignore it, or to clear bits. Sixth, hardware behaviour changes: flip-flops take new values and the device does something different. Only the first two stages are visible to C, which is the reason the rest of this module exists.One store, from C to RTL state01THE C STATEMENT*(volatile uint32_t *)(BASE + OFFSET) = value02ONE STORE INSTRUCTION, ONE WIDTHmeasured: str w0, [x8] with x8 = 0x4002100403THE LOAD/STORE UNIT ISSUES A TRANSACTIONaddress · width · direction · data04THE INTERCONNECT DECODES THE ADDRESSroutes it to whichever peripheral owns that range05REGISTER LOGIC ACCEPTS IT — OR DOES NOTper the access type: store, ignore, or clear bits06HARDWARE STATE CHANGESflip-flops take new values; the device behaves differently
Figure 1 — the six stages one register write passes through. Stages 1 and 2 are the only ones C can see, and stage 2 is the last one anything in your source file influences; everything after it is determined by the address, the width and the direction that left the processor. Stage 5 is where this module spends most of its time, because what the register logic does with a write is a property of that particular register rather than of the bus — it may store the value, ignore it entirely, or treat the bits as a command to clear something. The reason the chapter traces all six is that bugs get diagnosed at the wrong stage: a value that did not take effect is almost always stage 1 or 5 and almost never stages 3 and 4, which are the ones people suspect first because they are the unfamiliar part.

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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#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        */
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 = 0x00000004

Five 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
_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:
    ret

The 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.

Six properties that memory has and a hardware register does not, which is why a register looks like memory to software and behaves differently. Reading twice: memory gives the same value both times, while a status register may differ because the hardware changed it. Writing then reading: memory gives back what was written, while a register may not, because it may be read-only, write-only, or may latch something else. Reading with no other access: memory is unaffected, while a data register may pop a FIFO, so the read itself is the action. Writing a value already present: memory is a no-op, while a register write may trigger a transmission or clear a flag. Access width: memory accepts any supported width, while a register may require exactly one. Eliding an access: the compiler may remove a redundant memory access safely, but removing a register access removes the hardware effect.What you doOrdinary memoryA hardware registerRead it twicethe same valuenothing else writes itmay differthe hardware changed itWrite, then readwhat you wrotemaybe notread-only · write-only ·latchedJust read itno effectmay pop a FIFOthe read IS the actionWrite the value alreadytherea no-opmay transmit or clearChoose an access widthany supported widthmay require exactly oneLet the compiler elideitsafethe value is still thereremoves the effectmeasured in section 512
Figure 2 — six things you can do to a location, and what each means for memory as against a register. Every row in the right-hand column was measured on the model in section 4 or on generated code in section 5. The reason to read this as a table rather than a list is the pattern it exposes: ordinary memory is passive and a register is a participant. Memory stores what you give it and answers with what it stored; a register may answer with something the hardware put there, may act on being read, and may treat a write as a command rather than as data. The last row is the one with no equivalent on the left at all — eliding a redundant access to memory is free and correct, while eliding an access to a register deletes the thing the line of code existed to do.

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

  1. One assignment becomes one store at one width to one address. Measured: str w0, [x8] with x8 = 0x40021004. Three facts leave the processor — address, width, direction — plus data.
  2. The pointer type is the instruction, not documentation. uint32_t * produces a 32-bit transaction.
  3. 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.
  4. 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.
  5. Measured on that model: reading DATA twice gave two different values and changed STATUS; writing 1 to IRQ cleared a bit; CTRL came out of reset with reserved bit 3 set.
  6. volatile on every memory-mapped access — and the measurement behind the rule is two separate defects: the load was hoisted out of the loop (body became b LBB3_1, no access at all) and narrowed from ldr to ldrb.
  7. 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.
  8. volatile is not atomicity and not synchronisation — stated now, explained in 17.5.
  9. Memory stores; a register responds. The instruction is identical, which is why the difference must be carried by the programmer.

8. Exercises

  1. Compile §2's write_ctrl on your machine at -O2 -S and identify the store instruction and how the address constant is built. If your target is not arm64, report what it emitted.
  2. Change the pointer type in write_ctrl to uint8_t * and to uint16_t *, and record the instruction in each case. State the rule you have just confirmed in one sentence.
  3. 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.
  4. In wait_ready_plain, the compiler emitted ldrb rather than ldr. Explain what information let it narrow the access, and why that is a second defect rather than a detail of the first.
  5. 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?
  6. 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.
  7. 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.
  • volatile on 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.