Skip to content
VLSI Mentor

C Part II · Module 16 · Registers and Memory-Mapped I/O

Reading and Writing a Register in C

The canonical line has three decisions packed into it, and each one is load-bearing: the width comes from the pointer type and becomes the bus transaction, the volatile qualifier is what stops the access being deleted, and the integer-to-pointer conversion is a documented platform mechanism rather than a hack. This chapter measures all three — including what volatile actually preserves, in four numbers, and what happens when the qualifier is written in the other plausible position, which turns out to make the pointer volatile and leave the access unprotected.

Advanced22 min readCPart IIHardwareRegisters

Part II · Module 16 · Chapter 16.3 · Reading and Writing a Register in C

1. The Line, and Its Three Decisions

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
*(volatile uint32_t *)(BASE + OFFSET) = value;

Chapter 16.2 justified the (BASE + OFFSET) half. What remains is a cast with three separate decisions in it, none of which is stylistic:

  • uint32_t selects the access width, which becomes the width of the bus transaction
  • volatile is what stops the compiler deleting or relocating the access
  • the conversion from an integer to a pointer is implementation-defined, which means documented and usable rather than merely risky

Taking them in order.

2. The Width Is the Pointer Type

Chapter 16.1 §2 established that one assignment becomes one store at one width. The width comes from exactly one place:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void w8 (uint8_t  v) { *(volatile uint8_t  *)A = v; }
void w16(uint16_t v) { *(volatile uint16_t *)A = v; }
void w32(uint32_t v) { *(volatile uint32_t *)A = v; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
_w8:    strb  w0, [x8]
_w16:   strh  w0, [x8]
_w32:   str   w0, [x8]

Three different instructions, producing three different bus transactions, from three source lines that differ only in a type name.

This matters because peripherals care. A register documented as 32-bit may require a 32-bit access; an 8-bit write to it may update only one byte, may be ignored entirely, or may raise a bus fault, depending on the design. Measured against the model from Chapter 16.1, where CTRL accepts byte writes to its low byte:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CTRL after 32-bit write of 0x00081A3D = 0x00081a3d
CTRL after  8-bit write of 0xFF       = 0x00081aff   <- only the low byte changed

The byte write did something, and what it did was not what the caller intended. On a device that requires word access it would have done nothing at all, or faulted. Write the register at the width the datasheet specifies, which means the type in the cast is a transcription of the document rather than a choice.

A corollary worth stating: this is why the uint32_t family from Chapter 14.2 exists. int would compile and would be the right width on this machine today. uint32_t is 32 bits because the standard requires it, and a target where it is not provides no uint32_t at all rather than a 64-bit one.

3. volatile, and What It Actually Preserves

Chapter 16.1 §5 measured the motivating case: without volatile, a polling loop's load was hoisted out of the loop and narrowed to a byte. Here are the two properties behind that, isolated.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void read_vol(void)   { volatile uint32_t *p = (volatile uint32_t *)A; (void)*p; }
void read_plain(void) { uint32_t *p = (uint32_t *)A;                   (void)*p; }

void write_twice_vol(void)   { volatile uint32_t *p = (volatile uint32_t *)B; *p=1u; *p=1u; }
void write_twice_plain(void) { uint32_t *p = (uint32_t *)B;                   *p=1u; *p=1u; }

Load and store instructions emitted at -O2:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
read_vol           : 1 load/store
read_plain         : 0 load/store
write_twice_vol    : 2 load/store
write_twice_plain  : 1 load/store

Four numbers, and they are exactly the two things a register needs.

A read whose value is discarded still happens. read_plain emitted no load at all — the compiler saw the value was unused and removed the access. For ordinary memory that is correct and free. For DATA in Chapter 16.2, where reading pops the FIFO, the read is the operation and deleting it deletes the operation. Draining a FIFO by reading and discarding is an entirely normal thing to do, and without volatile it compiles to nothing.

Two writes of the same value both happen. write_twice_plain emitted one store — the first was redundant, since nothing could observe the intermediate state. For a transmit register where each write sends a byte, that is a dropped character. For a command register where each write triggers an action, it is a missed action.

Both deletions are correct about memory and wrong about a device, which is Chapter 16.1 §6's sentence arriving as generated code.

4. Where the Qualifier Goes

There are two plausible places to write volatile and only one of them does anything useful. This is worth measuring rather than asserting, because the wrong one is not merely ineffective — it is actively worse.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void a(void) { volatile uint32_t *p = (volatile uint32_t *)(uintptr_t)0x40021004u; *p = 1u; }
void b(void) { uint32_t *volatile p = (uint32_t *)(uintptr_t)0x40021004u;          *p = 1u; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
_a:
    mov   w8, #4100
    movk  w8, #16386, lsl #16
    mov   w9, #1
    str   w9, [x8]
    ret

_b:
    sub   sp, sp, #16
    mov   w8, #4100
    movk  w8, #16386, lsl #16
    str   x8, [sp, #8]        <- the POINTER is spilled to the stack
    ldr   x8, [sp, #8]        <- and reloaded, because IT is volatile
    mov   w9, #1
    ...

a is three instructions and one store. b allocates a stack frame, writes the pointer to memory and reads it back, and then does an ordinary unqualified store through it.

The reason is the ordinary declaration-reading rule from Chapter 7: the qualifier binds to what it is next to.

  • volatile uint32_t *p — pointer to volatile uint32_t. The object pointed at is volatile, so every access through p is preserved. This is what you want.
  • uint32_t *volatile p — volatile pointer to uint32_t. The pointer variable itself is volatile, so every access to p is preserved — hence the pointless spill and reload — while *p is an ordinary access the compiler may freely delete.

So the misplacement costs you a stack round-trip on every use and gives you none of the protection. Chapter 13.11 §3 made the general version of this point — never hide a pointer behind a typedef, because the qualifier then applies to the whole type — and this is the same rule meeting hardware.

Reading the declaration out loud is the check. "Pointer to volatile uint32_t" is right; "volatile pointer to uint32_t" is wrong. If you prefer, uint32_t volatile *p is identical to the first form and puts the qualifier where it cannot be misread.

5. The Conversion Is a Documented Mechanism

The cast from an integer to a pointer is not a loophole. Chapter 14.6 §4 established the classification and it is worth repeating in this setting because it is the licence for everything in this module:

OperationStatus
(volatile uint32_t *)(uintptr_t)0x40021004uimplementation-defined — C17 §6.3.2.3 ¶5
dereferencing it when misaligned for the typeundefined — §6.3.2.3 ¶7 (14.4)
dereferencing it when nothing is thereundefined

Implementation-defined means the implementation must choose a behaviour and document it, and you may rely on the documentation. Every embedded toolchain documents this conversion as the identity on a flat address space, which is exactly what memory-mapped I/O needs. The standard anticipated this use; it is not a hack that happens to work.

What remains undefined is the dereference, for two independent reasons — and only one of them is yours to fix. Alignment you control: a 32-bit register at a 4-byte-aligned offset, asserted as Chapter 16.2 §4 showed. Whether a device is actually there is settled by the address map, and nothing in the language can check it.

6. Putting It Together

The accessors that the rest of this module uses:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <stdint.h>

static inline uint32_t reg_read32(uintptr_t base, uint32_t offset) {
    return *(volatile uint32_t *)(base + offset);
}
static inline void reg_write32(uintptr_t base, uint32_t offset, uint32_t value) {
    *(volatile uint32_t *)(base + offset) = value;
}

Six lines, and they centralise every decision in this chapter: the width appears twice and nowhere else, volatile is in the one place it works, and the conversion happens in two functions rather than at every call site. Chapter 11.4 §8 established that static inline in a header costs nothing at any optimisation level above -O0, and the generated code for a call is the same single ldr or str measured in §2.

Two things that belong alongside them:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
_Static_assert(sizeof(uint32_t) == 4, "reg_read32 assumes a 32-bit word");
assert(offset % 4u == 0u);          /* 14.4: a misaligned typed pointer is UB */

And one that does not: do not give these functions a uint32_t * parameter instead of a base and an offset. The whole point of Chapter 16.2 §2 is that byte offsets must never touch a typed pointer, and a function signature is the right place to make that impossible.

7. The Misconception: volatile Makes the Access Safe

"I put volatile on it, so the access is safe."

It is preserved, which is a much narrower guarantee than safe, and the gap is where a specific class of bug lives.

volatile ensures the access happens, at the width you asked for, in the order you wrote it relative to other volatile accesses. That is all. In particular it does not provide:

Atomicity. A read-modify-write through a volatile pointer is still three separate operations — a load, some arithmetic, a store. An interrupt between the load and the store is not prevented by anything, and Chapter 16.5 measures the consequence.

Ordering against ordinary accesses. A volatile access is not a barrier for non-volatile memory operations around it.

Any cross-core or cross-device guarantee. volatile is a statement about what the compiler may do, not about what the processor's store buffer, the interconnect, or another core may do.

Those three are Chapter 17.5's subject — it exists because they are the three guarantees folklore most often attaches to volatile — and the mechanisms that actually provide ordering are Chapter 17.6's.

For this module, the working form of the correction is short: volatile makes the access reach the device. It does nothing about what happens between two accesses. Chapter 16.5 is entirely about what happens between two accesses.

8. What You Should Be Able to Say Now

  1. The canonical line has three decisions: width, volatile, and an implementation-defined conversion. None is stylistic.
  2. The pointer type is the instruction. Measured: uint8_t * → strb, uint16_t * → strh, uint32_t * → str. Transcribe the width from the datasheet.
  3. Measured: an 8-bit write to the model's 32-bit CTRL changed only the low byte — 0x00081a3d became 0x00081aff.
  4. volatile preserves two things a register needs, measured as load/store counts at -O2: a discarded read (1 against 0) and a repeated write (2 against 1).
  5. Both deletions are correct about memory — nothing can observe them — and wrong about a device, where the read pops a FIFO and the write sends a byte.
  6. volatile uint32_t *p is right; uint32_t *volatile p is wrong. Measured: the second spills the pointer to the stack and reloads it, then does an unqualified store. Read the declaration aloud.
  7. The integer-to-pointer conversion is implementation-defined, documented, and the intended mechanism. Only the dereference can be undefined — from misalignment, or from nothing being there.
  8. Centralise the access in static inline accessors taking a base and an offset, never a typed pointer.
  9. volatile means preserved, not safe. No atomicity, no ordering against ordinary accesses, no cross-core guarantee.

9. Exercises

  1. Write w8, w16 and w32 from §2 and record the three instructions on your machine. Then write the read versions and record those.
  2. Reproduce §3's four functions and count load/store instructions at -O2. Explain each of the four numbers in one sentence.
  3. Write read_plain so that the loaded value is used, and recount. Say what changed and why that does not rescue the general case.
  4. Compile §4's a and b and compare. Describe in words what b's stack traffic is for, and state what b fails to protect.
  5. Write the declaration for a pointer to a constant hardware address holding a volatile 32-bit register — the pointer itself never changes, the object does. Read it aloud and say which qualifier is where.
  6. Implement reg_read32 and reg_write32 from §6 and use them against the model from Chapter 16.1 to read ID and write CTRL. Add the alignment assertion and trigger it deliberately.
  7. For each of the three things volatile does not provide, write one sentence describing a concrete register bug that would result from assuming it does.

10. Summary

  • Three decisions in one line: width, volatile, and an implementation-defined conversion.
  • The pointer type selects the instruction — strb / strh / str, measured. Transcribe the width from the document.
  • Measured: an 8-bit write to a 32-bit register changed only the low byte (0x00081a3d → 0x00081aff).
  • volatile preserves a discarded read (1 vs 0 loads) and a repeated write (2 vs 1 stores) — the two properties a device needs.
  • volatile uint32_t *p qualifies the object; uint32_t *volatile p qualifies the pointer, costs a stack round-trip, and protects nothing.
  • Integer→pointer is implementation-defined and documented; only the dereference can be undefined.
  • Centralise in static inline accessors taking base + offset.
  • Preserved ≠ safe: no atomicity, no ordering against ordinary accesses, no cross-core guarantee (17.5).

An access now reaches the right register at the right width and survives the optimiser. What it carries is still a whole 32-bit word, and a datasheet almost never describes a register as a word — it describes it as a handful of named fields at stated positions, which is the skill Module 15 built against a plain variable and which now has to be applied to something that answers back.

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.