Legalityimplementation

How Undefined Behavior Becomes Faster Code

The canonical case, worked properly: a null check placed after a dereference is deleted, because dereferencing already implied the pointer was non-null. Not a compiler being malicious — ordinary branch simplification applied to a fact the language supplied.

The question

How exactly does an undefined construct in my source turn into a missing check in my binary?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The IR after the frontend has attached the language's assumptions to it — nonnull on a dereferenced pointer, nsw on signed arithmetic, type-based alias tags on loads and stores — and after a value-range or predicate analysis has propagated them through the dominator tree. At that point "is this pointer null here" is answerable from the IR alone, and the fact that the answer came from a language rule rather than from a constant is no longer visible to the pass that consumes it.

What this phase may assume or do

The transformations below are legal under exactly the same preconditions as always: a branch whose condition is provably constant may be replaced by its taken edge, and an instruction with no effect and no user may be deleted. What changes is where the proof came from. The condition !p is provably false not because a constant reached it, but because a dereference of p dominates the test and the language says a dereference of null is undefined — so every execution that reaches the test has a non-null p. The compiler must still not introduce undefined behavior: it may not sink the dereference below the test and it may not hoist a load above a branch that guarded it.

Key points

  • A null check after a dereference is removed because the dereference already asserted the pointer was non-null — ordinary branch simplification on a fact the language supplied.
  • Nothing about the transformation is special-cased for undefined behavior; the pass that deletes the branch cannot tell where its fact came from.
  • The same shape explains signed-overflow checks folding to true, type-punned loads not seeing stores, and empty infinite loops falling through in C++.
  • CVE-2009-1897 in the Linux tun driver is the canonical instance, and every element of it was ordinary.
  • The compiler is still forbidden from *introducing* undefined behavior, which is why it may not fix your ordering by sinking the dereference below the test.
  • The fixes are source-level: check before use, use checked-arithmetic builtins, reinterpret with memcpy or bit_cast, and run sanitizers in CI.
  • Which pass consumes which premise varies between compiler versions, which is why the failure mode is "broke on upgrade" rather than "broke on porting".

The dereference-then-check pattern

This is the example everyone eventually meets, and it is worth doing carefully because the sensational version — "the compiler deleted my security check" — teaches the wrong mechanism. Nothing was targeted. Three ordinary steps ran in sequence and the check happened to be in the way of the third.

The pattern is a function that uses a pointer and then, a few lines later, tests it for null. That ordering is easy to produce by accident: the test was added during review, the code was refactored and a line moved, a macro expanded to a use before a check. In source-reading order it looks defensive. In the language's reading order it is a contradiction, because the use already asserted what the test is about to doubt.

The most famous real instance is the Linux kernel's tun driver in 2009, where a struct member was read into a local before a null test on the pointer, and the compiler removed the test — turning a null-pointer bug into an exploitable one, because on that configuration page zero could be mapped. It became CVE-2009-1897, and it is the standard citation because every element is ordinary: ordinary code, ordinary compiler, ordinary optimization, and a consequence out of all proportion.

The check does not survive, and the reason is in the line above it
Before
void handler(struct tun *tun) {
  struct sock *sk = tun->sk;   /* dereference */
  if (!tun)                    /* check, afterwards */
    return;
  use(sk);
}
After
void handler(struct tun *tun) {
  struct sock *sk = tun->sk;
  use(sk);                     /* the branch is gone */
}
Legal only when

Dereferencing tun is undefined if tun is null, so on every execution that reaches the test, tun is non-null — the compiler is entitled to assume the alternative does not occur. The predicate !tun is therefore false on all reaching paths, and a conditional branch whose condition is provably false may be replaced by its taken edge. Removing the now-unreachable block is [[dead-code-elimination]]. Every step is a pass doing exactly what it does everywhere else; only the origin of the fact is unusual.

Illegal when

The check comes first. if (!tun) return; struct sock *sk = tun->sk; supplies no premise, because at the test the pointer has not been dereferenced and could be anything — so the branch stays and the code is safe. It is equally illegal in a language that defines null dereference: in Java the load throws a NullPointerException, which is defined observable behavior, so nothing may be assumed about what follows it. And the compiler may not fix the original by sinking the dereference below the test, because that would change which executions are defined.

The same mechanism, three more times

typicalWhether any specific compiler performs any specific one of these on your code depends on version, optimization level, inlining decisions and which analyses ran first. GCC and Clang both do all six in some configurations; MSVC differs on several. That variability is the practically important part: the assumption is always available, and which pass happens to use it changes without notice between releases, which is why code relying on the assumption not being used breaks on upgrade rather than on porting.

Once the shape is visible — a construct supplies a premise, an ordinary pass consumes it — the other famous cases stop being separate mysteries.

Signed overflow. int i incremented in a loop cannot wrap, because wrapping would be undefined. So i < n is monotone, the loop's trip count is computable, and the 32-bit counter can be widened to a 64-bit register without a wrap check on a 64-bit target. It also means x + 1 > x folds to true for signed x, which is why an overflow check written that way disappears — the standard fix is to compare against INT_MAX instead, or to use the checked builtins.

Strict aliasing. A store through float* cannot affect a load through int*, because accessing an object through an lvalue of an incompatible type is undefined. That premise is what lets a compiler keep a value in a register across an unrelated store, which is worth a great deal in loops — and it is why type-punning through pointer casts fails at -O2 while working at -O0, and why memcpy (which the standard blesses for this) is the portable way to reinterpret bytes.

Infinite loops. In C++, a loop with no side effects may be assumed to terminate. So a while (1) {} written deliberately to hang can be treated as unreachable and fall through into whatever follows. C11 exempts loops with constant controlling expressions, so the same source is defined in C and undefined in C++ — one of the clearest cases where the language, not the compiler, is the thing that changed.

Premise, consumer, symptomtypical
Source constructThe pass that consumes the premiseWhat the engineer sees
Dereference before a null testPredicate propagation, then branch simplification, then DCEThe defensive check is absent from the disassembly.
x + 1 > x on signed intInstruction simplification with the nsw flagAn overflow check compiles to an unconditional branch.
int loop counter on a 64-bit targetInduction-variable widening in the loop optimizerA wrapping counter that "worked" now runs past its intended bound.
*(int*)&some_floatAlias analysis via type-based metadata, then load reuseA stored value is not visible through the other pointer at -O2 only.
while (1) {} with an empty body in C++specLoop deletion under the forward-progress ruleExecution continues past a loop that was written to hang.
Non-atomic flag read in a spin loopLoop-invariant code motion, under the no-data-race assumptionThe loop never observes the other thread's write in a release build.

What to actually do about it

The useful conclusions are small and concrete, and none of them is "turn off the optimizer".

Check before you use. The premise is supplied by the use, so ordering the test first removes it entirely. This is not a style preference; it is the difference between a check that exists in the binary and one that does not.

Do not write overflow checks that rely on overflow. x + 1 > x, p + n < p and a * b / b == a are all tests written in terms of the very behavior the language left undefined. Use __builtin_add_overflow and its relatives, compare against the type's limits, or use unsigned arithmetic where wrapping is defined.

Reinterpret bytes with `memcpy`, not with a cast. Compilers recognise the pattern and emit no copy at -O1 and above, so the portable spelling is also the fast one. std::bit_cast in C++20 is the same thing with a type-checked signature.

Run a sanitizer in CI, and fuzz. Undefined behavior is not diagnosed by the compiler and will not be found by code review reliably. UBSan and ASan report at the construct; a fuzzer supplies the inputs that reach it — [[compiler-fuzzing]] for the compiler-facing version of the same argument.

Treat "it stopped working after we upgraded the compiler" as a UB hypothesis first. It is by far the most common cause, and it is much cheaper to test than to argue about.

Three rewrites, each removing a premise rather than fighting the optimizer
1/* 1. Order the check before the use: no premise is supplied. */
2if (!tun) return;
3struct sock *sk = tun->sk;
4
5/* 2. An overflow check that does not depend on overflow. */
6int sum;
7if (__builtin_add_overflow(a, b, &sum)) return -EOVERFLOW;
8/* or, without builtins: */
9if (a > 0 && b > INT_MAX - a) return -EOVERFLOW;
10
11/* 3. Reinterpret bytes without violating the aliasing rules. */
12float f = 1.0f;
13uint32_t bits;
14memcpy(&bits, &f, sizeof bits); /* compiles to a register move at -O1+ */

Every one of these is the same move: stop supplying the compiler with a premise you did not intend to give it. That is a different activity from disabling optimization, and it survives a compiler upgrade, which -O1 does not.

How it works

The steps, in the order the compiler takes them.

  • The frontend emits a load through the pointer and attaches the assumptions the language licenses — in LLVM, a nonnull fact derivable from the dereference and nsw on signed arithmetic.
  • A predicate or value-range analysis propagates those facts down the dominator tree, so every block dominated by the dereference records that the pointer is non-null.
  • Instruction simplification evaluates the branch condition against those facts and finds it constant.
  • Branch simplification replaces the conditional branch with an unconditional jump to the taken edge.
  • Unreachable-block elimination removes the untaken block, and dead-code elimination removes anything that only fed it.
  • No pass in that chain records that a language rule was involved, which is why no diagnostic is emitted and why the removal is hard to attribute after the fact.
  • A separate rule constrains the compiler in the other direction: speculating a load above a branch requires proving the load is safe, so the optimizer may not create undefined behavior that the source did not have.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A null check that appears in the source is absent from the binary, and a null pointer reaches code written on the assumption that it could not.
  • An overflow check written as a + b < a compiles to an unconditional false, and the arithmetic it guarded proceeds with a wrapped value.
  • A type-punning cast works in a debug build and returns stale data in a release build, and the symptom is a wrong value in a serialised structure.
  • A deliberate while (1) {} in C++ falls through, and the program executes whatever code followed the loop.
  • A spin loop on a plain bool flag never terminates in a release build, and the team concludes the other thread is not running.
  • A kernel or embedded fix that "works" is a reordering that merely stops supplying the premise on one compiler version, and the bug returns at the next upgrade.

When it helps

  • Explaining a missing check to a team without the folklore: naming the dominating construct that supplied the premise turns a mystery into a code review comment.
  • Auditing security-relevant C: the pattern "use, then validate" is mechanically greppable and is a genuine class of vulnerability.
  • Reviewing an overflow or bounds check, where the question is whether the check itself is written in terms of undefined behavior.
  • Understanding the performance cost of -fwrapv and -fno-strict-aliasing — each removes one of these premises across the whole translation unit.

When it hurts

  • As a story about compilers being adversarial. It leads teams to lower optimization levels, which hides the class of bug on one build configuration and ships it in another.
  • As a reason to distrust all optimization. The same passes, on the same code with the checks ordered correctly, are exactly the ones you want; the problem was the premise, not the pass.

What it costs

Every one of these is paid by something.

  • Consuming language-supplied premises buys real performance — bounds-check removal, induction-variable widening, cross-store register caching — and pays a class of bug with no diagnostic and a displaced symptom.
  • Removing the premises with -fwrapv and -fno-strict-aliasing buys predictability for existing code and pays measurable performance in loops and memory-heavy code, plus the fact that the source now means something different under different flags.
  • Sanitizer coverage buys reports at the construct and pays run time, memory, and the input-generation problem: a sanitizer only ever reports what your tests actually executed.
  • Writing checks with __builtin_*_overflow buys checks that survive optimization and pays portability, since the builtins are compiler extensions rather than standard C until C23's <stdckdint.h>.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Use a language where the premise does not exist: Java bounds-checks and throws, Rust panics or wraps by profile and enforces aliasing in the type system, so the entire pattern is unrepresentable.
  • Compile with the assumptions disabled and accept the cost, which is what several large C codebases including the Linux kernel do for strict aliasing.
  • Trap rather than assume: -ftrapv, -fsanitize=undefined -fno-sanitize-recover in production for low-throughput services, or hardware memory tagging where available.
  • Prove the absence of undefined behavior instead of testing for it: Frama-C, Astrée and similar tools do this for constrained subsets, at a cost in annotation effort that safety-critical projects accept and others do not — [[static-analysis]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • Compile the dereference-then-check function at -O0 and -O2 in Compiler Explorer and diff: the test/jz pair present in one is absent in the other, which is the entire lesson in four instructions.
  • Repeat with the check moved above the dereference and confirm it survives at every level.
  • clang -O2 -Wtautological-pointer-compare and GCC's -Wnonnull-compare warn about exactly this pattern in some cases, and are worth enabling despite the false positives.
  • clang -fsanitize=undefined on the signed-overflow example reports the addition rather than the missing check — a demonstration that the sanitizer reports the construct, not the consequence.
  • -fwrapv and -fno-strict-aliasing on a loop-heavy benchmark, measured: the difference is what the premises were worth on that code.
  • Our /compilers/ub-explorer walks the same chain — construct, premise, transformation, result — one step at a time.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The compiler removed my security check on purpose." No pass knew it was a security check. It was a branch with a provably false condition, and those are removed everywhere.
  • "This only happens with obscure optimizations." Branch simplification and dead-code elimination are the two most basic passes there are, and they are enabled at -O1.
  • "Adding a null check makes the code safer." Adding it *after* a use adds nothing, because the use already supplied the premise that removes it.
  • "The compiler should at least warn when it deletes a check." By the time the branch is deleted, the information that a language rule was involved has been laundered through two analyses. Some compilers warn in narrow cases; none can do it generally.

Misconceptions

The claim, and what is actually true.

The compiler exploits undefined behavior deliberately.
It propagates facts and simplifies branches. That one of the facts came from a language rule rather than a constant is invisible to every pass involved.
Lowering the optimization level fixes it.
It hides it on that build. The undefined behavior is still in the source, and a different compiler, a different version or a different inlining decision will expose it again.
Type-punning through a union or a cast is fine because it works.
The union case is defined in C and not in C++; the cast case is undefined in both. memcpy and std::bit_cast are the spellings that are defined and, at -O1 and above, generate the same instructions.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

If you use a pointer and then check whether it is null, the check disappears — because using it already told the compiler it was not null. The compiler is not being clever or hostile; it just simplifies a test whose answer it already knows. The fix is to check first, which costs nothing and leaves the check in the binary.

practical

Grep for the pattern: any use of a pointer that precedes its null test. Rewrite overflow checks in terms of the type's limits or the checked builtins rather than in terms of the overflow itself. Reinterpret bytes with memcpy or std::bit_cast and let the optimizer remove the copy. Then put UBSan and ASan in CI, because none of the above finds the instances nobody thought about. And when a program breaks after a toolchain upgrade, test this hypothesis before any other.

advanced

The structurally interesting part is that the premise is laundered. By the time branch simplification sees the condition, the fact "this pointer is non-null" is indistinguishable from a fact derived by constant propagation, and that is by design — the whole value of encoding language assumptions as IR attributes is that every existing analysis and transformation gets to use them without modification. It is also precisely why a good diagnostic is so hard: warning "I deleted this check because of a dereference above it" requires threading provenance through analyses that were built to forget it. Proposals in this space, including C++26's erroneous behavior, generally accept keeping the premise while bounding the damage, because attaching provenance to every derived fact would cost more than the optimizations are worth.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationCVE-2009-1897 was GCC on Linux in 2009 and the specific pass chain has changed since; Clang performs the same removal through different machinery. The point that generalises is that the premise is available to any conforming implementation, so "our compiler does not do that" is a statement about a version, not about the language.
specThe forward-progress difference is real and specified: C++ permits assuming a side-effect-free loop terminates ([intro.progress]), while C11 exempts loops whose controlling expression is a constant, so while (1) {} is defined in C and undefined in C++. Code shared between the two languages through a header can therefore be compiled with different meanings by the same toolchain.
typicalCompilers recognise memcpy of a small fixed size and emit a register move rather than a call at -O1 and above, which is what makes the aliasing-safe idiom free in practice. At -O0 the call remains, so a benchmark of the idiom at -O0 measures the wrong thing entirely.

If you were asked this in an interview

  • Explain, pass by pass, how a null check after a dereference ends up missing from the binary.
  • Why can the compiler not simply move the dereference below the check instead?
  • A team fixes a miscompilation by dropping to -O1. What do you tell them?

Connections

OS & Networkingwhy-virtual-memory
Domains that do not exist yet
  • Testing & Reliability Engineering — Dynamic analysis and input generation as a testing discipline
    Sanitizers only report constructs your inputs actually executed, so the value of a UBSan build is bounded by coverage. Getting the inputs is a testing problem owned there, and it is the reason sanitizer builds and fuzzing are always deployed together.