5.1.33 · D3C Programming

Worked examples — volatile keyword — preventing optimization of hardware registers

4,193 words19 min readBack to topic

This page belongs to the parent topic. Here we stop explaining and start doing. We take the theory and run it through every kind of situation the volatile keyword can land in — every optimization it disables, every pointer placement, the degenerate cases (loops that hang, reads that vanish), the limiting cases (-O0 vs -O2), a real-world hardware problem, and an exam trap.

Before any example, two words we will use everywhere — defined now, at first use, so nothing is assumed:

One more promise: we never assume you remember a symbol. When you see [flag] in assembly it means "the memory slot named flag", and R means "a CPU register — a tiny scratchpad inside the processor that it can read far faster than RAM". A register is not the same as a memory location; the whole volatile story is about the compiler secretly copying memory into a register and then trusting the copy. Keep that picture:

Figure — volatile keyword — preventing optimization of hardware registers
Figure s01 — "Memory is the truth, the register is a copy." Left, a burnt-orange box is RAM, the real slot [flag]. Right, a teal box is a CPU register R, a fast copy. A plum arrow "load (copy once)" shows the compiler copying RAM into the register one time — that is an optimization. The dark return arrow "volatile: re-read" shows what volatile forces: go back to the orange box on every access. Pedagogy: if you only remember one image on this page, remember that optimization trusts the teal copy, and volatile says the copy is a lie.


The scenario matrix

Every cell below is a distinct thing that can go wrong or right. The examples that follow are labelled with the cell(s) they cover.

# Case class Concrete trigger Covered by
A Register-caching in a spin loop ISR sets a flag Ex 1
B Dead-store elimination write-only hardware register Ex 2
C Duplicate-read removal two FIFO reads collapsed to one Ex 3
D Reordering across accesses data written before "go" bit Ex 4
E Pointer placement — data volatile volatile int *p Ex 5
F Pointer placement — pointer volatile int * volatile p (the wrong slot) Ex 5
G const volatile — read-only register poll a status register you must not write Ex 6
H The -O0 vs -O2 limiting case same bug appears/disappears with flags Ex 7
I Degenerate: volatile does not give atomicity count++ from an ISR Ex 8
J Real-world word problem debounced button + timer tick Ex 9
K Exam-style twist how many memory reads does this line emit? Ex 10

Links you may want open while reading: Memory-mapped IO, Interrupt Service Routines (ISR), Compiler Optimization Levels (-O0 -O2), const qualifier, Atomic operations and _Atomic, Memory Barriers and Ordering, Pointers and Type Qualifiers.


Example 1 — Cell A: the spin loop that hangs

Answer: it hangs. Here is why, step by step.

  1. What: the compiler reads ready into a register once, before the loop. Why this step? Inside the loop body nothing the compiler can see writes ready. Under the C standard it is then allowed to assume the value is stable, so caching it in a fast register is a legal speed win.
  2. What: it turns the loop into "compare the register to 0, jump back if equal". Why this step? With the value pinned in a register there is no reason to touch RAM again — that would be slower for no visible benefit.
  3. What: the ISR (the timer's interrupt handler) writes the memory slot [ready], not the register. Why this step? The ISR is invisible to the optimizer's single-function view; it lives outside this translation unit's control flow. So the memory changes but the register does not. The compare uses the stale register forever.

Pseudo-assembly the optimizer emits:

    load R, [ready]     ; ONE read
L:  cmp  R, 0
    jeq  L              ; R is frozen -> forever

The fix is one word:

volatile int ready = 0;

which forces a real load R, [ready] inside the loop every iteration, so the ISR's write is seen.

Recall Verify (sanity check)

After the ISR runs, the memory value of ready is 1. A volatile read returns exactly what is in memory, so the loop condition ready == 0 becomes 1 == 0 → false → loop exits. The non-volatile version keeps returning the frozen 0, so 0 == 0 stays true. Both behaviours are recomputed in the verification block.


Example 2 — Cell B: the write that gets deleted

  1. What: the optimizer sees two stores to the same address, no read between them, and the value is never read afterward. Why this step? This is dead-store elimination: if writing a value has no observable effect, the write is dead and can be removed. The first store is a clear candidate — the very next line overwrites the same address.
  2. What: it deletes the first store (0x5555). Why this step? A store is "dead" if a later store to the same address happens before any read. Here *WDT = 0xAAAA overwrites *WDT = 0x5555 with nothing reading in between, so the compiler is sure nobody could ever observe the 0x5555 — it is provably wasted work and is removed.
  3. What: it then deletes the second store (0xAAAA) too. Why this step? After removing the first store, the function is left with one store to an address that is never read anywhere in the compiler's view. A write whose value no one ever reads is also dead by the same rule, so the final write vanishes and pet() becomes an empty function. The watchdog never gets its unlock sequence → chip resets. Bug.
  4. The fix:
    volatile uint32_t *WDT = (volatile uint32_t *)0x40002000;
    Now each *WDT = ... is an observable side effect; the standard forbids removing it. Both writes, in order, reach the memory-mapped register.

Verify: count the store instructions. Non-volatile → 0 or 1 reach hardware (undefined-looking, but ≤1 in the collapsed case). Volatile → exactly 2, in the order written. The count 2 is confirmed in the verification block.


Example 3 — Cell C: the second read that disappears

  1. What: the compiler notices two reads of the same address with no intervening write. Why this step? Common-subexpression / redundant-load elimination: two identical loads "must" give the same value, so it computes a once and sets b = a.
  2. What: the second load [DATA] is removed; the FIFO is only popped once. Why this step? For an ordinary variable this is correct and free. For a FIFO, reading is a side effect (it advances the queue) — so you silently drop word #2. Data-loss bug.
  3. The fix: volatile uint32_t *DATA makes each *DATA a distinct observable read; both pops happen.

Verify: volatile emits 2 loads; non-volatile emits 1. Both counts are confirmed in the verification block.


Example 4 — Cell D: reordering breaks a protocol

  1. What: because both accesses are volatile, the compiler must keep them and must not reorder them relative to each other. Why this step? The C standard orders volatile accesses among themselves — volatile writes are observable side effects and must appear in program order with respect to other volatile accesses.
  2. What (the trap): this ordering guarantee is only against other volatile accesses. A non-volatile store nearby can still float around, and the CPU/bus hardware may reorder at runtime. Why this step? volatile constrains the compiler, not the processor's write buffers. For true hardware ordering across the memory system you need a memory barrier as well — shown concretely in Example 4b.
  3. Takeaway: volatile-to-volatile order is preserved by the compiler; if you mix in ordinary variables or need cross-core/hardware ordering, add a barrier.

Verify: the pair (PAYLOAD then GO) stays in source order for two volatile accesses — confirmed as an order property in the verification block.

Example 4b — the barrier that actually enforces order

  1. What: volatile keeps both writes and keeps their compiler order. Why this step? Without volatile, the compiler could delete or reorder them before we even reach the hardware — the barrier alone would not save us.
  2. What: the barrier (atomic_thread_fence / __DMB()) forces the hardware to drain the payload write before the go write. Why this step? The barrier is the runtime half of the guarantee; volatile is the compile-time half. You often need both for a correct device protocol. See Memory Barriers and Ordering.

Figure — volatile keyword — preventing optimization of hardware registers
Figure s02 — "volatile orders the compiler; the barrier orders the hardware." Top track (teal) is the compiler's output: store PAYLOAD then store GO, kept in order by volatile. Bottom track (orange) is the hardware write buffer: without a barrier the two stores can swap (dashed crossing arrows, plum "reorder?"); the vertical plum bar labelled "memory barrier" blocks that crossing so GO can only leave after PAYLOAD. Pedagogy: two different actors reorder — the compiler and the CPU — and you need one tool for each.

Verify: with the barrier, the hardware-visible order is PAYLOAD before GO (difference in slot index positive) — confirmed in the verification block.


Example 5 — Cells E & F: right-to-left pointer parsing

Read declarations right-to-left, exactly as you would peel arctan off an angle — you strip the outer question first.

Figure — volatile keyword — preventing optimization of hardware registers
Figure s03 — "Right-to-left tells you which part is volatile." Top row shows volatile int *p: a teal box "p = pointer" and an orange box "*p target = VOLATILE", with an arrow to "correct for registers" — the data is re-read. Bottom row shows int * volatile p: a plum box "p = VOLATILE ptr" and a dark box "*p target = plain", with an arrow to "*p still cached -> wrong" — only the pointer variable is protected. Pedagogy: the word volatile binds to whatever it sits directly beside; reading right-to-left makes that binding visible.

  1. volatile int *p (E): peel right-to-left → "p is a pointer to volatile int". The thing pointed at is volatile, so *p is re-read every time. This is what registers need. Why this step? A register lives at the address p holds; you want the data at that address re-fetched, not the address variable itself.
  2. int * volatile p (F): peel right-to-left → "p is a volatile pointer to a plain int". Here the volatile sits next to the pointer variable p, so it is the pointer that gets re-read from memory; the target *p is an ordinary int and may still be cached in a register. Why this step? volatile binds to whatever it stands beside. In form F it stands beside p, so the compiler protects the address value, not the data at that address. For a hardware register that is the wrong thing to protect: the whole point is that the register's contents change under you, yet form F still lets the compiler read *p once and reuse the stale copy. It compiles cleanly and does nothing useful — the classic silent mistake.
  3. Combine when needed: volatile int * volatile p = both the pointer and its target are volatile (rare; used when the pointer itself sits in volatile memory).

Verify: the parse rules are structural — confirmed in the verification block by encoding "which of {pointer, target} is volatile" for each form.


Example 6 — Cell G: const volatile, the read-only register

  1. What: const says "my code will not write through this pointer" — a write attempt is a compile error, catching bugs. Why this step? See const qualifier: const is a promise your program makes, not a claim about the physical bits.
  2. What: volatile says "someone else (hardware) does change it, so re-read every access". Why this step? The two qualifiers describe different actors. No contradiction: read-only to you, changing from outside.
  3. What: *STATUS & 0x01 keeps only bit 0 (the lowest bit) and zeroes the rest, giving 0 or 1. Why this step? The other bits may be unrelated flags; keeping just bit 0 isolates the "ready" signal so the loop condition tests exactly one thing.

Verify: for a STATUS value of 0x04 (bit 0 clear) the loop stays; for 0x05 (bit 0 set) it exits. 0x04 & 0x01 = 0 and 0x05 & 0x01 = 1 — confirmed in the verification block.


Example 7 — Cell H: the same bug at -O0 and -O2

  1. What at -O0: `-O0` does almost no optimization, so it happens to re-read [ready] each iteration. The bug is hidden by accident. Why this step? -O0 keeps naive memory accesses to make debugging predictable; the re-read is a side effect of laziness, not a guarantee.
  2. What at -O2: the register-caching from Example 1 kicks in → infinite loop. Why this step? -O2 is allowed to cache because the non-volatile variable told it "nothing else changes me". The code lied; the compiler believed it.
  3. Verdict: the code is wrong (missing volatile), not the compiler. Fixing with volatile makes it correct at every -O level.

Verify: correctness must not depend on the flag — confirmed as "volatile ⇒ same observed value regardless of caching" in the verification block.


Example 8 — Cell I: volatile is not atomic

  1. What: count++ compiles to three steps: loadadd 1store. Why this step? Incrementing memory is inherently read-modify-write; the CPU cannot do "+1 in place" as one indivisible action on most architectures.
  2. What: an interrupt can fire between the load and the store. Both the ISR and main can load the same old value, both add 1, both store — you lose one increment. Why this step? volatile guarantees each load/store hits memory, but it does not fuse the three steps into one uninterruptible atomic operation, and adds no memory barrier.
  3. The fix: use `_Atomic int` / `atomic_fetch_add` or disable interrupts around the update.

Numeric picture — two +1s that race can produce +1 total:

main: load 5          ISR: load 5
main: add  -> 6       ISR: add  -> 6
main: store 6         ISR: store 6      // final = 6, not 7

Verify: expected result of two atomic increments from 5 is 7; the lost-update race gives 6. Both are confirmed in the verification block.


Example 9 — Cell J: real-world word problem (button + tick)

  1. What: ticks must be volatile. Why this step? The loop condition ticks - start depends on ticks changing from the ISR. Without volatile the compiler caches ticks, so ticks - start stays 0 < 20 forever → hang.
  2. What: BTN must be volatile. Why this step? It is a memory-mapped input the hardware updates. Non-volatile would read the pin once and never notice a release → debouncing is defeated. (BTN & 0x01 keeps only bit 0, the pressed/released bit.)
  3. What: start is an ordinary local. Why this step? It is written once from a volatile read and never changed by anyone else — no reason to make it volatile.
  4. Numeric check: if the button is held and ticks runs start, start+1, … , start+20, the loop exits when ticks - start first reaches 20 (20 ms). If released at start+7, the difference 7 < 20 is still true so we are inside the loop, but the BTN test returns 0 — a rejected bounce.

Verify: exit condition (ticks - start) < 20 becomes false at difference 20; 20 - 0 = 20 and 7 < 20 is true (still waiting) — both are confirmed in the verification block.


Example 10 — Cell K: exam-style twist ("count the reads")

  1. What for v: every occurrence of a volatile object in an expression is a separate observable read. v + v reads v twice. Why this step? The standard treats each volatile access as a side effect; the compiler must not merge the two into one even though they look identical.
  2. What for n: n is ordinary. n + n may be collapsed to one read, then 2*n, or even folded further. Why this step? With no side effect, redundant-load elimination lets the compiler read n once (count 1).
  3. Answer: v2 reads (mandatory); n1 read (optimized).

Verify: the volatile-read count for v is 2; the check encodes the "2 vs 1" contrast in the verification block.


Active recall

What does ISR stand for and why does it matter for volatile?
Interrupt Service Routine — a function the hardware calls on its own, outside visible flow, so the compiler cannot see when it changes a variable; that is why such variables need volatile.
What is a FIFO and why is reading it a side effect?
A First-In-First-Out hardware queue where reading pops the front word and reveals the next, so a read changes state — the compiler must not remove a "duplicate" read.
How many real reads of v in v + v when v is volatile?
Two — each volatile access is a separate observable side effect and cannot be merged.
Why does the Example 8 race give 6 instead of 7 when incrementing 5 twice?
Both increments load the same old value 5, add 1 to 6, and store 6; one update is lost because count++ is not atomic.
In debounced_press, when does the wait loop exit if the button is held?
When ticks - start first equals 20, i.e. after 20 timer ticks (20 ms).
For a STATUS value 0x04, does while(!(*STATUS & 1)) continue waiting?
Yes — 0x04 & 0x01 = 0, so !0 is true and the loop keeps spinning.
What two tools together guarantee a device sees PAYLOAD before GO?
volatile (keeps the compiler from removing/reordering the stores) plus a memory barrier (stops the CPU write buffer from reordering them at runtime).