5.3.11 · D3Build Systems & Toolchain

Worked examples — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

2,976 words14 min readBack to topic

This page walks through every kind of situation the optimization / warning / sanitizer flags can throw at you. We first lay out a matrix of case-classes, then solve one concrete example per cell. Whenever a "same source, different flag" behaviour appears, we predict it, run it in our head, and verify with the real integer arithmetic C uses.

Parent: GCC/Clang Flags (topic note). If a term slips past undefined, it is built from zero right here.


The scenario matrix

Every cell below is a class of situation this topic can produce. The examples that follow are tagged with the cell they cover.

Cell Case class What makes it tricky Example
A Optimization changes speed but NOT result must prove result identical Ex 1
B Signed-vs-unsigned sign boundary (the "zero crossing") wrap-around at Ex 2
C Undefined behaviour: works at -O0, breaks at -O2 UB contract voided Ex 3
D Degenerate / zero input (empty loop, n = 0) limiting case, no iterations Ex 4
E Heap out-of-bounds — the redzone catch (ASan) one byte past the edge Ex 5
F Signed integer overflow at the max value (UBSan) arithmetic limit Ex 6
G Real-world word problem: choosing a flag set translate goal → flags Ex 7
H Exam twist: -Os vs -O3 size tradeoff size vs speed, both directions Ex 8
I Link-step degenerate failure (sanitizer forgotten at link) flag in one place only Ex 9

We need three tiny pieces of vocabulary before any example. Each is defined before first use.

See also Undefined Behaviour in C/C++ and CPU Caches and Instruction Cache for the deeper stories.


Ex 1 — Cell A · Optimization changes speed, not result

Look at figure s01: the top track is -O0 (load → add → store, every lap touches memory), the bottom track is -O3 (the value lives in a register the whole time, or the loop vanishes into a constant).

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers
  1. Compute the result by hand. . Why this step? The as-if rule only lets the optimizer change code that leaves the observable result untouched — so we must first pin down that result. .
  2. -O0 version. s and i live in memory; each lap does load s, add, store s. Five laps → 5 stores. Why this step? At -O0 the debugger must be able to read s at any breakpoint, so it stays in memory. This is slow but faithful.
  3. -O3 version. The compiler sees n is the constant 5 and every added value is known, so it may constant-fold the entire loop into s = 10;. Why this step? Since nothing about s's final value depends on runtime input, the whole loop is a constant. Deleting it does not change the observable result — legal.

Verify: . Both binaries print 10. Same answer, different instructions — exactly the invariant the topic note calls "same observable behaviour, different machine code."


Ex 2 — Cell B · The signed/unsigned zero boundary

  1. Find the type of n - 10. n is unsigned; 10 is promoted to unsigned, so the subtraction is unsigned. Why this step? The comparison < 0 behaves completely differently for signed vs unsigned operands — the type decides everything.
  2. Do unsigned arithmetic. Unsigned math wraps modulo . So . Why this step? Unsigned values can never be negative; instead of going below zero they wrap around to a huge positive number. That is the "zero boundary" of this cell.
  3. Evaluate the test. is false. "neg" never prints. Why this step? An unsigned number is never < 0, so the branch is dead — a silent logic bug.
  4. The warning. -Wall (via -Wtype-limits / -Wsign-compare) flags "comparison is always false due to limited range." Why this step? A static detector caught it with zero runtime — the 80/20 win the topic note advertises.

Verify: negative, so the test is False. Fix: if ((int)n - 10 < 0) prints neg because now the subtraction is signed and equals .


Ex 3 — Cell C · Works at -O0, crashes at -O2 (UB)

  1. Spot the uninitialized read. When p == NULL, the branch x = *p is skipped, so x is never assigned. Reading it is undefined behaviour. Why this step? UB is the hinge of this whole cell; identify it first.
  2. -O0 accident. At -O0, x occupies a stack slot that happens to hold some garbage; x + 1 returns garbage-plus-one — but does not crash. "Works" by luck. Why this step? No optimization means the naive garbage is just used as-is — the bug hides.
  3. -O2 assumption. The optimizer is allowed to assume UB never happens, so it may conclude "x is always read, therefore p was non-null, therefore delete the if (p) check." Now f(NULL) dereferences null → crash. Why this step? This is the deep contract: legal optimization + your UB = "miscompile." The bug is in the code, not the compiler.
  4. Detect it. -O1 -g -fsanitize=address,undefined reports the uninitialized/null use with a stack trace.

Verify: the correct value if p points at 41 is . With p = NULL there is no defined answer at all — that is precisely the point. See Undefined Behaviour in C/C++.


Ex 4 — Cell D · Degenerate zero input (empty loop)

  1. Trip the loop. With n = 0, the test i < 0 is false on the first check → the body never runs. Why this step? The degenerate/limiting case: zero iterations. Nothing is summed.
  2. Result. s keeps its initial value . Why this step? No addition happened, so the identity element stands.
  3. Optimizer. Since the loop body is unreachable when n == 0, and if n is a known constant 0, the optimizer deletes the loop and the array reads entirely — zero loads of a. Why this step? Reading a[i] for an empty range would be redundant work; removing it preserves the result.

Verify: empty sum . The array pointer is never dereferenced, so even a null a is safe when n = 0 — the degenerate case is well-defined precisely because the count guards the access.


Ex 5 — Cell E · Heap out-of-bounds, the redzone catch

Figure s02 shows the 16 valid bytes (blue), the poisoned redzone (red) glued to the end, and the shadow-memory map ASan checks on every store.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers
  1. Size the buffer. 4 * sizeof(int) bytes. Valid byte offsets are . Why this step? We must know exactly where the legal region ends to see where p[4] falls.
  2. Locate p[4]. Element index sits at byte offset — the first byte past the end. Why this step? Offset is bytes after the last valid byte: it is the very first poisoned byte. ASan's report literally says "0 bytes after."
  3. The shadow check. ASan keeps 1 shadow byte per 8 app bytes. The redzone bytes are marked "poisoned." The instrumented store at offset 16 reads the shadow byte, sees poison, and aborts with a stack trace. Why this step? This is the mechanism the topic note calls the redzone trick, shown concretely at the boundary.

Verify: buffer size bytes, p[4] offset , overrun bytes after the buffer — matching ASan's heap-buffer-overflow ... 0 bytes after. Also 4 \times 4 = 16 shadow region begins exactly here.


Ex 6 — Cell F · Signed overflow at the arithmetic limit (UBSan)

  1. Identify the limit. For 32-bit int, the maximum is . Why this step? Overflow is defined relative to this ceiling — the limiting value of the type.
  2. Add one. , which is beyond INT_MAX. For signed ints this is undefined behaviour (unlike unsigned, which wraps legally). Why this step? The signed/unsigned distinction from Ex 2 returns: unsigned wrap is defined, signed overflow is UB.
  3. UBSan output. -fsanitize=undefined prints signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'. Why this step? A runtime check fires exactly at the arithmetic boundary — no earlier, no later.

Verify: (INT_MAX) and , which exceeds — so the result is unrepresentable in int. Contrast: if x were unsigned, is perfectly legal and defined.


Ex 7 — Cell G · Real-world flag choice

  1. CI: correctness over speed. Use -Og -g -Wall -Wextra -fsanitize=address,undefined. Why this step? -Og keeps the debugger sane while still being faster than -O0; the two sanitizers plus warnings maximize bugs-caught-per-run — see Continuous Integration pipelines and Debugging with GDB and -g.
  2. Release: speed. Use -O2 -DNDEBUG. Why this step? -O2 is the production default (good bets, no i-cache-thrashing gambles); -DNDEBUG strips asserts.
  3. Firmware: size. Use -Os -DNDEBUG. Why this step? -Os is -O2 minus the passes that grow the binary — the byte budget wins over raw speed.

Verify: three goals → three sets, each mapping goal to lever. No sanitizer flag appears in the release/firmware sets because ASan costs ~2× time and ~3× memory — CI only. These are exactly the recipes in the topic note's "Putting it together." Store them in your Makefiles and CFLAGS/LDFLAGS.


Ex 8 — Cell H · Exam twist, -Os vs -O3 both directions

  1. Compare code size to cache budget. -O3 body bytes; i-cache budget bytes. Since , the loop cannot fit — the CPU re-fetches instructions every pass. Why this step? An optimization that grows code is only a win if the grown code still fits the cache; here it does not.
  2. -Os body. Suppose -Os keeps it at bytes. Since , it fits — no instruction re-fetch stalls. Why this step? Fitting the cache can beat doing "more clever" work that spills out of it.
  3. Conclusion. For this loop -Os (or -O2) can beat -O3. The optimization "bet" of Ex-note fame did not pay off. Why this step? Directly demolishes the "-O3 is always faster" mistake — the number is a bet, not a guarantee.

Verify: (overflows i-cache) and (fits). So the size ordering that matters is -Os fits, -O3 doesn't → -Os can win. The only honest arbiter is a benchmark: measure, don't assume.


  1. Compile step. -fsanitize=address inserts calls to ASan runtime functions (like __asan_report_store4) into a.o. Why this step? Instrumentation emits calls to a library that is not part of your code.
  2. Link step. The final link has no -fsanitize=address, so the ASan runtime library is not pulled in → undefined reference to __asan_.... Why this step? The linker resolves those emitted calls only when the sanitizer runtime is on the link line. Missing → degenerate failure.
  3. Fix. Pass the flag at both steps (or add it to both CFLAGS and LDFLAGS). Why this step? ASan is unique among these flags in needing a runtime linked in, unlike optimization flags which are compile-only.

Verify: the missing library causes an undefined reference, not a runtime crash — a build-time symptom that uniquely fingerprints "sanitizer forgotten at link." Adding -fsanitize=address to the link line resolves every __asan_* symbol.


Recall Self-check: predict before revealing

With n=5, does if (n-10 < 0) (n unsigned) print "neg"? ::: No — n-10 is unsigned, wraps to , which is never < 0. p = malloc(4*sizeof(int)); p[4] overruns by how many bytes? ::: bytes after the end — offset is the first poisoned redzone byte. INT_MAX + 1 for signed int — defined or UB? ::: UB (signed overflow); UBSan reports it. Unsigned would wrap legally. Empty loop for(i=0;i<0;i++) s+=a[i] gives s = ? ::: ; the body never runs, so the initial value stands. A loop grows to 520 bytes but the i-cache holds 256 — -O3 or -Os faster? ::: -Os can win; -O3's bigger code overflows the instruction cache. -fsanitize=address at compile only → what error? ::: Link-time undefined reference to __asan_*; add the flag to the link step too.