Using volatile in Embedded C: Stop the Compiler from Caching Your Hardware Flags
The volatile keyword prevents compiler optimizations that break interrupt flags and memory-mapped I/O, but it does not guarantee atomic access.
06 Nov 2026, 20:47 UTC

You write a loop that waits for a hardware flag. It works in simulation. On the board, the CPU spins forever. The bug is usually not the hardware or your logic; it is the compiler caching a value that the hardware or an interrupt can change.
In embedded C, volatile tells the compiler that a variable may change at any time by something outside the current thread of execution. The compiler must then generate an actual memory load or store for every reference instead of keeping the value in a CPU register.
The Problem: Register Caching in a Polling Loop
Compilers optimize by loading a variable into a register once and reusing it. That is safe for ordinary variables, but dangerous for flags shared with an Interrupt Service Routine (ISR) or for memory-mapped I/O (MMIO) registers, where hardware updates bits independently.
Consider this pattern:
int data_ready = 0;
void wait_for_data(void) {
while (data_ready == 0) {
/* wait */
}
/* process data */
}
Because wait_for_data never writes to data_ready, the compiler may load it once before the loop and branch forever on the register copy. An ISR that sets data_ready = 1 in RAM never breaks the spin.
Where volatile Is Required
Declare a variable volatile when its value can change outside the normal flow of the surrounding code:
- Flags set inside an ISR and read in the main loop.
- Memory-mapped hardware registers, such as UART status bits or timer counters.
- Variables modified by direct memory access (DMA) controllers.
MMIO means the CPU accesses hardware devices by reading and writing ordinary memory addresses. A status register at a fixed address may change because a byte arrived on a wire, not because your C code assigned to it.
Worked Example: UART Status Register
This example polls a UART status register until a receive-ready bit is set. The cast forces the compiler to treat a fixed address as a volatile object.
#include
#define UART_BASE 0x4000C000UL
#define UART_STATUS (*(volatile uint32_t *)(UART_BASE + 0x04UL))
#define UART_DATA (*(volatile uint32_t *)(UART_BASE + 0x00UL))
#define RX_READY (1UL << 5)
uint8_t receive_byte(void) {
while ((UART_STATUS & RX_READY) == 0) {
/* The volatile qualifier forces a reload of UART_STATUS
on every iteration. Without it, the compiler may load
the register once and loop forever on a stale value. */
}
return (uint8_t)(UART_DATA & 0xFFUL);
}
To verify, compile to assembly and inspect the loop. With the GNU ARM embedded toolchain, run:
arm-none-eabi-gcc -Os -S main.c -o main.s
Run this on a host with the cross-compiler installed; no special permissions are required. Replace main.c with your source file. In main.s, look for a load instruction such as LDR inside the polling loop. If the load appears only before the loop, the compiler has cached the register value and the driver will hang on real hardware.
Risk: using the wrong target triple or optimization level can produce assembly that does not match your production build, so verify with the same flags you use for the target.
Limitation: Volatile Is Not Atomicity
volatile prevents register caching and instruction removal. It does not make a read or write atomic, nor does it act as a memory barrier. On a 32-bit MCU, an 8-bit main loop reading a 32-bit timer updated by an ISR can observe a torn value if the interrupt fires between byte reads.
For multi-byte shared variables, combine volatile with a critical section: disable interrupts around the read, or use the atomic types provided by your compiler or RTOS. Do not use volatile as a substitute for a mutex in multi-core systems where caches can reorder memory operations.
Overusing volatile also hurts performance because every access becomes a real memory transaction, preventing the compiler from keeping hot variables in registers.
Practical Checklist
- Mark ISR flags and MMIO pointers
volatile. - Inspect generated assembly with
-Sto confirm loads appear inside polling loops. - Use critical sections or atomic types for variables wider than one byte.
- Profile before applying
volatileto performance-sensitive data that is not hardware-related.
Treat volatile as a correctness tool for hardware-facing code, not a general performance knob. When the compiler is allowed to optimize ordinary variables but forced to touch memory for device registers and interrupt flags, your polling loops will see the same reality the hardware sees.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.