Exercises — volatile keyword — preventing optimization of hardware registers
Level 1 — Recognition
Here you only need to spot whether volatile is required and name the rule. No code writing yet.
L1.1 — Register or ordinary?
For each variable, say whether it needs volatile (yes / no) and one word for why.
- A
uint32_tholding the sum of an array, used once then discarded. *(uint32_t *)0x40021000— a memory-mapped I/O peripheral status register.- A global
int doneset to1inside a timer ISR (Interrupt Service Routine), polled bymain(). - A loop counter
iinfor(i=0;i<10;i++).
Recall Solution
| # | Needs volatile? |
Why |
|---|---|---|
| 1 | No | Nothing changes it outside visible code. |
| 2 | Yes | Memory-mapped IO — hardware changes it. |
| 3 | Yes | Changed by an ISR behind the compiler's back. |
| 4 | No | Fully controlled by visible code; caching it is correct and desirable. |
Rule: volatile is needed only when a value can change without any visible-code action. Three sources do this:
- Hardware register — a peripheral flips bits on its own.
- ISR — an interrupt handler runs on your CPU between two of your instructions and writes the variable.
- Another thread / DMA — DMA (Direct Memory Access) is a separate hardware engine copying into RAM in parallel; a second thread runs concurrently on another core. Both change memory while your loop runs, with no visible write in your code.
The figure below shows these three "invisible writers" reaching around the compiler to the same memory cell.

L1.2 — Which optimization would bite?
while (status == 0) { } where status is a hardware flag with no volatile. Which of the three forbidden optimizations produces the bug: caching the value in a register (loop-invariant code motion), dead-store elimination, or reordering?
Recall Solution
Caching the value in a register. Because nothing visible writes status inside the loop, the compiler treats the load as loop-invariant — its value can't change across iterations — and applies loop-invariant code motion (LICM): it hoists the load out of the loop into a CPU register, then loops on that register copy forever.
- "Loop-invariant code motion" is the standard optimizer name; "caching in a register" is the everyday way to describe its effect here.
- (Dead-store elimination is about writes that are never read; reordering is about moving accesses — neither is the cause here.)
Level 2 — Application
Now write or fix small snippets.
L2.1 — Fix the hang
This polls a UART "data-ready" bit (bit 0 of the status register at 0x4000C000). It hangs at -O2. Fix it with the minimal change.
uint8_t *SR = (uint8_t *)0x4000C000;
while (!(*SR & 0x01)) { }Recall Solution
Make the pointed-to data volatile:
volatile uint8_t *SR = (volatile uint8_t *)0x4000C000;
while (!(*SR & 0x01)) { }Why the target and not the pointer? We re-read *SR every loop, so the thing being read must be volatile. Parsing right-to-left: volatile uint8_t *SR = "SR is a pointer to a volatile uint8_t". See pointer qualifiers.
Why & 0x01? The status register packs many flags; 0x01 masks all bits except bit 0 so we test only "data ready".
L2.2 — Count the guaranteed memory reads
volatile uint32_t *FIFO = (volatile uint32_t *)0x40000010;
uint32_t a = *FIFO;
uint32_t b = *FIFO;
uint32_t c = *FIFO;Each read pops one word from a hardware FIFO (First-In-First-Out queue — see the jargon box above). How many real memory reads does the compiler emit at -O2? What would the count be without volatile?
Recall Solution
- With
volatile: 3 reads. A volatile access is an observable side effect, so the compiler cannot merge them. - Without
volatile: 1 read. The compiler sees three reads of the same address with no write between and collapses them tob = a; c = a;— losing two FIFO words.
Answer: 3 with, 1 without.
L2.3 — Place the qualifier
A read-only hardware status register: your code must never write it, but hardware changes it. Write the pointer declaration for address 0x40000004, size uint32_t.
Recall Solution
const volatile uint32_t *STATUS = (const volatile uint32_t *)0x40000004;Why both? const (from const qualifier) makes the compiler reject any *STATUS = ... in your code — a compile-time guard. volatile forces a fresh read each time because hardware mutates it. They are not opposites: const = "I won't write it", volatile = "something else does".
The figure parses the three placements graphically (read right-to-left).

Level 3 — Analysis
Now reason about why the generated code differs.
L3.1 — Predict the assembly
Below are two loops. For each, say whether the load sits inside or outside the loop body in -O2 output, and why.
// (A)
int flag = 0;
while (flag == 0) { }
// (B)
volatile int flag = 0;
while (flag == 0) { }Recall Solution
(A) non-volatile — load is OUTSIDE. Walk it line by line:
load R, [flag] ; ONE read, hoisted ABOVE the loop
L: cmp R, 0 ; compare the cached register R
jeq L ; R never changes -> jump back forever
The compiler proved nothing in the loop writes flag, so the load is loop-invariant and got hoisted out. Inside the loop there is now no memory access at all — only the register R is tested. If an outside agent (ISR/hardware) writes memory, R never learns of it ⇒ hang.
(B) volatile — load is INSIDE.
L: load R, [flag] ; RE-READ memory every iteration
cmp R, 0
jeq L
Because a volatile read is an observable side effect, the compiler is forbidden to hoist it. The load stays below the label L, so every trip round the loop touches real memory and sees any external write.
The one-line difference: in (A) the load is above L (executes once); in (B) it is below L (executes every iteration).
The figure below is a timeline: the same ISR write arrives at time t, but only the volatile loop has a memory read after t to notice it.

L3.2 — Does -O0 "fix" (A)?
At -O0 loop (A) appears to work. Is -O0 a valid fix? Explain in terms of correctness vs. accident.
Recall Solution
No — it is accidental, not correct.
-O0happens to keep the re-read only because it does not perform loop-invariant code motion (see Compiler Optimization Levels (-O0 -O2)).- The C standard still permits caching a non-volatile variable at any level; another compiler or a future
-O2build re-breaks it. - It also disables all optimizations everywhere, tanking performance.
Correct fix = volatile, which is right at every optimization level and documents intent.
L3.3 — Why the second read vanishes
In L2.2 without volatile, exactly which optimization removed reads 2 and 3? Name it and state the false assumption it relied on.
Recall Solution
Common-subexpression elimination / redundant-load elimination. False assumption: "a memory location's value doesn't change unless visible code writes it, so re-reading the same address gives the same result." True for RAM, false for a FIFO where the read itself has a side effect (advancing the queue).
Level 4 — Synthesis
Combine ideas into a small correct design.
L4.1 — Shared flag done right
main() waits for a byte assembled by an ISR (Interrupt Service Routine) into a 4-byte struct, signalled by ready:
struct { uint8_t b[4]; } packet; // filled by ISR
int ready = 0; // set to 1 by ISR after fill
// main:
while (ready == 0) { }
process(packet);Two bugs hide here. Name both and give the corrected declarations + the missing safety step.
Recall Solution
Bug 1 — ready not volatile: the poll loop caches it → hang. Fix: volatile int ready = 0;.
Bug 2 — no ordering guarantee: even with volatile, the compiler/CPU may make main see ready == 1 before the packet bytes are visible, because volatile provides no barrier across packet and ready (see Memory Barriers and Ordering). A memory barrier is a fence instruction that forces all writes before it to become visible before any write after it.
Fix — insert a barrier between the payload writes and the flag, so packet is guaranteed visible first:
volatile int ready = 0;
// Inside the ISR:
void isr(void) {
packet.b[0] = a; packet.b[1] = b;
packet.b[2] = c; packet.b[3] = d; // (1) write payload
__sync_synchronize(); // (2) full memory barrier (GCC/Clang builtin)
ready = 1; // (3) now publish the flag
}The __sync_synchronize() fence guarantees steps (1) land in memory before step (3).
What is memory_order_release? The C11 model lets you attach an ordering tag to an atomic write. A release write is a one-directional fence: it guarantees that everything written before it becomes visible to any reader before that reader can see the released value. So a release-tagged write of ready bundles the "publish payload first" barrier into the flag write itself — the reader pairs it with an acquire read. Cleaner than a separate __sync_synchronize():
_Atomic int ready = 0; // see [[Atomic operations and _Atomic]]
atomic_store_explicit(&ready, 1, memory_order_release); // publish payload, then flagvolatile alone fixes visibility of the flag, not ordering of the payload.
L4.2 — When volatile is not enough
main does shared_count++ where shared_count is incremented by an ISR too. You made it volatile. Is the program now correct? If not, what's the fix?
Recall Solution
Not correct. volatile forces each access to memory, but shared_count++ is load → modify → store — three separate accesses. An interrupt firing between load and store loses an increment (a lost-update race).
Fix: use `_Atomic`: _Atomic int shared_count; and atomic_fetch_add(&shared_count, 1);, or disable interrupts around the update.
Slogan: volatile ⇒ fresh, not atomic.
Level 5 — Mastery
Full reasoning, edge cases, and a numeric trace.
L5.1 — Trace the FIFO loss
A driver reads a 4-word FIFO (First-In-First-Out queue) in a non-volatile loop the compiler collapses. The FIFO physically holds words [10, 20, 30, 40] (word 0 pops first). The (buggy) code is:
uint32_t *FIFO = (uint32_t *)0x40000010;
uint32_t sum = 0;
for (int i = 0; i < 4; i++)
sum += *FIFO; // NOT volatileThe compiler hoists the load out of the loop (one real read, value cached).
(a) What value does the single hoisted read return (the first FIFO word)?
(b) What is sum computed by the buggy code?
(c) What should sum be with volatile?
(d) How many FIFO words are lost (never popped), and what is their combined value?
Recall Solution
(a) The single read pops the first word: 10.
(b) Buggy: the cached 10 is added four times → sum = 10 + 10 + 10 + 10 = 40.
(c) Correct (volatile forces 4 real reads popping 10, 20, 30, 40): sum = 10 + 20 + 30 + 40 = 100.
(d) Only word 0 was ever read. Words 20, 30, 40 are never popped → 3 words lost, combined value 90.
Consistency check: the correct sum (100) minus the buggy sum (40) equals 60, which is not the same as the lost-words total (90) — because the bug both drops 20,30,40 and double-counts 10 three extra times. That mismatch is itself the lesson: caching corrupts the total in two ways at once.
Fix: volatile uint32_t *FIFO = (volatile uint32_t *)0x40000010;.
L5.2 — const volatile full case matrix
For a const volatile uint32_t *reg, decide for each action whether it compiles and whether it does a real memory access:
uint32_t x = *reg;(read)*reg = 5;(write)
Recall Solution
| Action | Compiles? | Real memory access? |
|---|---|---|
x = *reg; |
Yes | Yes — volatile forces a fresh read each time. |
*reg = 5; |
No | — const makes the write a compile error. |
This is exactly a read-only hardware status register: hardware writes it (volatile ⇒ re-read), your code may only read it (const ⇒ writes rejected).
L5.3 — Count guaranteed accesses (degenerate cases)
For volatile int v;, how many real memory accesses does the standard guarantee for each line?
v = 0; v = 0;int t = v; t = v;(tordinary)v;(an expression statement that readsvand discards it)
Recall Solution
- 2 writes. Volatile writes are observable side effects — the compiler may not drop the "dead" second store. (Non-volatile, it would emit 1.)
- 2 reads. Both
vreads hit memory (tmerges are irrelevant — the reads of v count). - 1 read. Even a discarded volatile read is an observable side effect and must happen; a non-volatile
v;would be deleted entirely.
Totals: 2, 2, 1.
L5.4 — The narrow-bus atomicity trap
A volatile uint32_t tick is written by a timer ISR and read in main() on an 8-bit MCU (data bus moves one byte per access). You reasoned: "it's volatile, so the read is a single fresh memory access — safe." Is a single read of tick guaranteed to return a consistent 32-bit value?
Recall Solution
No. On an 8-bit MCU a 32-bit volatile load is compiled into four separate byte reads (the data bus is only 8 bits wide — see the jargon box). The timer ISR can fire between two of those byte reads and update tick, so main can assemble a torn value: some bytes old, some new (e.g. read 0x00FF, ISR bumps to 0x0100, you finish reading and get 0x01FF — a value that never existed).
volatileguarantees each byte access is fresh and real, but it does not make the four-byte sequence atomic.- On a 32-bit CPU the same
uint32_tmoves in one bus transaction, so a single access is atomic there — this bug is width-dependent, which is exactly why it's easy to miss when porting from a 32-bit dev board to an 8-bit target. - Fix: use `_Atomic`, or read
ticktwice and retry until two reads agree, or briefly disable interrupts around the read. Slogan:volatile⇒ fresh, not atomic — and "one C statement" is not the same as "one bus transaction".