Skip to content

Part 1 · The C Language30 min read

Undefined Behavior, volatile, and What the Optimizer Assumes

Sooner or later every firmware engineer meets the bug that only appears with optimization on. The code is “obviously correct”, it worked at -O0, and at -O2 a loop never exits or a null check vanishes. The compiler is almost never wrong in these cases. It is applying the rules of C more literally than you were. This lesson teaches those rules: the four categories of behavior the standard defines, how the optimizer uses “undefined behavior cannot happen” as a fact to reason from, the strict aliasing rule, the precise meaning of volatile (and the things people wrongly believe it does), what each optimization level turns on, and the tools that find these bugs for you.

The C standard sorts every construct into one of four categories. You need to know which one you are relying on.

CategoryMeaningExamples
Definedthe standard says exactly what happensunsigned arithmetic wraps modulo 2^N; 5 / 2 == 2
Implementation-definedthe compiler must pick a behavior and document itsize of int; whether plain char is signed; result of right-shifting a negative value; converting an out-of-range value to a signed type
Unspecifiedone of several allowed behaviors, not necessarily documented or consistentthe order in which function arguments are evaluated in f(a(), b()); whether two identical string literals share storage
Undefinedthe standard imposes no requirements at allsigned overflow; out-of-bounds access; dereferencing a null pointer; shifting by the type’s width or more; reading an uninitialized local; modifying a string literal; a data race; violating strict aliasing

Implementation-defined behavior is safe to use as long as you pin it down for your compiler and target. GCC on Arm documents that int is 32 bits, plain char is unsigned (unlike x86, where it is signed), and right shift of negative values is arithmetic. C standards, compiler extensions, and portable code lists the ones that matter.

Unspecified behavior is safe as long as your program’s result does not depend on the choice. f(read_byte(), read_byte()) depends on argument evaluation order, so it is a bug even though nothing undefined happens: GCC on Arm often evaluates the arguments right to left.

Undefined behavior (UB) is different in kind. It does not mean “the hardware does something weird”, it means the program has no meaning at all from the point of view of the language. The compiler is allowed to assume it never happens, and modern compilers rely on that assumption constantly to generate fast code.

The optimizer reasons from “UB cannot happen”

Section titled “The optimizer reasons from “UB cannot happen””

The optimizer does not hunt for UB to punish you. It proves facts about your program and uses them to remove work. “This expression cannot overflow” and “this pointer cannot be null” are facts it can derive from UB rules, and once it has them, it deletes code that would only matter if they were false.

#include <stdbool.h>
#include <stdint.h>
bool add_would_overflow(int32_t a)
{
return a + 100 < a; /* intended: detect overflow */
}

Mathematically a + 100 < a is false for every integer. In C, the only way it could be true is if a + 100 overflowed, and signed overflow is UB. So GCC at -O2 for Cortex-M4 compiles it to:

add_would_overflow:
movs r0, #0 @ always false
bx lr

The check is gone. The correct way to ask the question is to compare before doing the arithmetic, so no overflow ever happens:

#include <stdbool.h>
#include <stdint.h>
bool add_would_overflow(int32_t a, int32_t b)
{
return (b > 0 && a > INT32_MAX - b) || (b < 0 && a < INT32_MIN - b);
}

GCC and Clang also provide __builtin_add_overflow(a, b, &result), which returns whether the mathematically correct result fit and compiles to an add followed by a check of the overflow flag.

The same assumption shapes loops. In for (int32_t i = start; i <= end; i++), if end were INT32_MAX the loop could never terminate, because i would have to overflow first. The compiler assumes that cannot happen, concludes the loop runs exactly end - start + 1 times, and uses that trip count to unroll or restructure it. With an unsigned counter it has to allow for the wrap, which is one reason signed loop counters sometimes produce tighter code, and one reason “make it unsigned” is not a free fix.

#include <stddef.h>
#include <stdint.h>
typedef struct {
int32_t raw;
int32_t scale;
} sensor_t;
int32_t sensor_value(const sensor_t *s)
{
int32_t raw = s->raw; /* dereference first */
if (s == NULL) { /* check second: deleted */
return -1;
}
return raw * s->scale;
}

Dereferencing a null pointer is UB, so after s->raw the compiler knows s is not null and removes the if entirely. GCC calls this optimization -fdelete-null-pointer-checks, and it is on by default.

On a desktop the dereference would crash first, so the deleted check rarely matters. On an STM32 it matters a lot: address 0x00000000 is valid memory, an alias of the start of flash, holding the initial stack pointer. s->raw on a null s happily reads 0x20020000 or similar, no fault occurs, the check that would have caught it no longer exists, and the function returns garbage multiplied by whatever is at address 4 (the reset vector). Put checks before the first use, and consider -fno-delete-null-pointer-checks for firmware where address 0 is readable; the Linux kernel builds with it for the same reason.

#include <stdbool.h>
#include <stdint.h>
int32_t pick(bool ok)
{
int32_t result;
if (ok) {
result = 5;
}
return result; /* uninitialized when !ok */
}

The only path with defined behavior sets result to 5, so GCC at -O2 is entitled to emit movs r0, #5 / bx lr and ignore ok completely, and it typically does. At -O0 the function returns whatever was in that stack slot. Two builds of the same source now disagree, which is the signature of UB. GCC warns with -Wmaybe-uninitialized (part of -Wall), but only when optimization is on, because the analysis depends on optimization passes.

#include <stdbool.h>
#include <stdint.h>
static int32_t table[4];
bool in_table(int32_t v)
{
for (int32_t i = 0; i <= 4; i++) { /* off by one: reads table[4] */
if (table[i] == v) {
return true;
}
}
return false;
}

If v is not among the four real entries, the loop reads table[4], which is UB. So on every execution with defined behavior, the function returns true before reaching that iteration. The compiler is permitted to compile in_table to return true. GCC’s version of this, a loop that read one element past an array, became an infinite loop in the SPEC benchmark when GCC 4.8 introduced aggressive loop optimizations, and GCC now warns about the pattern when it can see it: iteration 4 invokes undefined behavior [-Waggressive-loop-optimizations].

#include <stddef.h>
#include <stdint.h>
#include <string.h>
void report_error(void);
void copy_frame(uint8_t *dst, const uint8_t *src, size_t n)
{
memcpy(dst, src, n);
if (src == NULL) { /* deleted: memcpy implies src != NULL */
report_error();
}
}

The standard says the pointer arguments of memcpy, memset and friends must be valid, even when n is 0. GCC knows this about its built-in memcpy, and C libraries also mark the parameters nonnull, so after the call src is assumed non-null and the check is removed. A change accepted for the next revision of C makes zero-length calls with null pointers defined, but C11 and C17 compilers are entitled to the assumption, so check for null before the call.

The strict aliasing rule (C11 6.5p7) says an object may only be accessed through an lvalue of:

  • its own type (or a const/volatile-qualified version of it),
  • the signed or unsigned variant of its type,
  • an aggregate or union that contains such a member,
  • or a character type (char, signed char, unsigned char, and therefore uint8_t).

Anything else is UB. The rule exists so the compiler can assume that pointers to different types do not point at the same memory, which lets it keep values in registers instead of reloading them after every store through some other pointer.

Here is the rule doing exactly that:

#include <stdint.h>
int32_t alias_demo(int32_t *i, float *f)
{
*i = 1;
*f = 0.0f;
return *i; /* compiler assumes *f did not change *i */
}
alias_demo:
movs r3, #1
movs r2, #0
str r3, [r0] @ *i = 1
str r2, [r1] @ *f = 0.0f (bit pattern 0)
movs r0, #1 @ return 1, no reload of *i
bx lr

If a caller passes the same address for both parameters, the function returns 1 although memory now holds 0. Compile with -fno-strict-aliasing and the last movs r0, #1 becomes ldr r0, [r0].

The firmware version of this bug is reading a float’s bit pattern, or decoding a received byte buffer as a struct:

#include <stdint.h>
uint32_t float_bits_bad(float f)
{
return *(uint32_t *)&f; /* UB: float accessed as uint32_t */
}

GCC warns about this one with -Wstrict-aliasing (in -Wall) when it can see both types. Usually it produces the right answer anyway, which is what makes it dangerous: inline it into a larger function and the compiler may reorder the read before the write of f.

There are two correct fixes. The first is memcpy, which accesses both objects as characters and is therefore always allowed:

#include <stdint.h>
#include <string.h>
uint32_t float_bits(float f)
{
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
}
float_bits:
vmov r0, s0 @ move the float's bits from s0 to r0
bx lr

That is the whole function: f arrives in the FPU register s0 under the hard-float ABI and the result leaves in r0. No call to memcpy, no memory access. The fixed-size memcpy is the idiom the compiler is designed to optimize.

The second fix is a union. C (unlike C++) explicitly allows reading a union member other than the one last written; the bytes are reinterpreted as the new type:

#include <stdint.h>
uint32_t float_bits_union(float f)
{
union {
float f;
uint32_t u;
} x = { .f = f };
return x.u;
}

For parsing received byte buffers, the safe pattern is to assemble fields with shifts, as shown in Bit manipulation, or to memcpy into a properly typed struct. Casting uint8_t * to struct header * violates strict aliasing and may produce unaligned accesses.

The big hammer is -fno-strict-aliasing, which tells GCC to assume any pointer can alias any other. The Linux kernel uses it. It costs a little performance, and it is a reasonable choice for code bases full of legacy casts, but it does not make the code portable to compilers without the flag, and it does nothing for concurrency.

volatile is the most misunderstood keyword in C. Here is what the standard actually guarantees, stated for firmware.

What volatile does:

  1. Every read and every write of a volatile object in the source is performed, exactly as many times as written. The compiler may not cache the value in a register, merge two writes, or delete a write whose value is never read.
  2. Volatile accesses are not reordered relative to each other. Two volatile writes happen in program order.

That is all, and it is exactly what memory-mapped registers need: reading USART2->SR twice must produce two bus reads, because the value changes underneath you, and writing DR must happen even though the program never reads the variable back.

What volatile does NOT do:

  1. It does not make anything atomic. reg |= 0x10 on a volatile is still a load, an orr and a store. An interrupt between the load and the store loses its own update. A volatile uint64_t is still read with two 32-bit loads and can tear.
  2. It does not order non-volatile accesses. The compiler may move ordinary loads and stores across a volatile access. Writing a buffer and then setting a volatile “ready” flag does not guarantee the buffer writes happen first.
  3. It is not a hardware memory barrier. No dmb or dsb is emitted. On a Cortex-M4 with no cache this rarely matters for plain RAM, but it matters for peripheral writes before wfi, after changing VTOR, and around DMA. The Cortex-M architecture covers when the barriers are needed.

This is the canonical example, and you will write it wrong at least once:

#include <stdbool.h>
static bool data_ready; /* missing volatile */
void USART2_IRQHandler(void)
{
/* ... read the byte, clear the flag ... */
data_ready = true;
}
void wait_for_data(void)
{
while (!data_ready) {
/* spin */
}
}

The compiler compiles wait_for_data by looking only at wait_for_data. Nothing inside the loop writes data_ready, there is no function call that might, and the variable is not volatile. So the value cannot change during the loop, and reading it once is enough. GCC at -O2 for Cortex-M4 produces:

wait_for_data:
ldr r3, .L7 @ r3 = address of data_ready
ldrb r3, [r3] @ read the flag ONCE
cbnz r3, .L1 @ already set? return
.L3:
b .L3 @ otherwise spin forever, never re-reading
.L1:
bx lr
.L7:
.word data_ready

The loop has become b .L3, a branch to itself. When the interrupt fires and sets data_ready, the CPU returns into this one-instruction loop and stays there forever. In the debugger, data_ready shows 1, and the program is stuck in a loop “waiting” for it. At -O0, every C expression is evaluated from memory, so the bug does not appear.

Now with the fix:

static volatile bool data_ready;
wait_for_data:
ldr r2, .L7 @ r2 = address of data_ready
.L3:
ldrb r3, [r2] @ re-read the flag on EVERY iteration
cmp r3, #0
beq .L3 @ still clear: loop
bx lr
.L7:
.word data_ready

The load is now inside the loop. (The exact labels and register choices vary between GCC versions; the structure does not. When the variable is accessed via a section anchor you will see .word .LANCHOR0 and an offset instead of the variable’s name.)

There is a second, subtler reason the volatile-free version is allowed to misbehave. C11 6.8.5p6 says a loop whose controlling expression is not a constant, and whose body performs no I/O, no volatile accesses and no atomic operations, may be assumed to terminate. An empty spin loop on a non-volatile flag has none of those, so the compiler may assume it ends. Accessing a volatile object makes the loop observable behavior and switches that freedom off.

Data plus flag: where volatile stops being enough

Section titled “Data plus flag: where volatile stops being enough”
#include <stdbool.h>
#include <stdint.h>
static uint8_t rx_frame[16];
static volatile bool rx_frame_ready;
void frame_isr_complete(const uint8_t *src) /* called from the ISR */
{
for (uint32_t i = 0u; i < 16u; i++) {
rx_frame[i] = src[i]; /* non-volatile stores */
}
rx_frame_ready = true; /* volatile store */
}
uint8_t frame_first_byte(void) /* called from main */
{
while (!rx_frame_ready) {
}
return rx_frame[0]; /* non-volatile load */
}

volatile on the flag guarantees the flag is re-read, but the stores to rx_frame are ordinary accesses that the compiler may legally move past the volatile store, and the load of rx_frame[0] could legally be performed before the loop. In this exact code GCC usually generates the order you wrote, so it works, until inlining or a compiler upgrade changes the schedule. The correct tools are a compiler barrier (__asm volatile("" ::: "memory")) or, better, C11 atomics with release and acquire ordering. That is the subject of Concurrency, atomics, and memory ordering. Making the whole buffer volatile also works, but it forbids every optimization on it.

Use volatile forDo not use volatile as
memory-mapped peripheral registersa substitute for disabling interrupts around read-modify-write
variables written by an ISR and read by main (single flags, counters of native width)a way to share multi-field structs safely
variables modified by DMA behind the compiler’s backa memory barrier for other data
locals you want to keep visible in the debugger at -O2 (temporary)a fix for a strict aliasing bug
delay loops that must not be optimized away (only for quick experiments)a thread synchronization primitive

Each -O level enables a bundle of individual optimization flags. You can see exactly which ones with arm-none-eabi-gcc -O2 -Q --help=optimizers.

LevelWhat it doesUse it for
-O0no optimization; every variable lives in memory, every statement is compiled separatelystepping through unfamiliar code; never for timing
-Ogoptimizations that do not hurt debuggingday-to-day debug builds of firmware
-O1basic optimizations: register allocation, dead code removal, simple constant propagationrarely chosen directly
-O2nearly everything that does not trade size for speed: inlining of small and static functions, strict aliasing, instruction scheduling, loop optimizationsrelease builds; the level most firmware ships with
-O3-O2 plus aggressive inlining, loop unrolling, vectorizationhot DSP loops, after measuring; grows code
-Os-O2 minus optimizations that grow codeflash-constrained parts, often nearly as fast as -O2 on Cortex-M because flash wait states punish large code
-Ozeven more aggressive size reduction (GCC 12 and later, Clang)very small parts
-Ofast-O3 plus -ffast-math: ignores NaN, infinities, signed zeros and strict IEEE roundingalmost never in firmware; it changes numerical results

The important point for this lesson: UB that is harmless at -O0 becomes visible at -O2 because -O2 enables the passes that reason. Inlining puts a function’s body into its caller, where the compiler can see more and prove more. Strict aliasing is enabled from -O2. Value range propagation, the pass that uses “signed overflow cannot happen”, needs optimization to run at all.

Treat it as a symptom, not a mystery. In order of likelihood, the cause is:

  1. A missing volatile on a register access, an ISR-shared variable, or a delay loop.
  2. Undefined behavior: an uninitialized variable, signed overflow, out-of-bounds index, or strict aliasing violation.
  3. A timing assumption: a busy-wait delay that is ten times shorter at -O2, or a peripheral polled before it is ready.
  4. A race between an ISR and main that the slower -O0 code happened to hide.
  5. Stack layout differences: an overflow that -O0’s larger frames hit in a harmless spot.
  6. A compiler bug. Possible, but last on the list for a reason.

A systematic approach:

  • Turn on every warning in Testing and debugging C on the host and fix them all at -O2. Several UB warnings only fire with optimization enabled.
  • If the logic can run on the host, build it with UBSan and ASan (below).
  • Try -fno-strict-aliasing. If the bug disappears, you have an aliasing violation. Try -fwrapv: if the bug disappears, you have signed overflow. Neither flag is the fix; each is a diagnosis.
  • Bisect by file: build half the source files at -O0 and half at -O2, and narrow down. Then bisect within the file using __attribute__((optimize("O0"))) on individual functions.
  • Compare the disassembly of the suspect function at both levels (arm-none-eabi-objdump -d -S). Look for loads that moved out of loops, checks that disappeared, and stores that vanished.

-fwrapv makes signed overflow defined: it wraps in two’s complement, like unsigned arithmetic. The add_would_overflow check from the start of the lesson then works as written. The cost is that the compiler loses the loop and range reasoning that relied on overflow being impossible, typically a few percent in loop-heavy code. Some projects enable it as a safety net. The risk is that code written for -fwrapv silently breaks when someone builds it without the flag.

-ftrapv makes signed overflow on addition, subtraction and multiplication call an abort routine. It is slow (each operation becomes a library call), has historically been incomplete, and is superseded by UBSan’s -fsanitize=signed-integer-overflow.

On the target itself, GCC can insert UBSan checks that execute a trap instruction instead of printing a report: -fsanitize=undefined -fsanitize-undefined-trap-on-error. A detected overflow then raises a UsageFault (escalating to HardFault if UsageFault is not enabled) at the exact instruction, which your fault handler from Watchdogs, faults, and recovering from them can report. It costs code size and speed, so it belongs in test builds.

The undefined behavior sanitizer (UBSan) and the address sanitizer (ASan) instrument your code at compile time so that UB is detected when it actually happens at runtime. They run on a host (Linux or macOS with GCC or Clang; Windows with Clang or MSVC for ASan), which is one more reason to keep logic testable on the host.

ub_demo.c
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
int main(int argc, char **argv)
{
(void)argv;
int big = INT_MAX;
int sum = big + argc; /* argc is 1: overflows */
uint32_t one = 1u;
uint32_t shift = 31u + (uint32_t)argc; /* 32 */
uint32_t mask = one << shift; /* shift by the full width */
printf("%d %u\n", sum, (unsigned)mask);
return 0;
}

Using argc stops the compiler from folding the values at compile time, so the errors happen at runtime where the sanitizer can see them.

Terminal window
gcc -std=c11 -Wall -Wextra -g -fsanitize=undefined ub_demo.c -o ub_demo
./ub_demo
ub_demo.c:9:19: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ub_demo.c:13:25: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
-2147483648 1

Each report gives file, line, column and the exact values. By default the program continues after a report; add -fno-sanitize-recover=undefined to make the first error abort, which is what you want in a test suite. Notice that the printed output looks “reasonable”, with a wrapped sum and a mask of 1. Without the sanitizer you would never know two lines invoked UB.

ASan catches memory errors: out-of-bounds reads and writes on the stack, heap and globals, use-after-free, and double free:

asan_demo.c
#include <stdint.h>
#include <stdio.h>
static void fill(uint8_t *buf, uint32_t n)
{
for (uint32_t i = 0u; i <= n; i++) { /* off by one */
buf[i] = 0xAAu;
}
}
int main(void)
{
uint8_t buf[4];
fill(buf, 4u);
printf("%u\n", (unsigned)buf[0]);
return 0;
}
Terminal window
gcc -std=c11 -Wall -Wextra -g -fsanitize=address asan_demo.c -o asan_demo
./asan_demo
==4242==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc2b1e0a34 at pc 0x55d0c4e0b2a1 bp 0x7ffc2b1e0a00 sp 0x7ffc2b1e09f0
WRITE of size 1 at 0x7ffc2b1e0a34 thread T0
#0 0x55d0c4e0b2a0 in fill asan_demo.c:7
#1 0x55d0c4e0b31c in main asan_demo.c:14
...
This frame has 1 object(s):
[32, 36) 'buf' (line 13) <== Memory access at offset 36 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow asan_demo.c:7 in fill

The addresses differ on every run; the important parts are the error type, the access size, the stack trace and the variable it overflowed. On a Cortex-M this write would have silently corrupted whatever sat next to buf. Testing and debugging C on the host folds both sanitizers into the unit test build.

The compiler can see many UB patterns statically. These are the warnings that pay for themselves:

WarningInCatches
-Wuninitialized, -Wmaybe-uninitialized-Wall (need -O1+)reads of uninitialized locals
-Wstrict-aliasing-Wall (with -fstrict-aliasing)type-punning pointer casts
-Wsequence-point-Walli = i++ and other unsequenced modifications
-Warray-bounds-Wall (need -O2)constant out-of-bounds indices
-Waggressive-loop-optimizationson by defaultloops that provably reach UB
-Wshift-count-overflow, -Wshift-count-negativeon by defaultconstant shifts by the width or more
-Wshift-negative-value-Wextraleft shift of a negative constant
-Wreturn-local-addron by defaultreturning the address of a local
-Wnull-dereferencenot in -Wallpaths the compiler proves dereference null (-O2)
-fanalyzerseparate flag (GCC 10+)interprocedural use-after-free, null deref, leaks

Compile with optimization when you want the full set of warnings; several of them depend on analysis that only runs at -O1 and above.

Two widely used coding standards exist largely to keep code away from UB and implementation-defined behavior. MISRA C, from the automotive industry and now used in most safety-related firmware, restricts the language: no dynamic memory, no recursion, explicit conversions between “essential types”, every switch with a default, and so on. CERT C, from the Software Engineering Institute, is organized around security and names rules after the problem they prevent, such as INT32-C “ensure that operations on signed integers do not result in overflow”. Static analysis tools check both automatically. You will meet them properly in Safety, standards, and coding guidelines; for now, know that most of their rules are this lesson’s material, turned into checkable requirements.

  • Overflow check written after the overflow. if (a + b < a) with signed types is deleted. Fix: check against INT32_MAX - b before adding, or use __builtin_add_overflow.
  • Null check after the first dereference. The compiler removes it. Fix: check before the first use.
  • ISR-shared flag without volatile. The loop reads it once and spins forever at -O2. Fix: volatile, and atomics when data accompanies the flag.
  • Believing volatile makes x++ or reg |= bit atomic. Fix: critical section, BSRR-style set/clear registers, or C11 atomics.
  • Casting byte buffers to struct or uint32_t pointers. Strict aliasing violation and possible unaligned access. Fix: memcpy or assemble with shifts.
  • Accepting “works at -O0” as proof. Fix: develop and test at the optimization level you ship.
  • Shipping with -fwrapv or -fno-strict-aliasing to hide a bug. Fix: use them to diagnose, then fix the code.
  1. In Compiler Explorer, with arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb, compile wait_for_data with and without volatile. Identify the self-branch in the non-volatile version and the load inside the loop in the volatile one. Then add a call to an external function inside the loop (without volatile) and explain why the load comes back.
  2. Compile alias_demo with and without -fno-strict-aliasing and find the single instruction that differs. Write a main on your host that calls it with the same address for both arguments and prints the result under both flags.
  3. Build ub_demo.c and asan_demo.c with the sanitizers on your host and confirm the reports. Then compile ub_demo.c without the sanitizer at -O2 and explain why the output is identical even though the program has UB.
  4. Write bool add_i32_checked(int32_t a, int32_t b, int32_t *out) that returns false instead of overflowing, without using any builtin. Test it with INT32_MAX + 1, INT32_MIN + -1, and -5 + 3. Compare its -O2 assembly with a version using __builtin_add_overflow.
  5. Take pick() and in_table() from this lesson, compile them at -O0 and -O2 for the target, and read the disassembly of each. Report whether your GCC version exploits the UB in each function, and which warnings appear at each level.

Question 1:At -O2, GCC compiles bool f(int32_t x) { return x + 1 > x; } to return true. Why is that legal?

Question 2:An ISR sets static bool done = false; to true. main runs while (!done) {} and hangs at -O2. In the debugger done reads 1. What happened?

Question 3:Which statement about volatile is true?

Question 4:Which is a correct, warning-free way to get the bit pattern of a float f as a uint32_t?

Question 5:A function reads s->len and then checks if (s == NULL) return;. On an STM32F407, what happens when s is null at -O2?

Question 6:A bug disappears when you add -fno-strict-aliasing. What should you conclude?

  • Implementation-defined behavior is documented and safe once pinned down; unspecified behavior is safe if your result does not depend on it; undefined behavior means the program has no meaning and the compiler may assume it never happens.
  • The optimizer turns “UB cannot happen” into facts: no signed overflow, no null after a dereference, no out-of-bounds access. Checks that only matter when those facts are false get deleted.
  • Strict aliasing forbids accessing an object through an unrelated pointer type except via character types; use memcpy (free after optimization) or a union for type punning.
  • volatile guarantees every access happens, in order relative to other volatile accesses. It is not atomic, not a memory barrier, and does not order ordinary accesses.
  • A non-volatile flag polled in a loop becomes a load once followed by b . at -O2; this is the classic firmware hang.
  • “Works at -O0, fails at -O2” is almost always missing volatile, UB, timing assumptions or a race; use warnings, sanitizers, -fno-strict-aliasing/-fwrapv as diagnostics, and file-level bisection.

Next: The C standard library on a microcontroller, where you find out what printf really costs and how to make it work over a UART.