Every C programmer eventually writes this overflow check, and the compiler eventually deletes it:
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-checkssince. - Strict aliasing —
*(float *)&itells the optimizer int-writes and float-reads can’t alias, so it may reorder them. Usememcpy(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:
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.