The problem
Early in Phase 1, I wrote what looked like a trivially correct flag-based communication between a timer ISR and my main loop:
uint8_t g_flag = 0;
void TIM2_IRQHandler(void) {
TIM2->SR &= ~TIM_SR_UIF;
g_flag = 1;
}
int main(void) {
/* ... init ... */
while (1) {
if (g_flag) {
g_flag = 0;
do_something();
}
}
}
At -O0 this works. The main loop checks g_flag on every
iteration, the ISR sets it, do_something() gets called. Exactly as written.
At -O2, do_something() never gets called. The flag is set
by the ISR, I verified it in GDB but main never sees it. No warning. No error. Just
silent and wrong behaviour which made me dig deeper.
What the compiler actually does
The optimizer's job is to make code faster. One of its most powerful tools is load elimination: if a variable is read multiple times and the compiler can prove it hasn't changed between reads, it loads it once into a register and reuses that register value. No repeated memory access.
From the compiler's perspective, g_flag is a global variable that nothing
in the main() function's call graph ever writes to. The compiler cannot see
into the ISR as interrupt handlers are not called from application code but invoked
by hardware. The optimizer legally concludes: this variable never changes during the
while loop, so I'll load it once and keep the value in a register.
The resulting machine code for the while loop looks approximately like this:
; load g_flag once into r0 LDR r0, [pc, #offset_of_g_flag] loop: CMP r0, #0 ; compare register value — NOT memory BEQ loop ; if zero, loop back ; ... do_something ... B loop
The ISR writes 1 to the memory address of g_flag. But main
is comparing a stale register value. The write never reaches the comparison. The program
is stuck.
The C standard says a program with data races on non-atomic, non-volatile variables
has undefined behaviour. The optimizer is allowed to assume undefined behaviour doesn't
happen, which means it's allowed to assume that no other execution context modifies
g_flag between reads. From the standard's point of view, the compiler
is correct and the program is wrong.
What volatile does and what it doesn't
Adding volatile tells the compiler: every read and write to this variable
must go through memory. Do not cache the value in a register. Do not reorder or
eliminate accesses.
volatile uint8_t g_flag = 0; /* fix */
Now the optimizer generates a memory load on every loop iteration:
loop: LDR r0, [pc, #offset_of_g_flag] ; load from memory every time CMP r0, #0 BEQ loop ; ... do_something ... B loop
The ISR's write is now visible to main. Problem solved! but for this specific case :) .
What volatile doesn't do
volatile is not a synchronisation primitive. It prevents the compiler from
optimising away accesses, but it does not:
- Guarantee atomicity: a 32-bit read on a Cortex-M0 is not atomic if it crosses a word boundary
- Prevent reordering by the CPU: irrelevant on Cortex-M (in-order pipeline) but relevant on Cortex-A
- Prevent the compiler from reordering other operations around the volatile access
- Replace a mutex when two tasks in an RTOS both read and write the same variable
For a single-writer (ISR) / single-reader (main) flag on a Cortex-M, volatile
is sufficient. For anything more complex: a 64-bit counter, a struct or a shared buffer for example
you need more.
When volatile isn't enough: the 32-bit counter case
Consider a tick counter incremented in a SysTick ISR and read by application code:
volatile uint32_t g_tick = 0;
void SysTick_Handler(void) {
g_tick++;
}
uint32_t App_GetTick(void) {
return g_tick; /* is this safe? */
}
On a Cortex-M4, a uint32_t read is a single LDR instruction meaning it is
atomic on a word-aligned address. This is safe. The read either happens before or after
the increment, never in the middle of it.
Now consider a 64-bit counter:
volatile uint64_t g_tick_us = 0;
void TIM2_IRQHandler(void) {
g_tick_us += 10; /* 10µs per tick */
}
uint64_t App_GetTickUs(void) {
return g_tick_us; /* NOT safe */
}
A 64-bit read on a 32-bit CPU compiles to two LDR instructions: one for
the low word, one for the high word. If the ISR fires between those two loads, the
application reads a low word from the new value and a high word from the old value.
The result is a completely wrong timestamp.
App reads low word: 0xFFFFFFFF (about to overflow)
ISR fires: g_tick_us goes from 0x00000000FFFFFFFF to 0x0000000100000009
App reads high word: 0x00000001
App sees: 0x00000001FFFFFFFF which is wrong by ~4 billion microseconds
The fix is to disable interrupts around the 64-bit read:
uint64_t App_GetTickUs(void) {
uint64_t val;
__disable_irq();
val = g_tick_us;
__enable_irq();
return val;
}
Or use a copy-with-retry pattern that detects the torn read without disabling interrupts:
uint64_t App_GetTickUs(void) {
uint64_t a, b;
do {
a = g_tick_us;
b = g_tick_us;
} while (a != b); /* retry if ISR modified between the two reads */
return a;
}
Verifying it yourself: Compiler Explorer
The fastest way to build intuition about what the optimizer does is to look at the generated assembly directly. Compiler Explorer (godbolt.org) for instance lets you paste C code and see the output for any GCC version and optimization level instantly with no toolchain setup.
Try this: paste the original flag example without volatile, set the compiler
to ARM GCC 1X.y with -O2 -mcpu=cortex-m4, and look at the
generated loop. You'll see the load hoisted out. Add volatile and watch the
load move back inside the loop. It takes about 30 seconds and teaches more than reading
about it for an hour.
What MISRA-C says
MISRA-C 2012 Rule 8.6 requires that objects shared between an ISR and other code be
declared volatile. This is one of the few MISRA rules that is both mandatory
and obviously correct. It maps directly to a class of real bug that has caused production
failures in safety-critical systems.
MISRA also warns against using volatile as a substitute for proper
synchronisation. Rule 1.3 (undefined behaviour) and the advisory guidance around
concurrent access. The rule is: use volatile for ISR-shared variables,
use disable-irq or RTOS primitives for anything that requires atomicity beyond a
single machine word.
Takeaways
- Any variable modified in an ISR and read in application code must be
volatile. Without it, -O1 and above may silently cache the value in a register. volatileprevents compiler caching. It does not provide atomicity, memory ordering, or mutual exclusion.- A single
uint32_tread/write is atomic on Cortex-M (word-aligned). Auint64_tis not. It takes two loads/stores. - For anything beyond a simple flag: disable interrupts around the critical section, or use a copy-with-retry pattern for read-only access.
- Use Compiler Explorer with
-O2whenever you're unsure what the optimizer is doing to your code. It's the fastest feedback loop available.