
Your firmware passes every test at -O0. You flip the switch to -O2 for the release build and suddenly the device hangs, corrupts data, or triggers a HardFault. The optimizer didn’t break your code — it exposed undefined behavior that was always there.
Engineers often blame the compiler: “It worked in debug, -O2 broke it.” The reality is different. The C standard gives compilers license to assume undefined behavior never occurs. At -O0, the compiler emits straightforward code that accidentally preserves the side effects of UB. At -O2, the optimizer reasons from the premise “this program has no UB” and transforms code in ways that are valid for correct programs but catastrophic for buggy ones.
// UB: signed overflow on 32-bit intint32_t accumulate(int32_t *buf, size_t len) {int32_t sum = 0;for (size_t i = 0; i < len; i++) {sum += buf[i]; // overflow here is UB}return sum;}
At -O0, sum wraps around predictably on two’s-complement hardware. At -O2, GCC may vectorize the loop, assume sum + buf[i] cannot overflow, and generate instructions that trap or produce garbage when it does. The bug isn’t the optimization — it’s the missing overflow check.
Signed overflow is the most common culprit. The C standard declares it undefined, meaning the compiler can assume it never happens.
// DANGEROUS: overflow is UBint32_t scale_sensor(int32_t raw, int32_t factor) {return raw * factor; // UB if result > INT32_MAX}// SAFE: check before multiplyint32_t scale_sensor_safe(int32_t raw, int32_t factor) {if (raw > INT32_MAX / factor) return INT32_MAX;if (raw < INT32_MIN / factor) return INT32_MIN;return raw * factor;}// ALSO SAFE: use unsigned for defined wraparounduint32_t scale_sensor_u32(uint32_t raw, uint32_t factor) {return raw * factor; // defined modulo 2^32}
Compiler behavior: At -O2, GCC may replace raw * factor with a single SMULL instruction that traps on overflow, or assume the result fits and omit checks entirely. The fix is explicit bounds checking or using unsigned types where wraparound is intentional.
Shifting by the type width or more is UB. The classic embedded bug: 1 << 31 on a 32-bit int.
// UB: 1 is signed int, 1<<31 sets sign bituint32_t mask = 1 << 31; // UB on 32-bit int// CORRECT: use unsigned literaluint32_t mask = 1u << 31; // defined// CORRECT: use fixed-width typesuint32_t mask = UINT32_C(1) << 31;// UB: right-shift negative value is implementation-definedint32_t bad_div2 = -5 >> 1; // implementation-defined// SAFE: use unsigned or divideint32_t safe_div2 = (int32_t)((uint32_t)-5 >> 1);
At -O2, the compiler may constant-fold 1 << 31 to 0 or INT32_MIN unpredictably, or delete code paths that depend on the result.
Accessing an object through an incompatible pointer type is UB. The optimizer assumes pointers of different types cannot alias.
// UB: type-punning via pointer castfloat f = 3.14f;uint32_t bits = *(uint32_t *)&f; // UB: strict aliasing violation// CORRECT: memcpy (compiler optimizes to single load)uint32_t bits;memcpy(&bits, &f, sizeof(bits));// CORRECT: union (C99/C11 guaranteed)union { float f; uint32_t u; } converter;converter.f = 3.14f;uint32_t bits = converter.u;
At -O2, GCC may reorder loads/stores assuming float* and uint32_t* never point to the same address, producing stale or corrupted values.
Reading an uninitialized local variable is UB. At -O0, stack memory often happens to be zero. At -O2, register allocation means the “value” is whatever was in the register.
// UB: 'flags' uninitializedvoid configure_peripheral(void) {uint32_t flags;if (some_condition) flags = ENABLE_BIT;REG->CR = flags; // garbage at -O2}// SAFE: always initializevoid configure_peripheral_safe(void) {uint32_t flags = 0;if (some_condition) flags = ENABLE_BIT;REG->CR = flags;}
Enable -Wuninitialized (included in -Wall) to catch these at compile time.
Buffer overreads/overwrites are UB. The optimizer may assume loop bounds are valid and eliminate checks.
// UB: potential overrunvoid process_packet(uint8_t *buf, uint16_t len) {for (uint16_t i = 0; i <= len; i++) { // off-by-one: i == len is OOBhandle_byte(buf[i]);}}// SAFE: bounds checkedvoid process_packet_safe(uint8_t *buf, uint16_t len) {for (uint16_t i = 0; i < len; i++) {handle_byte(buf[i]);}}
# Minimum viable warning set for embedded-Wall -Wextra \-Wshift-overflow=2 \-Wconversion \-Wsign-conversion \-Wnull-dereference \-Warray-bounds=2 \-Wstrict-overflow=5 \-Wuninitialized \-Wmaybe-uninitialized \-Wstrict-aliasing=2 \-Werror=implicit-function-declaration
Add -Werror in CI to treat warnings as errors. The -Wshift-overflow=2 catches 1 << 31 on signed types. -Wstrict-overflow=5 warns about optimizations that assume no signed overflow.
UBSan instruments operations with runtime checks. For embedded targets, use the trap-on-error mode to avoid libubsan dependency.
# Host-side unit testsgcc -fsanitize=undefined -fsanitize-undefined-trap-on-error \-fno-sanitize-recover=all test.c -o test# Target build (requires libubsan or trap mode)arm-none-eabi-gcc -fsanitize=undefined -fsanitize-undefined-trap-on-error \-fsanitize=integer-divide-by-zero -fsanitize=null \firmware.c -o firmware.elf
On Cortex-M, the trap emits a UDF instruction, triggering a UsageFault with UNDEFINSTR set in UFSR. Install a UsageFault handler that logs the PC and resets.
MISRA C:2012 rules directly target UB patterns:
Run a MISRA checker (PC-lint, MISRA++ for C, or commercial tools) in CI.
REG |= BIT is not undefined behavior — but it is a data race when an ISR touches the same register.
+--------------------------+ +--------------------------+| MAIN CONTEXT | | ISR CONTEXT |+--------------------------+ +--------------------------+| ldr r0, [REG] <--+ | || orr r0, r0, #BIT | | || | | (interrupt arrives) || | | ldr r1, [REG] || | | orr r1, r1, #OTHER_BIT || | | str r1, [REG] || str r0, [REG] | | || (stale write!) | | |+--------------------------+ +--------------------------+
ASCII diagram: Lost update on read-modify-write
Timeline:T0 Main: LOAD REG (0x00)T1 Main: OR 0x01 -> 0x01T2 ISR: LOAD REG (0x00)T3 ISR: OR 0x08 -> 0x08T4 ISR: STORE REG (0x08)T5 Main: STORE REG (0x01) <-- BIT 3 LOST!
Volatile does not fix this. It forces the load and store to happen, but cannot prevent the interrupt between them.
// RP2040: write to REG+0x2000 sets bits, +0x3000 clears#define REG_SET(addr, bits) (*(volatile uint32_t *)((addr) + 0x2000) = (bits))#define REG_CLR(addr, bits) (*(volatile uint32_t *)((addr) + 0x3000) = (bits))REG_SET(IRQ_EN_ADDR, 1u << 0); // single bus write, cannot be interrupted
// STM32: BSRR low 16 bits set, high 16 bits clearGPIOA->BSRR = (1u << 5); // set PA5GPIOA->BSRR = (1u << (5+16)); // clear PA5// Single write, atomic at bus level
// Cortex-M: PRIMASK disables all maskable interruptsstatic inline void irq_en_set_bit(uint32_t bit) {uint32_t pm = __get_PRIMASK();__disable_irq();IRQ_EN |= (1u << bit);__set_PRIMASK(pm);}
Keep the critical section to 3-5 instructions. Every cycle inside adds latency to all interrupts.
#include <stdatomic.h>atomic_uint_fast32_t shared_counter;void isr_increment(void) {atomic_fetch_add_explicit(&shared_counter, 1, memory_order_relaxed);}uint32_t main_read(void) {return atomic_load_explicit(&shared_counter, memory_order_acquire);}
On Cortex-M3/M4/M7, GCC implements this with LDREX/STREX loops. Do not use atomics for peripheral registers — exclusive access to device memory is generally unsupported.
# Debug build: no optimization, max diagnosticsset(CMAKE_C_FLAGS_DEBUG"-Og -g3 -Wall -Wextra -Wshift-overflow=2 -Wconversion-Wsign-conversion -Warray-bounds=2 -Wstrict-aliasing=2-fno-strict-aliasing" # temporary for legacy code)# Release build: optimization WITH diagnosticsset(CMAKE_C_FLAGS_RELEASE"-O2 -g -Wall -Wextra -Wshift-overflow=2 -Wconversion-Wsign-conversion -Warray-bounds=2 -Wstrict-aliasing=2-Werror=implicit-function-declaration")# Test build: UBSan trap modeset(CMAKE_C_FLAGS_UBSAN"-Og -g -fsanitize=undefined -fsanitize-undefined-trap-on-error-fno-sanitize-recover=all -Wall -Wextra")
Never ship -fno-strict-aliasing in production. It masks UB instead of fixing it. Use it only as a temporary bridge while refactoring type-punning code to memcpy or unions.
When -O2 breaks previously working code:
-O2 until you isolate the translation unit.objdump -d file.O0.elf vs objdump -d file.O2.elf — look for removed instructions, reordered memory ops, or new trapping instructions.-fsanitize=undefined -fsanitize-undefined-trap-on-error, reproduce, catch the UsageFault.| Symptom at -O2 | Likely UB Cause | Fix |
|---|---|---|
| Arithmetic gives wrong result | Signed overflow | Bounds check or use unsigned |
| Bit mask evaluates to 0 | Shift count >= width | Use 1u << n or UINT32_C(1) << n |
| Type-punned value corrupted | Strict aliasing | memcpy or union |
| Variable holds garbage | Uninitialized read | Initialize at declaration |
| Loop skips/extra iterations | OOB array access | Fix bounds, enable -Warray-bounds |
| ISR update lost | Data race (not UB) | Atomic alias, BSRR, PRIMASK, or C11 atomic |
The optimizer is not your enemy. It is a UB detector that runs at compile time. Every -O2 failure is a bug that existed at -O0 — you just got lucky. Fix the UB, keep the optimization, ship faster firmware.
-fsanitize=undefined usage — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html#index-fsanitize-undefinedQuick Links
Legal Stuff





