Welcome. This first module focuses on a fault line that matters in both firmware and driver work: code that appears to communicate safely between execution contexts but has no valid synchronization contract.
Over the next few lessons, you will move from C11 atomics and memory ordering to selecting locks and safely handling MMIO/DMA ownership transfers. Today’s goal is narrower and foundational: take a shared-state design that uses volatile as if it were a synchronization primitive, explain precisely why that fails, and correct it using C11 atomics.
What volatile does—and the critical things it does not do
In embedded code, volatile has a legitimate and important role. It tells the compiler that an access to an object is an observable side effect: it must actually perform that access rather than caching the value in a register, eliminating it, or merging it with another access.
That is exactly what you need for a memory-mapped device register. A status register can change because hardware changes it; a read must therefore reach the device each time.
#define UART_STATUS_ADDR 0x40001000u
volatile uint32_t * const uart_status =
(volatile uint32_t *)UART_STATUS_ADDR;
uint32_t status = *uart_status; /* Must perform a device read */
But an object being shared by threads, RTOS tasks, cores, or an ISR and thread is a concurrency problem, not merely an optimization problem. volatile supplies none of the guarantees needed to solve it:
| Requirement | Does volatile provide it? |
Why it matters |
|---|---|---|
| Force an individual load/store to occur | Yes | Appropriate for MMIO. |
Make x++ indivisible |
No | It is usually load, add, store. |
| Prevent two contexts from updating data simultaneously | No | Lost updates remain possible. |
| Establish a happens-before relationship | No | A reader has no valid basis for seeing a writer’s earlier payload writes. |
| Provide acquire/release ordering | No | It does not publish data to another context. |
| Make a C data race well-defined | No | A data race on non-atomic data is undefined behavior in C. |
The Siemens Embedded video makes the distinction in an embedded setting, including the familiar case where an interrupt occurs between the load and store of a volatile increment.
The Embedded Way - The volatile keyword in C
Watch “The Embedded Way - The volatile keyword in C” from Siemens Embedded to separate the valid MMIO use of volatile from its invalid use as thread or ISR synchronization.
Watch the basic purpose for why device registers and unexpectedly changing values need volatile. Then watch the concurrency limit, especially the generated load-add-store sequence for an increment and the interrupt interleaving that loses an update.
A useful interview sentence is:
volatiletells the compiler that an access is observable; atomics and synchronization primitives tell the language implementation and processor how concurrent accesses are allowed to interact.
Keep the two jobs separate. In particular, do not replace a device-register volatile pointer with a C atomic type, and do not make ordinary shared RAM safe merely by adding volatile.
The broken “ready flag” pattern
Consider a common one-producer/one-consumer message handoff. The producer fills in a payload, then sets a flag. The consumer waits for the flag and reads the payload.
#include <stdbool.h>
#include <stdint.h>
struct message {
uint32_t tick;
uint32_t parameter;
};
static struct message g_payload;
static volatile bool ready;
void producer(uint32_t tick, uint32_t parameter)
{
g_payload.tick = tick;
g_payload.parameter = parameter;
ready = true;
}
bool consumer(struct message *out)
{
while (!ready) {
/* spin */
}
*out = g_payload;
return true;
}
This code is wrong under the C memory model, even though it may appear to work in a particular build on a particular board.
There are two distinct defects:
-
The flag itself has a data race.
producer()writesready, whileconsumer()reads it, andreadyis non-atomic. Marking itvolatiledoes not change that. -
The payload has a data race too.
The producer writesg_payload, while the consumer reads it, with no happens-before relationship connecting those accesses. The apparent control dependency—“I only read after I sawready”—is not synchronization in C.
A C data race is not just an occasional wrong answer. It gives the program undefined behavior. The compiler is entitled to optimize based on the assumption that correctly written code does not have unsynchronized conflicting accesses. A successful test on x86, or at optimization level -O0, is not evidence that the design is valid for Cortex-A, Cortex-M multicore, or a production optimized build.
The atomic variables notes show both sides of the failure: an ordinary busy-wait loop can be compiled in a way that does not keep checking the flag, and a compiler/CPU may make the payload publication visible in an unsafe order.
[PDF] Atomic Variables and Operations - Virginia Tech
Read the relevant slides from Virginia Tech’s “Atomic Variables and Operations.” They connect the source-level ready-flag bug to generated code on x86 and ARM64, then contrast C11 atomics with volatile.
Start at the slide section “Recap: failure of busy-waiting ‘done’ flag check” (p. 5/13). Read the source and assembly surrounding the failure output. On p. 6/13, read the atomic source, then compare the ARM64 ordinary loads/stores with the acquire/release instructions on p. 7/13. Finally, read the “Addendum: the volatile keyword” on p. 12/13, focusing on the volatile addendum.
Do not focus on memorizing a particular assembly instruction such as mfence, stlrb, or ldarb. Those are implementation choices that vary by target and requested memory order. The design-level rule is what matters: a producer must publish its prior writes, and a consumer must acquire that publication before reading the associated data.
Correcting the handoff with C11 atomics
Use a C11 atomic object for the synchronization state, while keeping the payload as ordinary memory.
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
struct message {
uint32_t tick;
uint32_t parameter;
};
static struct message g_payload;
static atomic_bool ready = ATOMIC_VAR_INIT(false);
void producer(uint32_t tick, uint32_t parameter)
{
g_payload.tick = tick;
g_payload.parameter = parameter;
atomic_store_explicit(&ready, true, memory_order_release);
}
bool consumer(struct message *out)
{
while (!atomic_load_explicit(&ready, memory_order_acquire)) {
/* spin, or use an RTOS wait mechanism in a real design */
}
*out = g_payload;
return true;
}
This works under a specific protocol:
- There is one producer that writes the payload once.
- The producer does not modify that payload again after publishing
ready = true. - The consumer reads the payload only after its acquire load observes the value published by the producer.
- This is a one-shot handoff. Reusing the same buffer for multiple messages requires additional ownership or sequence-state design.
The important relationship is:
More formally:
- In the producer, the payload writes are sequenced before the release store.
- If the consumer’s acquire load reads the value stored by that release operation, the release store synchronizes with that acquire load.
- In the consumer, the acquire load is sequenced before the payload read.
- By transitivity, the producer’s payload writes happen before the consumer’s payload reads.
That last relationship is what removes the payload data race and makes the handoff meaningful.

The image’s “cones” are a helpful mental model:
- Everything before a release is published before the release event.
- Everything after a matching acquire can observe what was published.
- The connection only exists when the acquire operation reads the released value from the same atomic object.
Why the orders are different
A release store means: “do not let earlier reads or writes become visible as though they occurred after this publication.”
An acquire load means: “once this value is observed, do not let later reads or writes act as though they happened before observation.”
For this one-way handoff, memory_order_release and memory_order_acquire express the exact intent. C11’s default atomic operations are sequentially consistent, so this simpler version is also correct:
atomic_store(&ready, true);
while (!atomic_load(&ready)) {
}
However, explicit acquire/release is often preferable in driver and firmware reviews because it documents that ready is a publication flag, not part of a single global ordering requirement.
A common but incorrect “optimization” is this:
atomic_store_explicit(&ready, true, memory_order_relaxed);
paired with:
if (atomic_load_explicit(&ready, memory_order_relaxed)) {
*out = g_payload;
}
The flag accesses themselves are atomic, so the ready data race is removed. But relaxed operations do not establish the publication ordering needed for the non-atomic payload. Atomicity and ordering are separate properties.
Atomic counters solve a different shared-state problem
A ready flag publishes a collection of ordinary writes. A counter often needs an atomic read-modify-write operation instead.
This is unsafe:
static volatile uint32_t event_count;
void record_event(void)
{
event_count++;
}
The increment commonly expands conceptually to:
- Load
event_count - Add one
- Store the result
If an ISR and a thread, or two threads, interleave those steps, one increment can overwrite the other. volatile may ensure that each load and store happens, but it cannot make the three-step operation one indivisible update.
For a counter with no associated payload to publish, use an atomic read-modify-write with relaxed ordering:
#include <stdatomic.h>
#include <stdint.h>
static atomic_uint event_count = ATOMIC_VAR_INIT(0);
void record_event(void)
{
atomic_fetch_add_explicit(&event_count, 1, memory_order_relaxed);
}
memory_order_relaxed is appropriate here because the sole requirement is that increments are not lost. The counter is not being used to make other memory accesses visible.
CERT’s example contrasts the volatile lost-update problem with atomic updates.
CON43-C. Do not allow data races in multithreaded code | CERT Secure Coding
Read the CERT Secure Coding example as a compact demonstration that volatile does not make arithmetic updates safe, while an atomic object makes an atomic increment or decrement possible.
In “Noncompliant Code Example,” read the explanation beginning the lost update. Then move past the mutex alternative to “Compliant Solution (Atomic)” and read the atomic correction. Notice that this fixes independent increments and decrements; it does not automatically make a larger multi-step business rule atomic.
Even an atomic counter does not automatically protect a compound operation such as:
if (event_count < limit) {
event_count++;
}
The check and increment are still a larger protocol. They may require a compare-and-exchange loop or a lock, depending on the intended invariant. The key distinction for now is:
- Atomic load/store with acquire/release: publish or consume related data.
- Atomic read-modify-write: update one shared scalar without lost updates.
- Mutex or other higher-level synchronization: protect a multi-variable or multi-step invariant.
Embedded and driver review checklist
When reviewing a shared-state snippet, use this compact sequence:
- List every shared object. Include flags, counters, descriptor fields, and buffer contents—not just the obvious
readyvariable. - Find conflicting accesses. A write concurrent with a read or write requires synchronization unless the object is atomic and the protocol is valid.
- Identify the protocol. Is this publication, ownership transfer, counting, exclusion, or notification?
- Choose the primitive to match the protocol. Do not start by adding
volatile. - State the ordering edge. For publication: writes to data, release, acquire, reads from data.
- Check reuse and lifetime. A one-shot ready flag cannot safely describe repeated buffer reuse without further state.
- Treat hardware separately.
volatileis often needed for MMIO accesses, but device ordering, DMA visibility, cache maintenance, and barriers are separate concerns.
For bare-metal ISR handoffs, do not assume every C11 atomic operation is automatically suitable in every environment. An atomic type may not be lock-free, and an implementation that falls back to a library lock can be inappropriate in interrupt context. Use the RTOS or platform’s documented ISR-safe primitives where required; later lessons will make that choice systematic.
Key takeaways
volatileis for observable accesses such as MMIO. It does not provide atomicity, mutual exclusion, a happens-before relationship, or inter-thread memory ordering.- A
volatileready flag plus a non-atomic payload is still a data-racy C program. - For a one-way payload handoff, write the payload first, use a release store to publish the flag, then use an acquire load before consuming the payload.
- For independent scalar updates such as event counts, use an atomic read-modify-write operation; relaxed ordering is sufficient only when no associated data must be published.
- Correctness depends on the full ownership protocol, including whether and when a buffer is reused.
Next, you will analyze acquire-release traces more explicitly: given a weakly ordered processor and a set of loads and stores, you will determine which observed outcomes are permitted and which the synchronization edge forbids.