5.1.33 · D2C Programming

Visual walkthrough — volatile keyword — preventing optimization of hardware registers

1,956 words9 min readBack to topic

We only assume you know that a program is a list of instructions and that a computer has some fast scratch slots (registers) and a big slow store (memory / RAM). Everything else we build.


Step 1 — The two places a value can live

WHAT. A number your program uses lives in one of two places: memory (a numbered mailbox, slow to reach) or a CPU register (a tiny slot right inside the processor, blazing fast). Look at the figure: the mailbox [flag] on the right is memory; the box R on the left is a register.

WHY it matters. Reaching into memory costs many CPU cycles. Reaching a register costs almost nothing. So the compiler loves to copy a memory value into a register once and then reuse the register. That copy is the whole story of this page.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

Step 2 — The compiler's core assumption

WHAT. The C compiler reads your function and builds a mental model: "the only things that touch memory are the reads and writes I can see written here." Under that belief, if nothing in view writes flag, then the value in R stays a perfect copy of [flag] forever.

WHY. This assumption is what permits every speed optimization. If the register copy can never go stale, why ever re-load? The figure shows the compiler drawing a green "still valid" seal over R because it sees no write between the load and the use.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

Step 3 — The optimization fires: read-once

WHAT. Take the classic wait loop:

int flag = 0;
while (flag == 0) { }

The compiler applies Step 2: nothing in the loop writes flag, so it loads once, before the loop, and spins on the register.

WHY. Re-loading every iteration would be pure waste under the assumption. So the optimizer hoists the load out. The figure shows the load arrow drawn outside the loop box — it happens exactly once.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

The generated shape:

    load  R, [flag]     ; ONE read, before the loop
L:  cmp   R, 0           ; compare the CACHED copy
    jeq   L              ; equal-to-zero? jump back
  • cmp R, 0 — compare register R against the number 0.
  • jeq L — "jump if equal" back to label L. Because R never changes inside the loop, jeq always jumps. This is correct only if the assumption of Step 2 holds.

Step 4 — A hidden writer breaks the assumption

WHAT. Now a real chip enters. An interrupt service routine runs between your instructions and writes memory:

void timer_isr(void) { flag = 1; }   // hardware calls this

The ISR writes [flag] = 1 in memory. But the loop is spinning on R, which is still 0. The truth in the mailbox changed; the copy in the register did not.

WHY it hangs. The equation from Step 2, , is now false — but the compiler baked in the belief that it can never be false, so no re-load was generated. The figure shows the ISR's coral arrow reaching only the mailbox, never the register. The loop is deaf.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

Step 5 — volatile deletes the assumption

WHAT. Qualify the variable:

volatile int flag = 0;
while (flag == 0) { }

volatile tells the compiler: "for this variable, the assumption of Step 2 is FORBIDDEN. Treat every read as if a hidden writer might have just changed memory."

WHY this is the exact cure. The bug was one specific false belief — " stays valid". volatile doesn't add locks, doesn't slow the whole program, doesn't touch other variables. It surgically removes the one permission that produced the bug. Now the load is pulled back inside the loop (figure): every comparison is preceded by a fresh trip to memory.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers
L:  load  R, [flag]     ; RE-READ every iteration
    cmp   R, 0
    jeq   L

Now when the ISR sets [flag] = 1, the very next load sees 1, cmp fails, jeq falls through, and the loop exits. The chain of custody from hardware → memory → register → your code is unbroken.


Step 6 — Edge case: the dead-store / duplicate-read trap

WHAT. A FIFO (first-in-first-out queue) hardware register: reading it consumes one word and advances to the next.

volatile uint32_t *DATA = (volatile uint32_t *)0x40000010;
uint32_t a = *DATA;   // pulls word #1
uint32_t b = *DATA;   // pulls word #2

WHY volatile is essential here. Without volatile, the compiler sees two reads of the same address with no write between them, concludes they must be identical, and rewrites b = a — deleting the second real read. That is data loss: word #2 is never pulled. The figure shows the FIFO conveyor: without volatile the second cup is skipped; with volatile both cups are taken.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

This is the mirror image of Step 3's bug: there the danger was too few reads of a status flag; here it's too few reads of a data port. volatile fixes both by forbidding the "same address, same value" shortcut.


Step 7 — Degenerate case: the read-only register (const volatile)

WHAT. Some hardware registers you must never write (a status register), yet hardware changes them constantly. That is two facts at once:

const volatile uint32_t *STATUS = (const volatile uint32_t *)0x4000C000;
  • `const`your code is forbidden to write it (a compile-time promise).
  • volatilehardware changes it, so re-read every time.

WHY they combine, not clash. const restricts the software's writes; volatile describes the hardware's writes. They speak about different actors, so both can be true. The figure splits the register into two arrows: a blocked (coral) arrow from your code, and a live (mint) arrow from the chip.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

Step 8 — The limit of volatile: it is NOT atomicity

WHAT. volatile int count; does not make count++ safe against interrupts.

WHY. count++ is really three separate machine steps — a load, an add, a store:

volatile guarantees each of those three touches real memory — but an interrupt can strike between step 1 and step 3. The figure shows the ISR sneaking in after the load: it increments count, then your store overwrites it, and one increment is lost.

PICTURE.

Figure — volatile keyword — preventing optimization of hardware registers

For true atomic increments use `_Atomic`; for ordering guarantees across several variables use memory barriers. volatile covers one variable's visibility, nothing more.


The one-picture summary

Figure — volatile keyword — preventing optimization of hardware registers

The whole page is one fork in the road. The compiler always wants to cache. volatile is the single flag that decides whether the cached copy is trusted (bug road) or discarded on every access (correct road).

Recall Feynman retelling — the whole walkthrough in plain words

A number lives in a slow mailbox (memory) and the CPU keeps a fast sticky-note copy (a register). The compiler is lazy: it copies the mailbox to the sticky note once and then only reads the sticky note, because it assumes nobody but the visible code touches the mailbox (Step 1–3). But a hardware chip, an interrupt, or another thread can change the mailbox behind the compiler's back (Step 4) — now the sticky note lies, and a wait-loop hangs forever. volatile is one word that tells the compiler "never trust the sticky note for this box — walk to the mailbox every single time" (Step 5). That also stops it from skipping a second read of a FIFO port where each read pulls new data (Step 6). You can even combine it with const: you promise not to write the box, but the hardware still does, so keep re-reading (Step 7). The one thing volatile does not do is make count++ safe — that's three steps and an interrupt can slip between them (Step 8). Visibility, yes; atomicity, no.

Compiler behaviour without volatile
loads the variable once into a register and reuses that cached copy, deleting later re-reads.
Compiler behaviour with volatile
emits a real memory load/store for every access, never caching, never merging duplicates.
Why const volatile is valid
const forbids the software from writing; volatile says hardware writes it — different actors, both true.
Why volatile does not make count++ atomic
it is load-add-store; an interrupt can strike between the load and the store, losing an increment.