Skip to content
VLSI Mentor

C Part II · Module 17 · volatile for Hardware Engineers

Why the Compiler May Remove Your Register Access

A loop that waits for a flag to change compiles, at -O2, to a single return instruction — not a hoisted load, not an infinite branch, simply gone. Module 16 kept measuring this and kept deferring the explanation, and the explanation is not that the optimiser is aggressive. It holds a model of what memory is, every transformation here is a correct deduction from that model, and a hardware register breaks the model's premises. This chapter is the model: four transformations, the assumption each rests on, and the generated code for all eight cases.

Advanced21 min readCPart IIHardwareCorrectness

Part II · Module 17 · Chapter 17.1 · Why the Compiler May Remove Your Register Access

1. The Debt

Module 16 measured the optimiser deleting register accesses five separate times and each time said the explanation was Module 17's. The measurements were:

  • a polling loop's load hoisted out of the loop, the body reduced to an unconditional branch (16.1 §5)
  • the same load narrowed from a word to a byte (16.1 §5)
  • a read whose value was discarded emitting no load at all (16.3 §3)
  • two writes of the same value merged into one store (16.3 §3)
  • the rule "volatile on every memory-mapped access" stated without justification (16.3 §7)

This chapter is the justification, and it begins by rejecting the usual framing.

The optimiser is not being aggressive. It is not fighting you, and it has not got cleverer than is good for it. It holds a specific, simple model of what memory is; that model is correct for every ordinary object in your program; and every transformation below is a valid deduction from it. A hardware register is an object that breaks the model's premises, and nothing in the C source says so.

Once you can state the model, the transformations stop being surprises and become predictions.

2. The Model

Three assumptions. They are not written in the standard in this form, but they are what the standard's rules amount to for an ordinary object:

One — nothing changes an object except the program. If no code between two points writes g, then g has the same value at both. There is no other agent.

Two — a load has no effect. Reading an object is a way of obtaining its value. If the value is not used, the read accomplished nothing and can be removed.

Three — only the final value of a store sequence is observable. If g = 1 is followed by g = 2 with nothing in between, nobody can ever have seen the 1.

Every one of those is true of a local variable, a malloc'd buffer, a global that only your program touches. They are the premises that make optimisation possible at all, and a compiler that abandoned them would produce code several times slower for every program ever written.

They are all three false for a hardware register, which Chapter 16.1 §6 established: the device changes it, a read may pop a FIFO, and each write may transmit a byte.

3. The Four Transformations

The same four functions, compiled at -O2, over extern uint32_t g and then over extern volatile uint32_t g. The address-materialisation loads (adrp and the GOT load) are excluded throughout — they fetch the address, not the data.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
extern uint32_t g;

uint32_t two_reads(void)   { return g + g; }
void     two_writes(void)  { g = 1u; g = 2u; }
void     wait_flag(void)   { while (g == 0u) { } }
void     read_discard(void){ (void)g; }

Two reads become one — and the arithmetic changes.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plain:      ldr  w8, [x8]
            lsl  w0, w8, #1          <- one load, doubled

volatile:   ldr  w9, [x8]
            ldr  w8, [x8]
            add  w0, w8, w9          <- two loads, added

This pair is the most precise evidence in the chapter, because the instruction changed, not merely the count. With an ordinary object the compiler knows both reads yield the same value, so g + g is 2 × g and a shift is the cheapest way to say it. With volatile it must assume the two reads may differ, so it has two distinct values and must add them. Assumption one, withdrawn, visible in the opcode.

Two writes become one.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plain:      mov  w9, #2
            str  w9, [x8]            <- only the 2 is written

volatile:   mov  w9, #1
            str  w9, [x8]
            mov  w9, #2
            str  w9, [x8]            <- both

Assumption three. Nothing could observe the 1, so it was not written.

A discarded read disappears.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plain:      _read_discard:
                ret                  <- nothing at all

volatile:   the load is emitted

Assumption two. Chapter 16.3 §3 measured this as zero loads against one and said it mattered for a read-to-clear register; now the reason is on the table.

And the loop is removed entirely.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plain:      _wait_flag:
                ret

volatile:   LBB2_1:
                ldr  w9, [x8]
                cbz  w9, LBB2_1
                ret

Not hoisted. Not turned into an infinite branch. Gone, with main falling straight through to whatever came after. That one needs its own section.

Four transformations the optimiser applies to an ordinary object, with the assumption each one rests on and what happens to the same code when the object is volatile. Reading the same object twice becomes a single load, because nothing between the two reads can change it; with volatile both loads survive, and the arithmetic changes from a shift to an addition because the two values may now differ. Writing twice becomes a single store of the second value, because nothing can observe the first; with volatile both stores survive. A loop that waits on the object is removed entirely, because the standard permits assuming a side-effect-free loop terminates; with volatile the loop reloads every iteration. A read whose result is discarded is deleted, because a load has no effect; with volatile it is performed.What you wroteOrdinary objectThe assumptionWith volatileRead it twicereturn g + gone loadthen a shift to doubleitnothing changeditbetween the two readstwo loadsand an add, not ashiftWrite it twiceg = 1; g = 2one storeonly the value 2nobody saw thefirsttwo stores1 then 2Wait for it tochangewhile (g == 0) { }the loop is gonemain falls straightthrougha quiet loop endsC17 6.8.5p6reloads each passldr inside the loopRead and discard(void)gnothing emitteda load has noeffectthe load happens12
Figure 1 — the four transformations, the assumption each rests on, and what the identical source produces when the object is volatile. The third column is the one to read: every entry in it is true of an ordinary object, which is why the compiler is entitled to reason this way and why the resulting code is correct for every variable in your program. The first row is the sharpest evidence, because the arithmetic itself changes — a shift for a plain object, because both reads must agree and doubling is cheaper, and an addition for a volatile one, because the two values may now differ. The third row is the one that produces the worst field behaviour, and section 4 is why it is a removal rather than a hoist.

4. The Loop That Vanished

while (g == 0u) { } compiling to ret deserves explaining, because it is not covered by the three assumptions in §2. It rests on a fourth rule, and that rule is written in the standard explicitly.

C17 §6.8.5 paragraph 6 permits an implementation to assume that an iteration statement terminates, when its controlling expression is not a constant expression and its body:

  • performs no input/output operations,
  • does not access volatile objects, and
  • performs no synchronisation or atomic operations.

while (g == 0u) { } satisfies all three conditions for a plain g. So the compiler may assume the loop terminates. If it terminates, g must be non-zero afterwards, and since nothing in the loop does anything, the loop can simply be deleted.

Two things are worth drawing out.

The rule names volatile as the exemption. That is not a coincidence or a happy accident — the standard is telling you, in the text of the forward-progress rule itself, exactly which qualifier reinstates the loop. The volatile version keeps it because the body now accesses a volatile object and the assumption no longer applies.

This is a different outcome from Chapter 16.1 §5, and the difference is instructive. There, the same shape of loop became b LBB3_1 — an infinite branch — after a single hoisted load. Here it disappears. The difference is incidental: in 16.1 the address was a cast constant, so one load happened before the loop and the compiler kept a branch; here g is an extern object and the forward-progress rule applied first. Both are the same underlying fact — the loop body performs no access — and the observable outcomes differ. One hangs forever, one falls through as though the condition were satisfied.

The second is worse, and Chapter 17.4 measures a program doing exactly that: printing a success message in 0.018 seconds without ever reading the flag it claimed to be waiting for.

5. What the Model Gets Right

It would be easy to leave this chapter thinking the optimiser's model is a liability. It is the opposite, and the balance matters for judging what to do about it.

Those three assumptions are what make C fast. Eliminating redundant loads, removing dead stores, hoisting invariants out of loops — these are not marginal tricks; they are most of the difference between -O0 and -O2, and they apply correctly to essentially every object in every program ever compiled. A register is the exception, and it is a rare one: a typical program has millions of ordinary memory accesses and a handful of memory-mapped ones.

So the design question the C committee faced was not "should the compiler assume these things?" but "how should a program say that this particular object is different?" — and the answer is a qualifier that is opt-in, per object, and costs nothing anywhere else.

That framing is worth carrying into the next chapter, because it explains the shape of what volatile guarantees. It is not a general-purpose safety feature. It withdraws exactly the three assumptions in §2, for exactly the objects you mark, and it does nothing else at all — which is why Chapter 17.5 can list three well-known things it does not provide.

6. The Misconception: The Optimiser Is Too Aggressive

"The compiler is being too clever. I will build the driver at -O0, or add -fno-strict-aliasing, or volatile everything, and the problem goes away."

Three claims, and each fails differently.

"Too clever" is the wrong diagnosis. Nothing in §3 is clever. Removing a store nobody can observe is the most basic optimisation there is, and a compiler that kept it would be considered broken. The transformations are correct deductions from premises that hold for every ordinary object. The thing that is wrong is the premise, for one object, and the program never said so.

-O0 hides the bug and costs everything. It is true that the symptom disappears — §4's callout measured why. It is also true that your whole program is now several times slower, that the bug is still there waiting for the day someone raises the optimisation level, and that you have made the register code's correctness depend on a build flag. That is not a fix; it is a deferral with interest.

volatile everywhere is not free either. Qualifying ordinary objects as volatile suppresses optimisation on them, correctly and permanently. A hot loop over a volatile array reloads every element on every access. The qualifier is precise and should be applied precisely: to the objects that genuinely are not ordinary.

The correct reading is the one §5 set up:

The optimiser's model is right about ordinary memory. A register is not ordinary memory. Marking the exception is your job, because nothing else in the program can tell.

Which is a statement of the problem. The next chapter is the statement of the solution, and it is narrower than most people expect.

7. What You Should Be Able to Say Now

  1. The optimiser holds a model of memory, and it is correct for ordinary objects. Three assumptions: nothing else changes an object; a load has no effect; only the final store is observable.
  2. All three are false for a register — the device changes it, a read may pop a FIFO, each write may act (16.1 §6).
  3. Two reads become one load and a shift; with volatile, two loads and an add. The arithmetic changes because the premise that both reads agree is exactly what the qualifier withdraws.
  4. Two writes become one store; with volatile, both.
  5. A discarded read emits nothing; with volatile, the load happens.
  6. A wait loop on a plain object is removed entirely — ret, not a hoist — under C17 §6.8.5 ¶6, which permits assuming a side-effect-free loop terminates and names volatile as the exemption.
  7. Chapter 16.1 §5 saw an infinite branch instead, from the same underlying fact. The outcomes differ incidentally; falling through is the worse of the two.
  8. The signature of this whole family: the bug depends on optimisation level and moves when unrelated code changes. -O0 hides it, which is why a debug build misleads.
  9. The check is static — read the generated code and look for the load inside the loop.
  10. The model is not aggression, -O0 is not a fix, and volatile everywhere is not free.

8. Exercises

  1. Reproduce §3's four functions over extern uint32_t g and over the volatile version. Record the instruction counts, excluding the GOT loads, and confirm all eight results.
  2. Explain, in one sentence each, why lsl w0, w8, #1 is correct for the plain two_reads and wrong for the volatile one.
  3. Build wait_flag at -O0, -O1 and -O2 over a plain g and record which ones contain a loop. State the general rule your results support.
  4. Read C17 §6.8.5 ¶6 (or a draft). List its three conditions, and say which one each of volatile, printf and an atomic operation would break.
  5. Write a loop that waits on a plain g and calls an empty non-inline function in the body. Does the loop survive at -O2? Explain the result from ¶6's conditions.
  6. Take §3's two_writes and insert a printf between the two stores. Predict whether both stores survive, then check. Which assumption did the printf break?
  7. A colleague proposes building only the driver file at -O0 and the rest of the project at -O2. List two things that fixes and two it does not.

9. Summary

  • The optimiser has a model, and it is right about ordinary memory: nothing else writes it · a load has no effect · only the last store is observable.
  • A register breaks all three, and the C source does not say so.
  • Measured at -O2: two reads → one load and a shift, against two loads and an add with volatile — the arithmetic changes.
  • Two writes → one store. A discarded read → nothing emitted.
  • A wait loop on a plain object is deleted outright (ret), under C17 §6.8.5 ¶6, whose text names volatile as the exemption.
  • 16.1 §5's infinite branch and this removal are the same fact with different outcomes; falling through is worse than hanging.
  • Signature: optimisation-dependent, moves with unrelated edits, invisible at -O0. Check the generated code, not the behaviour.
  • Not aggression; not fixed by -O0; not fixed by qualifying everything. Mark the exception precisely.

The problem is now stated exactly: three assumptions, true in general, false for one kind of object, and no way for the compiler to tell. The solution is a single qualifier, and what makes the next chapter worth reading is that its guarantee is far narrower than its reputation — narrow enough to be stated in one sentence, and narrow enough that three of the things people use it for are not in that sentence at all.

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.