HomeAbout UsContact Us

Fixing Embedded C Code That Breaks With -O2 Optimization

By Jithin Tom
Published in Embedded C/C++
November 12, 2026
3 min read
Fixing Embedded C Code That Breaks With -O2 Optimization

Table Of Contents

01
The Optimization Paradox
02
The Five UB Patterns That Break Under Optimization
03
Detecting UB Before Release
04
The Register Update Race: Not UB, But Broken Anyway
05
Build Configuration That Catches UB
06
Debugging a Release Build Failure
07
Summary
08
Related Reading
09
References
10
Frequently Asked Questions

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.

The Optimization Paradox

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 int
int32_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.

The Five UB Patterns That Break Under Optimization

1. Signed Integer Overflow

Signed overflow is the most common culprit. The C standard declares it undefined, meaning the compiler can assume it never happens.

// DANGEROUS: overflow is UB
int32_t scale_sensor(int32_t raw, int32_t factor) {
return raw * factor; // UB if result > INT32_MAX
}
// SAFE: check before multiply
int32_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 wraparound
uint32_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.

2. Shift Count Out of Range

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 bit
uint32_t mask = 1 << 31; // UB on 32-bit int
// CORRECT: use unsigned literal
uint32_t mask = 1u << 31; // defined
// CORRECT: use fixed-width types
uint32_t mask = UINT32_C(1) << 31;
// UB: right-shift negative value is implementation-defined
int32_t bad_div2 = -5 >> 1; // implementation-defined
// SAFE: use unsigned or divide
int32_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.

3. Strict Aliasing Violations

Accessing an object through an incompatible pointer type is UB. The optimizer assumes pointers of different types cannot alias.

// UB: type-punning via pointer cast
float 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.

4. Uninitialized Automatic Variables

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' uninitialized
void configure_peripheral(void) {
uint32_t flags;
if (some_condition) flags = ENABLE_BIT;
REG->CR = flags; // garbage at -O2
}
// SAFE: always initialize
void 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.

5. Out-of-Bounds Array Access

Buffer overreads/overwrites are UB. The optimizer may assume loop bounds are valid and eliminate checks.

// UB: potential overrun
void process_packet(uint8_t *buf, uint16_t len) {
for (uint16_t i = 0; i <= len; i++) { // off-by-one: i == len is OOB
handle_byte(buf[i]);
}
}
// SAFE: bounds checked
void process_packet_safe(uint8_t *buf, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
handle_byte(buf[i]);
}
}

Detecting UB Before Release

Compiler Warnings (Static)

# 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.

Undefined Behavior Sanitizer (Runtime)

UBSan instruments operations with runtime checks. For embedded targets, use the trap-on-error mode to avoid libubsan dependency.

# Host-side unit tests
gcc -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 and Static Analysis

MISRA C:2012 rules directly target UB patterns:

  • Rule 10.1: No implicit conversions that change signedness
  • Rule 10.3: No composite expressions with mixed signedness
  • Rule 12.2: Right-hand operand of shift between 0 and width-1
  • Rule 18.1: Pointers not cast to incompatible types

Run a MISRA checker (PC-lint, MISRA++ for C, or commercial tools) in CI.

The Register Update Race: Not UB, But Broken Anyway

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 -> 0x01
T2 ISR: LOAD REG (0x00)
T3 ISR: OR 0x08 -> 0x08
T4 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.

Four Correct Patterns

1. Hardware Atomic Aliases (RP2040, etc.)

// 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

2. Dedicated Set/Clear Registers (STM32 GPIOx_BSRR)

// STM32: BSRR low 16 bits set, high 16 bits clear
GPIOA->BSRR = (1u << 5); // set PA5
GPIOA->BSRR = (1u << (5+16)); // clear PA5
// Single write, atomic at bus level

3. Critical Section (Portable)

// Cortex-M: PRIMASK disables all maskable interrupts
static 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.

4. C11 Atomics for Shared RAM

#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.

Build Configuration That Catches UB

CMake Example

# Debug build: no optimization, max diagnostics
set(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 diagnostics
set(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 mode
set(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.

Debugging a Release Build Failure

When -O2 breaks previously working code:

  1. Bisect the optimization flags: Compile individual files at -O2 until you isolate the translation unit.
  2. Compare assembly: objdump -d file.O0.elf vs objdump -d file.O2.elf — look for removed instructions, reordered memory ops, or new trapping instructions.
  3. Enable UBSan on target: Rebuild the failing module with -fsanitize=undefined -fsanitize-undefined-trap-on-error, reproduce, catch the UsageFault.
  4. Check the map file: Look for variables placed adjacent to buffers that might overflow.
  5. Audit the failing function for the five UB patterns above.

Summary

Symptom at -O2Likely UB CauseFix
Arithmetic gives wrong resultSigned overflowBounds check or use unsigned
Bit mask evaluates to 0Shift count >= widthUse 1u << n or UINT32_C(1) << n
Type-punned value corruptedStrict aliasingmemcpy or union
Variable holds garbageUninitialized readInitialize at declaration
Loop skips/extra iterationsOOB array accessFix bounds, enable -Warray-bounds
ISR update lostData 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.

References

  1. ISO/IEC 9899:2018 (C18), §6.5 Expressions — undefined behavior cases J.2 — https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3974.htm
  2. GCC Documentation, “Options for Debugging Your Program” — -fsanitize=undefined usage — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html#index-fsanitize-undefined
  3. ARM Cortex-M Programming Guide to Memory Barriers, DAI0321A — PRIMASK/LDREX/STREX usage — https://support.arm.com/documentation/dai0321/a/DAI0321A_programming_guide_memory_barriers_for_m_profile.pdf
  4. RP2040 Datasheet, §2.1.2 — Atomic register aliases (SET/CLR/XOR) — https://pip.raspberrypi.com/documents/RP-008371-DS-rp2040-datasheet.pdf
  5. STM32F4xx Reference Manual RM0090, §8.4.7 — GPIOx_BSRR set/clear register — https://www.st.com/content/ccc/resource/technical/document/reference_manual/3d/6d/5a/66/b4/99/40/d4/DM00031020.pdf/files/DM00031020.pdf/jcr:content/translations/en.DM00031020.pdf
  6. MISRA C:2012 Guidelines, Rules 10.1, 10.3, 12.2, 18.1 — UB prevention — https://www.misra.org.uk/
  7. Regehr et al., “A Guide to Undefined Behavior in C and C++”, Part 1-3 — https://blog.regehr.org/archives/213
  8. Memfault Blog, “Undefined Behavior Sanitizer Trap on Error” — embedded UBSan deployment — https://interrupt.memfault.com/blog/ubsan-trap

Frequently Asked Questions

Why does my embedded C code work at -O0 but fail at -O2?

At -O0 the compiler generates literal, unoptimized code that accidentally preserves undefined behavior side effects. At -O2, the optimizer assumes undefined behavior never occurs and removes or reorders code based on that assumption, exposing latent bugs like signed overflow, strict aliasing violations, or uninitialized reads.

What are the most common undefined behaviors that optimization exposes in embedded code?

The top five are: signed integer overflow, out-of-bounds array access, strict aliasing violations (type-punning through incompatible pointers), uninitialized local variables, and shift operations with invalid counts (e.g., 1 << 31 on 32-bit int).

How do I catch undefined behavior before it reaches production?

Compile with -Wall -Wextra -Wshift-overflow -Wconversion for static detection. For runtime detection, use -fsanitize=undefined -fsanitize-undefined-trap-on-error in test builds (host or target with libubsan). Run MISRA C static analysis and enable compiler warnings as errors in CI.

Does volatile fix optimization-related bugs?

No. Volatile only prevents the compiler from caching a variable in a register or eliminating apparently redundant reads/writes. It does not make undefined behavior defined, nor does it provide atomicity for read-modify-write sequences on peripheral registers or shared memory.

What is the safest way to update peripheral register bits without losing concurrent updates?

Use hardware atomic aliases (RP2040 SET/CLR/XOR register mirrors), dedicated set/clear registers (STM32 GPIOx_BSRR), or a short critical section with PRIMASK on Cortex-M. For shared RAM variables, use C11 <stdatomic.h> with memory_order_relaxed or stronger.

Tags

embedded-cundefined-behavioroptimizationgccclangcortex-mdebugging

Share


Previous Article
Fixing Flaky CI Pipelines in Embedded Firmware
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Linker Script Mastery: Memory Layout for Embedded Firmware
Linker Script Mastery: Memory Layout for Embedded Firmware
October 24, 2026
5 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media