C Part II · Module 17 · volatile for Hardware Engineers
What volatile Does Not Guarantee
Three times across two modules this curriculum has said volatile does not give you that and moved on. This is where the debts are collected, with a measurement for each: an increment on a volatile object compiling to three separate instructions, and a plain store being deleted and another moved across a volatile one that was supposed to be sitting between them. The third — mutual exclusion — gets no measurement because there is nothing to measure, and that is the point. All three reduce to one sentence about what the qualifier is for, and each has a named replacement.
Expert22 min readCPart IIHardwareCorrectness
Part II · Module 17 · Chapter 17.5 · What volatile Does Not Guarantee
1. The Debts
This chapter has been promised five times:
- 16.3 §7 — "
volatilemeans preserved, not safe", with the three gaps named and deferred - 16.5 §4 — the window between a read and a write, and "
volatiledoes not help" - 17.2 §4 — the qualifier in "not reordered with respect to other volatile accesses"
- 17.2 §5 — "a guarantee about the abstract machine only"
- 17.4 §6 — the limits of
volatile sig_atomic_t, stated and not explained
Three distinct non-guarantees, each believed by people who would tell you they understand volatile, and each with a measurement.
Before the three, the contrast. Chapter 17.2 measured what the qualifier does give: a discarded read is performed, two writes both happen, g + g becomes two loads and an add, and two volatile accesses keep their order. Those are real and they are the whole list.
2. Not Atomic
The most common and the most consequential.
extern volatile uint32_t v;
void inc(void) { v++; }_inc:
ldr w9, [x8]
add w9, w9, #1
str w9, [x8]
retThree instructions. A load, an arithmetic operation, and a store — which is precisely Chapter 16.5's read-modify-write sequence, with the qualifier applied.
volatile did its job completely: the load happened, the store happened, neither was elided or merged. And the three steps are as separate as they ever were. An interrupt between the ldr and the str reads the old value, increments it, stores it, and is then overwritten by the str — one increment lost, silently.
This is worth being blunt about because the belief is widespread: there is nothing in the definition of volatile about indivisibility. Chapter 17.2 §1 derived the whole guarantee from one fact — accesses are side effects and therefore observable — and indivisibility does not follow from that and was never claimed.
Chapter 17.4 §4 met the same point from the signals side: sig_atomic_t guarantees that a single access is not torn, and says nothing about ++, which is not a single access.
The replacement has a name. For a counter shared with an interrupt handler or another thread, C11 provides _Atomic types and operations such as atomic_fetch_add, which compile to a genuine atomic read-modify-write instruction sequence. That is outside this module's scope — it is a subject in its own right — but the name is what you need, because "use volatile" is the wrong answer and "be careful" is not an answer.
The difference is visible in one line. atomic_fetch_add(&ac, 1u) on this target compiles to:
ldaddal w9, w8, [x8]One instruction, an atomic add with acquire-release semantics, against volatile's three. There is no interleaving to worry about because there is no gap.
3. Not Ordered Against Ordinary Accesses
Chapter 17.2 §4 measured this as part of the definition. It belongs here too, because it is the gap that produces the most serious device bugs.
extern volatile uint32_t vreg;
extern uint32_t plain;
void mixed(void) {
plain = 1u; /* plain */
vreg = 2u; /* volatile */
plain = 3u; /* plain */
}_mixed:
ldr x8, [x8, _vreg@GOTPAGEOFF]
mov w9, #2
str w9, [x8] <- the VOLATILE store, first
ldr x8, [x8, _plain@GOTPAGEOFF]
mov w9, #3
str w9, [x8] <- the surviving plain store, after
retplain = 1u is gone — a dead store, eliminated despite a volatile access standing between it and the store that overwrote it. And the ordering the source expressed, plain before volatile, is not present in the output.
The reason is exactly what Chapter 17.2 §2 predicted from the as-if rule: plain accesses are not observable behaviour, so nothing constrains them, and a volatile access is a barrier for other volatile accesses and for nothing else.
Why this is the dangerous one. The canonical device sequence is:
dma_buffer[0] = header; /* ordinary memory */
dma_buffer[1] = payload; /* ordinary memory */
DMA_CTRL = DMA_START; /* volatile register write */The buffer writes are plain stores. Nothing in the C source requires them to reach memory before the DMA_START write reaches the device — and if the engine reads the buffer when it sees the start bit, it may read a buffer that was never written. The volatile on DMA_CTRL does nothing for the two lines above it.
Compiled here, the order happened to be preserved — and the compiler still transformed the buffer writes freely:
stp w0, w1, [x8] <- the TWO buffer stores, merged into one instruction
ldr x8, [x8, _DMA_CTRL@GOTPAGEOFF]
mov w9, #1
str w9, [x8] <- the volatile storeTwo source statements became one stp. That they also stayed before the volatile store is this compiler's choice on this day, not a guarantee — and the merge is the evidence that it regards them as freely transformable. Relying on the order is relying on something nobody promised, which is Chapter 16.8 §6's observed, not guaranteed.
Chapter 17.6 is the mechanism that fixes this, and it is not a qualifier.
4. Not Mutual Exclusion
This one gets no measurement, and the absence is the argument.
There is no lock in the definition of volatile. Chapter 17.2 §1 derived the entire guarantee from "accesses are side effects"; nothing in that creates a critical section, acquires anything, or excludes anyone. There is nothing to measure because there is nothing there.
What people actually want when they reach for it:
if (flag != 0) { /* read */
handle(); /* act */
flag = 0; /* clear */
}Three operations on a shared object with a gap between each. If the handler sets flag between the handle() and the flag = 0, the new event is cleared without being handled — which is Chapter 16.5 §2's measured W1C failure in software clothing, and the reason that chapter insisted "unsafe" and "wrong" are different problems.
volatile guarantees that the read reads and the write writes. It has no opinion about the gap.
No race trace is fabricated here, for the reason Chapter 16.5 §4 gave: single-threaded C on this machine cannot produce an interrupt arriving between two instructions, and inventing a transcript would be worth nothing. The argument is from the definition, and the definition is short enough to check.
The replacements have names: disabling interrupts around the sequence (for the handler case on one core), a real lock, or restructuring so only one agent writes the object — Chapter 13.11's ownership habit, which costs nothing at runtime and is the one people skip.
5. The Whole Picture
6. One Sentence
The three non-guarantees look unrelated and are not. Read Figure 1's two halves:
The top half is about whether an access happens, and in what form. Is it emitted? At what width? In what order relative to other volatile accesses?
The bottom half is about what happens between two accesses, or inside one. Can something interleave with my increment? Can something run between my read and my write? Can ordinary work be moved across my register access?
That is the whole distinction:
volatileis about whether an access happens. It is not about what happens between two accesses.
Every one of the three non-guarantees is a question about between. Atomicity asks what can happen between the load and the store of a read-modify-write. Mutual exclusion asks what can happen between a read and a later write. Ordering asks whether other work can move across the boundary.
The qualifier has nothing to say about any of them, not because of an oversight but because Chapter 17.2 §1 derived its entire meaning from a rule about side effects being preserved — and preservation is a statement about each access individually.
7. The Misconception: volatile Is the Embedded Programmer's Thread Safety
"In embedded work you use
volatilefor anything shared — registers, flags, buffers. That is what it is for."
Half right, and the half that is wrong accounts for a large share of intermittent embedded faults.
It is for registers (Modules 16 and 17.3) and for a single flag written asynchronously (17.4 §5). Both are cases where the problem is the access not happening, which is the problem it solves.
It is not a sharing mechanism, and the replacements are specific:
| What you want | What volatile gives | What to use |
|---|---|---|
| a counter incremented from two contexts | three separate instructions | _Atomic and atomic_fetch_add |
| read-act-clear without interference | no protection at all | disable interrupts, a lock, or one owner |
| memory writes visible before a register write | nothing | a barrier — Chapter 17.6 |
The reason the folklore persists is that volatile is necessary in all three cases — remove it and you get the deleted-access bugs of Chapter 17.1 on top of everything else — so adding it genuinely does fix some bugs. It is necessary and not sufficient, and "it fixed the last one" is Chapter 13.2's reasoning that this curriculum has spent two parts dismantling.
The accurate position:
Use
volatilefor every memory-mapped access and for an asynchronously written flag. When two agents interact with an object,volatileis a prerequisite and never the mechanism.
8. What You Should Be Able to Say Now
- The three non-guarantees are atomicity, mutual exclusion, and ordering against ordinary accesses — deferred here five times across Modules 16 and 17.
- Not atomic. Measured:
v++on avolatile uint32_tisldr,add,str— three instructions, exactly 16.5's read-modify-write. The replacement is_Atomic/atomic_fetch_add. - Indivisibility was never claimed. 17.2 §1 derived the guarantee from accesses being side effects; nothing about that implies a single instruction.
- Not ordered against ordinary accesses. Measured: with a volatile store between two plain ones, one plain store was deleted and the source's ordering was not preserved.
- The dangerous shape is the DMA sequence — buffer writes then a start-bit write. The buffer writes are plain stores and nothing orders them.
- Not mutual exclusion. No measurement, because there is no lock in the definition. The read-act-clear sequence has gaps
volatilehas no opinion about. - No race trace is fabricated here or in 16.5 §4 — this machine cannot produce one, and an invented transcript is worth nothing.
- The dividing line:
volatileis about whether an access happens, not about what happens between two accesses. All three non-guarantees are questions about between. - Necessary, never sufficient. It is a prerequisite wherever two agents share an object, and never the mechanism.
9. Exercises
- Compile
v++on avolatile uint32_tat-O2and confirm the three instructions. Then write out the interleaving that loses an increment. - Reproduce §3's
mixedand confirm both effects. Add a fourth statement writing a different plain variable before the volatile store and report whether it survives and where it lands. - Write the DMA sequence from §3 and identify, line by line, which statements the compiler is free to move and which it is not.
- For §4's read-act-clear, write out the interleaving that loses an event. Say which chapter measured the hardware version of the same failure.
- Fill in Figure 1's three columns for a
volatile sig_atomic_tflag written by a signal handler. Which rows change from Chapter 17.4's pattern, and which do not? - For each row of §7's table, write the one-sentence justification for the replacement named.
- A colleague's fix for an intermittent fault was to add
volatileto a shared counter, and the fault became rarer. Explain, using this chapter and Chapter 13.2, why "rarer" is the expected outcome and why it is not evidence of a fix.
10. Summary
- Three non-guarantees, deferred five times and collected here: atomicity, mutual exclusion, ordering against ordinary accesses.
- Not atomic — measured:
v++isldr·add·str. Use_Atomic/atomic_fetch_add. - Not ordered against plain accesses — measured: a plain store deleted and the ordering lost across a volatile store. This is the DMA-buffer bug.
- Not mutual exclusion — no lock exists in the definition; nothing to measure. Use interrupt disable, a lock, or one owner.
- No fabricated race traces (same discipline as 16.5 §4).
- The line:
volatileis about whether an access happens, not what happens between accesses. All three gaps are between. - Necessary, never sufficient — and "adding it made the fault rarer" is 13.2's reasoning, not evidence.
The third gap is the only one of the three with a mechanism inside this module's scope, and it has a complication the other two do not: there are two separate things that can reorder your accesses — the compiler, and the machine — and they need different mechanisms, one of which emits no instruction 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.
