Every C programmer eventually writes this overflow check, and the compiler eventually deletes it:

c
int next = len + delta;
if (next < len) abort();      /* overflow check — gcc -O2: gone */

Signed overflow is undefined behavior; therefore, reasons the optimizer, len + delta did not overflow; therefore next < len is impossible for positive delta; therefore the branch is dead code. The check written to catch the bug is removed because of the bug it checks for. Understanding why this is legal — and why compilers genuinely can’t just “be reasonable” — is the difference between writing C and gambling in it.

UB Is a Contract Term, Not an Error Mode

The C standard defines behavior for valid programs. For certain operations — signed overflow, out-of-bounds access, null dereference, data races, aliasing violations, use-after-free — it says: if this occurs, the standard places no requirements on the implementation. Not “returns an unspecified value” (that’s a different, tamer category) — no requirements. On the whole execution, including the parts that already happened; a later UB can make an earlier printf not appear, because the optimizer reordered on the assumption the UB path is unreachable. People call it time travel; it’s just dataflow analysis over a premise you handed the compiler.

The premise is the point. Every optimization is a theorem of the form “this transformation preserves behavior of valid programs.” for (i = 0; i <= n; i++) a[i] vectorizes cleanly only if i can’t wrap; pointer loads can be hoisted only if they can’t trap; p->x; if (!p) lets the null check fold away because a null p would have already been UB. Demanding the compiler “do the obvious thing” on UB means disabling the assumption machinery that makes -O2 worth having — the same inference that deletes your overflow check is the one deleting bounds checks it proved redundant.

Where It Actually Bites

The UB list is long; the production-incident list is short and worth knowing cold:

  • Signed overflow — checks written after the operation get folded, loop bounds get assumed. Write checks before the fact (delta > INT_MAX - len) or use __builtin_add_overflow.
  • Dereference-before-null-check — the deref licenses check deletion. The famous 2009 Linux TUN/TAP exploit was exactly this shape (plus mmap at page 0); the kernel has compiled with -fno-delete-null-pointer-checks since.
  • Strict aliasing*(float *)&i tells the optimizer int-writes and float-reads can’t alias, so it may reorder them. Use memcpy (compiles to the same instruction, blesses the type pun) or -fno-strict-aliasing, which the kernel also does.
  • Out-of-bounds / use-after-free — not “reads garbage”: the optimizer may cache, reorder, or miscompile surrounding code based on the impossible access. All security-relevant.

Note what the kernel flags concede: real codebases negotiate the contract, buying back specific UB categories with flags (-fwrapv for wrapping signed arithmetic exists too). That’s a legitimate engineering position — but it’s a per-project treaty, not the language default, and code that silently assumes it doesn’t travel.

The Workflow That Wins

Arguing with the optimizer loses; instrumenting wins. The sanitizers changed this game completely:

bash
gcc -O2 -fsanitize=undefined,address -g -fno-sanitize-recover=all ...

UBSan traps signed overflow, misaligned access, bad shifts, null deref at the moment they execute, with file:line. ASan catches out-of-bounds and use-after-free with shadow memory (~2× slowdown — cheap for CI). Run the test suite under both continuously, and fuzz the parsers under them; the combination converts “impossible” field crashes into same-day fixes. For the paranoid tier: -ftrapv-style hardening in production via -fsanitize=signed-integer-overflow -fsanitize-minimal-runtime, and -D_FORTIFY_SOURCE=3 for libc bounds. None of this is optional-extra tooling anymore; it’s the standard cost of writing in a language whose optimizer holds you to a contract you can’t see.

And when you want the compiler to assume something, say it out loud — assert in debug plus __builtin_unreachable() in release is you supplying the premise deliberately, which is the only polite direction for that relationship to run.

Takeaways

  • UB isn’t a value or a crash — it’s a premise the optimizer may reason from, forwards and backwards.
  • The canonical deletions: post-hoc overflow checks, null checks after derefs, aliasing-violating reorders. Check before the operation, always.
  • Real projects buy back UB with flags (-fwrapv, -fno-strict-aliasing, -fno-delete-null-pointer-checks) — a treaty you must document.
  • UBSan+ASan in CI and under fuzzing is the workflow; -O2 without sanitizer coverage is unaudited risk.
  • memcpy is the blessed type-pun; _builtin*_overflow are the blessed checks; unreachable() is you stating premises on purpose.