phase 1 embedded C toolchain

Why your embedded code breaks at -O2

Code that works perfectly at -O0 can silently malfunction at -O2 — no compiler warning, no obvious bug, just wrong behaviour. Here's what the optimizer actually does to variables shared between an ISR and application code, why volatile exists, and where it's still not enough.

Mohamed Ali Bessaidi 7 min read Phase 1, Week 2

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:

what -O2 generates (simplified)
; 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.

This is not a compiler bug

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:

with volatile
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:

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.

The torn read

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

← back
All posts