Why Your Polling Loop Hangs at -O2: The volatile Qualifier in Embedded C
Your UART polling loop works at -O0 but hangs at -O2. The culprit: missing volatile on memory-mapped registers. A worked example shows why volatile exists, how to apply it correctly, what it doesn't do, and how to verify the generated assembly.
28 Apr 2026, 15:06 UTC

The Problem: Code That Works Until You Optimize
You write a simple UART polling loop. It works perfectly at -O0. Then you enable -O2 for production, and the board sits there—no output, no errors, just a CPU spinning forever. The compiler didn't break your code; it optimized exactly what you told it to. The missing piece is volatile.
What volatile Actually Means
In C, volatile tells the compiler: "This object's value can change outside the visible flow of the program. Do not cache it in a register. Do not optimize away reads or writes. Every access in the source must appear in the generated code."
Without it, the compiler's data-flow analysis sees a loop like while (!(STATUS & READY)) and notices that STATUS is never modified in the function. It's legally allowed to hoist the load out of the loop, turning your polling into an infinite spin on a stale value.
Worked Example: UART Status Register Polling
Consider a typical Cortex-M microcontroller with a UART at base address 0x40000000. The status register is at offset 0x00, data register at 0x04. Bit 5 (0x20) is the TX-ready flag.
#include <stdint.h>
#define UART_BASE 0x40000000u
#define UART_STATUS (*(volatile uint32_t *)(UART_BASE + 0x00))
#define UART_DATA (*(volatile uint32_t *)(UART_BASE + 0x04))
#define TX_READY (1u << 5)
void uart_send_byte(uint8_t byte) {
while (!(UART_STATUS & TX_READY)) {
/* wait */
}
UART_DATA = byte;
}
The volatile on the pointer target (volatile uint32_t) forces a load from 0x40000000 on every loop iteration. Remove it, and -O2 typically produces a single load before the loop.
Interrupt-Shared Data: The Other Canonical Use
A flag set in an interrupt service routine (ISR) and polled in main-line code has the same problem—the compiler doesn't see the ISR as a caller.
volatile uint8_t packet_ready = 0;
void USART1_IRQHandler(void) {
if (USART1->SR & RXNE) {
packet_ready = 1;
}
}
int main(void) {
while (1) {
if (packet_ready) {
process_packet();
packet_ready = 0;
}
}
}
Without volatile on packet_ready, the main loop may never see the update at high optimization levels.
What volatile Does NOT Do
- Not atomicity: A 32-bit
volatileread on a 16-bit bus can tear. The hardware must guarantee atomic access width. - Not ordering: The compiler may reorder
volatileaccesses relative to non-volatile ones. Use memory barriers (__DMB()on ARM,__sync_synchronize()on GCC) for ordering. - Not a mutex: On multi-core systems,
volatileprovides no cache coherency or mutual exclusion.
Common Misuse Patterns
- Applying
volatileto ordinary variables "just in case" — this disables useful optimizations and masks design issues. - Using
volatilefor thread communication in RTOS tasks — use proper synchronization primitives (semaphores, queues). - Confusing pointer-to-volatile with volatile-pointer:
volatile uint32_t *p(the pointee is volatile, usually what you want) vsuint32_t *volatile p(the pointer itself is volatile).
Verifying the Generated Code
Don't guess—inspect the assembly. On ARM GCC:
arm-none-eabi-gcc -O2 -c uart.c -o uart.o
arm-none-eabi-objdump -d uart.o | grep -A10 "uart_send_byte"
Look for a load instruction (LDR) inside the loop body. At -O0 you'll see it regardless. At -O2 without volatile, the load moves before the loop or disappears entirely.
Test on target hardware or a cycle-accurate simulator (QEMU, Renode) at the optimization level you ship with.
Closing Checklist
- Mark memory-mapped register pointers as
volatile(the pointee, not the pointer). - Mark ISR-shared flags as
volatile. - Use
<stdint.h>fixed-width types matching the hardware register width. - Verify with
objdumpat your production optimization level. - Remember:
volatileis necessary but not sufficient for correct hardware access or concurrency.
If your polling loop hangs at -O2, check volatile first. It's the difference between "the compiler thinks this never changes" and "the hardware actually changes it."
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.