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.
How exactly does an undefined construct in my source turn into a missing check in my binary?
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.
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
memcpyorbit_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.
void handler(struct tun *tun) {
struct sock *sk = tun->sk; /* dereference */
if (!tun) /* check, afterwards */
return;
use(sk);
}void handler(struct tun *tun) {
struct sock *sk = tun->sk;
use(sk); /* the branch is gone */
}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.
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
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.
| Source construct | The pass that consumes the premise | What the engineer sees |
|---|---|---|
| Dereference before a null test | Predicate propagation, then branch simplification, then DCE | The defensive check is absent from the disassembly. |
x + 1 > x on signed int | Instruction simplification with the nsw flag | An overflow check compiles to an unconditional branch. |
int loop counter on a 64-bit target | Induction-variable widening in the loop optimizer | A wrapping counter that "worked" now runs past its intended bound. |
*(int*)&some_float | Alias analysis via type-based metadata, then load reuse | A stored value is not visible through the other pointer at -O2 only. |
while (1) {} with an empty body in C++spec | Loop deletion under the forward-progress rule | Execution continues past a loop that was written to hang. |
| Non-atomic flag read in a spin loop | Loop-invariant code motion, under the no-data-race assumption | The 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.
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
nonnullfact derivable from the dereference andnswon 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 < acompiles 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
boolflag 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
-fwrapvand-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
-fwrapvand-fno-strict-aliasingbuys 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_*_overflowbuys 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-recoverin 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
-O0and-O2in Compiler Explorer and diff: thetest/jzpair 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-compareand GCC's-Wnonnull-comparewarn about exactly this pattern in some cases, and are worth enabling despite the false positives.clang -fsanitize=undefinedon the signed-overflow example reports the addition rather than the missing check — a demonstration that the sanitizer reports the construct, not the consequence.-fwrapvand-fno-strict-aliasingon a loop-heavy benchmark, measured: the difference is what the premises were worth on that code.- Our
/compilers/ub-explorerwalks 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.
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.
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.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
- Testing & Reliability Engineering — Dynamic analysis and input generation as a testing disciplineSanitizers 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.