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.
Four kinds of behavior
Section titled “Four kinds of behavior”The C standard sorts every construct into one of four categories. You need to know which one you are relying on.
| Category | Meaning | Examples |
|---|---|---|
| Defined | the standard says exactly what happens | unsigned arithmetic wraps modulo 2^N; 5 / 2 == 2 |
| Implementation-defined | the compiler must pick a behavior and document it | size of int; whether plain char is signed; result of right-shifting a negative value; converting an out-of-range value to a signed type |
| Unspecified | one of several allowed behaviors, not necessarily documented or consistent | the order in which function arguments are evaluated in f(a(), b()); whether two identical string literals share storage |
| Undefined | the standard imposes no requirements at all | signed 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.
Signed overflow
Section titled “Signed overflow”#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 lrThe 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.
Null check after dereference
Section titled “Null check after dereference”#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.
Uninitialized reads
Section titled “Uninitialized reads”#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.
Out-of-bounds access
Section titled “Out-of-bounds access”#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].
memcpy with a null pointer
Section titled “memcpy with a null pointer”#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.
Strict aliasing
Section titled “Strict aliasing”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 thereforeuint8_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 lrIf 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 lrThat 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, precisely
Section titled “volatile, precisely”volatile is the most misunderstood keyword in C. Here is what the standard actually guarantees, stated for firmware.
What volatile does:
- 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.
- 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:
- It does not make anything atomic.
reg |= 0x10on a volatile is still a load, anorrand a store. An interrupt between the load and the store loses its own update. A volatileuint64_tis still read with two 32-bit loads and can tear. - 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.
- It is not a hardware memory barrier. No
dmbordsbis emitted. On a Cortex-M4 with no cache this rarely matters for plain RAM, but it matters for peripheral writes beforewfi, after changingVTOR, and around DMA. The Cortex-M architecture covers when the barriers are needed.
The busy-wait flag
Section titled “The busy-wait flag”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_readyThe 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_readyThe 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.
When to use volatile
Section titled “When to use volatile”Use volatile for | Do not use volatile as |
|---|---|
| memory-mapped peripheral registers | a 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 back | a 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 |
Optimization levels
Section titled “Optimization levels”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.
| Level | What it does | Use it for |
|---|---|---|
-O0 | no optimization; every variable lives in memory, every statement is compiled separately | stepping through unfamiliar code; never for timing |
-Og | optimizations that do not hurt debugging | day-to-day debug builds of firmware |
-O1 | basic optimizations: register allocation, dead code removal, simple constant propagation | rarely chosen directly |
-O2 | nearly everything that does not trade size for speed: inlining of small and static functions, strict aliasing, instruction scheduling, loop optimizations | release builds; the level most firmware ships with |
-O3 | -O2 plus aggressive inlining, loop unrolling, vectorization | hot DSP loops, after measuring; grows code |
-Os | -O2 minus optimizations that grow code | flash-constrained parts, often nearly as fast as -O2 on Cortex-M because flash wait states punish large code |
-Oz | even 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 rounding | almost 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.
“It works at -O0 but not at -O2”
Section titled ““It works at -O0 but not at -O2””Treat it as a symptom, not a mystery. In order of likelihood, the cause is:
- A missing
volatileon a register access, an ISR-shared variable, or a delay loop. - Undefined behavior: an uninitialized variable, signed overflow, out-of-bounds index, or strict aliasing violation.
- A timing assumption: a busy-wait delay that is ten times shorter at
-O2, or a peripheral polled before it is ready. - A race between an ISR and main that the slower
-O0code happened to hide. - Stack layout differences: an overflow that
-O0’s larger frames hit in a harmless spot. - 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
-O0and 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 and -ftrapv
Section titled “-fwrapv and -ftrapv”-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.
Sanitizers on the host
Section titled “Sanitizers on the host”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.
#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.
gcc -std=c11 -Wall -Wextra -g -fsanitize=undefined ub_demo.c -o ub_demo./ub_demoub_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 1Each 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:
#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;}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 0x7ffc2b1e09f0WRITE 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 variableSUMMARY: AddressSanitizer: stack-buffer-overflow asan_demo.c:7 in fillThe 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.
Warnings that catch UB
Section titled “Warnings that catch UB”The compiler can see many UB patterns statically. These are the warnings that pay for themselves:
| Warning | In | Catches |
|---|---|---|
-Wuninitialized, -Wmaybe-uninitialized | -Wall (need -O1+) | reads of uninitialized locals |
-Wstrict-aliasing | -Wall (with -fstrict-aliasing) | type-punning pointer casts |
-Wsequence-point | -Wall | i = i++ and other unsequenced modifications |
-Warray-bounds | -Wall (need -O2) | constant out-of-bounds indices |
-Waggressive-loop-optimizations | on by default | loops that provably reach UB |
-Wshift-count-overflow, -Wshift-count-negative | on by default | constant shifts by the width or more |
-Wshift-negative-value | -Wextra | left shift of a negative constant |
-Wreturn-local-addr | on by default | returning the address of a local |
-Wnull-dereference | not in -Wall | paths the compiler proves dereference null (-O2) |
-fanalyzer | separate 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.
Rule sets: MISRA C and CERT C
Section titled “Rule sets: MISRA C and CERT C”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.
Common mistakes
Section titled “Common mistakes”- Overflow check written after the overflow.
if (a + b < a)with signed types is deleted. Fix: check againstINT32_MAX - bbefore 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
volatilemakesx++orreg |= bitatomic. Fix: critical section,BSRR-style set/clear registers, or C11 atomics. - Casting byte buffers to struct or
uint32_tpointers. Strict aliasing violation and possible unaligned access. Fix:memcpyor assemble with shifts. - Accepting “works at -O0” as proof. Fix: develop and test at the optimization level you ship.
- Shipping with
-fwrapvor-fno-strict-aliasingto hide a bug. Fix: use them to diagnose, then fix the code.
Exercises
Section titled “Exercises”- In Compiler Explorer, with
arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb, compilewait_for_datawith and withoutvolatile. 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 (withoutvolatile) and explain why the load comes back. - Compile
alias_demowith and without-fno-strict-aliasingand find the single instruction that differs. Write amainon your host that calls it with the same address for both arguments and prints the result under both flags. - Build
ub_demo.candasan_demo.cwith the sanitizers on your host and confirm the reports. Then compileub_demo.cwithout the sanitizer at-O2and explain why the output is identical even though the program has UB. - 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 withINT32_MAX+ 1,INT32_MIN+ -1, and-5+3. Compare its-O2assembly with a version using__builtin_add_overflow. - Take
pick()andin_table()from this lesson, compile them at-O0and-O2for 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.
Check yourself
Section titled “Check yourself”Question 1:At -O2, GCC compiles bool f(int32_t x) { return x + 1 > x; } to return true. Why is that legal?
Assuming no UB, x + 1 is always greater than x. The compiler uses that fact to fold the comparison to a constant.
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?
Nothing in the loop body can change a non-volatile done as far as the compiler can see, so reading it once is a valid optimization. volatile forces a reload every iteration.
Question 3:Which statement about volatile is true?
The standard only guarantees that volatile accesses happen as written and in order among themselves. It provides no atomicity, no hardware barrier, and no ordering for non-volatile accesses.
Question 4:Which is a correct, warning-free way to get the bit pattern of a float f as a uint32_t?
memcpy accesses both objects as bytes, which strict aliasing always allows, and compiles to a single vmov. The cast versions violate strict aliasing, and (uint32_t)f converts the value rather than copying the bits.
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?
Address 0 is readable on the STM32, so no fault occurs, and because a null dereference is UB the compiler removed the later check. Move checks before the first use.
Question 6:A bug disappears when you add -fno-strict-aliasing. What should you conclude?
The flag is a diagnostic: it tells you the code relies on aliasing that the standard forbids. The fix belongs in the code.
Key takeaways
Section titled “Key takeaways”- 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. volatileguarantees 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/-fwrapvas 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.