volatile in C: What It Actually Buys You (and What It Doesn't)
volatile forces real loads and stores for hardware registers and ISR-shared flags — but it gives you no atomicity, no ordering, and no thread safety. Here's how to use it correctly and verify the codegen.
09 Aug 2025, 02:52 UTC

Your firmware reads a status register in a loop, waiting for a hardware flag. It works with -O0 and hangs forever with -O2. The classic fix is volatile — but if you don't know why it fixes the problem, you'll eventually reach for it in places where it silently does nothing useful, like sharing data between threads. This piece sorts out what volatile actually guarantees, where it belongs, and where it will betray you.
The problem: compilers assume memory is boring
C's abstract machine lets the compiler assume that an object's value only changes when the program writes to it. That's what enables optimizations like common subexpression elimination (CSE) and loop-invariant code motion: if you read STATUS_REG twice with no write in between, the compiler is entitled to read it once and reuse the value.
Hardware breaks that assumption. A memory-mapped status register can change because a peripheral finished a transfer, not because your code wrote anything. An ISR (interrupt service routine) can update a flag between two reads on the main line. The compiler can't see either of these, so it optimizes the "redundant" loads away and your polling loop becomes an infinite loop over a cached register value.
What volatile guarantees
The volatile qualifier tells the compiler: every read in the source must become a real load, and every write must become a real store, in the order the abstract machine specifies. Concretely:
- No caching a volatile value in a register across statements.
- No deleting a store because the value "isn't read later."
- No merging or splitting accesses to the object.
This behavior has been stable from C89 through C11 and into C23 — it's one of the most portable tools in embedded C.
A worked example: polling a UART status register
Assume a 32-bit memory-mapped UART on a Cortex-M-class part, with a status register at 0x4000_1000 where bit 0 means "transmit buffer empty":
#include <stdint.h>
#define UART_STATUS (*(volatile uint32_t *)0x40001000u)
#define UART_TX_EMPTY (1u << 0)
void uart_wait_idle(void)
{
while ((UART_STATUS & UART_TX_EMPTY) == 0u) {
/* spin: each iteration must re-read the register */
}
}Without volatile, an optimizing compiler can hoist the load out of the loop and test a stale value forever. With it, each iteration emits a fresh load instruction.
How to verify: compile with arm-none-eabi-gcc -O2 -S uart.c (or your target's equivalent) and inspect the assembly. You should see a load instruction (e.g., ldr on ARM) inside the loop, not a single load before it. Run this on your build machine; no special permissions needed. The risk of skipping this check is real: a refactor that drops the qualifier compiles cleanly and only fails on hardware.
The same pattern applies to flags shared with an ISR:
static volatile uint8_t rx_done; /* written in ISR, read in main loop */
void USART_IRQHandler(void)
{
rx_done = 1u;
}Here volatile prevents the main loop from caching rx_done. On a single-core MCU where the ISR preempts the main line, this is usually sufficient for a single-byte flag.
Where volatile falls apart
Three limits matter, and the research consensus on them is firm:
- No atomicity. A
volatile uint32_ton a 16-bit bus can tear — one half updated, one half stale. Volatile says nothing about indivisible access. - No memory ordering. The compiler may reorder volatile accesses relative to non-volatile ones, and the CPU may reorder at the hardware level on weakly-ordered architectures. If your driver writes a DMA buffer (non-volatile) and then rings a doorbell register (volatile), you may need an explicit barrier.
- No cross-core synchronization. Volatile is not a concurrency primitive. For threads, use C11
_Atomictypes and<stdatomic.h>; for multi-core embedded, use your platform's barriers and mutexes. Code that "works" with volatile on a single-core AVR can fail on a dual-core ESP32 or an SMP Linux system.
There's also a cost: every volatile access is a real memory transaction. Blanket-qualifying a struct "just in case" defeats optimization and bloats code. Qualify the specific objects that hardware or an ISR can change behind the compiler's back — nothing more.
A decision rule you can apply today
Use volatile when exactly one of these holds: the object is a memory-mapped register, or it's shared between normal flow and an interrupt handler on a single core, and the access is naturally atomic on your target. For anything involving threads, multiple cores, or multi-step hardware protocols, reach for atomics, barriers, or a critical section instead. Then prove it: build at your production optimization level, read the generated assembly for the hot path, and confirm the loads and stores appear where the hardware needs them. That five-minute check catches the entire class of "works in debug, hangs in release" bugs before the board does.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.