Systems Programming · Unsafe Code

Undefined Behaviour as a Compiler Permission

Why the optimiser treats undefined behaviour as a promise about what never happens, and how that promise lets it delete checks, fold comparisons, and reorder loads.

Undefined behaviour is usually explained as “anything can happen”, which is accurate and useless. The operational definition is narrower and more interesting: undefined behaviour is a licence for the compiler to assume the condition never arises, and every optimisation that depends on that assumption is legal. The licence is not a description of what the hardware does. It is a description of what the translation unit promised.

The C standard states the rule in a single sentence: if a program contains a construct for which the standard imposes no requirements, the standard imposes no requirements on the program at all. That is a total escape hatch, and it is deliberate. It lets an implementation generate code for the well-behaved executions without carrying the cost of defining a result for the ill-behaved ones.

Three grades of unspecified behaviour

The standard distinguishes several failure grades, and conflating them causes most of the confusion around the topic.

Implementation-defined behaviour is a documented choice. The result of converting a negative integer to an unsigned type is fully defined, and the sign of char is implementation-defined: an implementation must pick one and say which. Code that depends on such a choice is portable to implementations that make the same choice.

Unspecified behaviour is a choice among a set of valid outcomes, where the implementation need not document which one it picks and need not pick consistently. The order of evaluation of function arguments is the canonical case. Any order is correct; a program that depends on a particular order has a bug, but the bug is confined to that expression.

Undefined behaviour removes the constraint entirely. Reading a variable after its lifetime ended, dereferencing a null pointer, shifting a signed value into the sign bit, or indexing outside an array are all in this class. The distinction from unspecified behaviour is not a matter of degree: unspecified behaviour still has a set of legal outcomes, and undefined behaviour has none.

C23 adds a fourth category, erroneous behaviour, for constructs that are not undefined but for which a conforming implementation may diagnose and refuse to translate. It exists so that some common mistakes can be made mandatory errors without retroactively making existing programs undefined.

Where the licence is spent

The optimiser spends the licence in three recurring patterns. The first is dead-branch elimination. If a dereference of p appears before a test of p == NULL, the compiler may conclude that p is non-null on any execution that reaches the test, and delete the test. The reasoning is sound under the standard: an execution in which p is null has already committed undefined behaviour, so its subsequent behaviour is unconstrained, so the compiler is free to make the branch take the other path.

The second is algebraic simplification. For signed int, x + 1 > x is not a tautology on every input, since the addition can overflow. Because overflow is undefined, the compiler may assume it does not occur and fold the comparison to true. With unsigned int, where wraparound is defined, the same fold would be a miscompilation, and compilers do not perform it.

The third is load reordering across stores, which is where strict aliasing enters. The standard’s effective type rules say an object’s stored value may be accessed only through an lvalue of compatible type. Given two pointers of unrelated types, the compiler may conclude they do not alias, and hoist a load above an intervening store. Two pointers of different types that do point at the same bytes break that conclusion, and the resulting reordering is not a compiler bug.

LLVM models the middle of this pipeline with a value called poison, which is distinct from undefined behaviour. An operation such as add nsw (no signed wrap) that overflows produces poison rather than immediate undefined behaviour. Poison propagates through most operations, so a computation on a poison value yields poison. It becomes undefined behaviour only when it reaches a branch condition, a memory address, or another point where the value must be concrete. The split exists because it lets the optimiser delete instructions that consume poison without declaring the whole execution void.

int f(int *p, int *q) {
    *p = 1;
    int t = *q;      /* may be hoisted above the store if p and q cannot alias */
    *p = 2;
    return t;
}

If p and q point at the same object and the types differ, the effective type rule is violated, the compiler’s assumption of non-aliasing is unjustified only in the sense that the program broke the contract, and the returned value may be the old one. The transformation is legal. The program is not.

Time travel and retrospective causation

The most counter-intuitive consequence is that undefined behaviour can invalidate execution that occurred before the offending instruction. Consider a function that performs a bounds check, and later, on a path that the check guards, indexes past the end. The compiler is entitled to reason backwards: an execution that reaches the out-of-bounds access is undefined, therefore it never happens, therefore the index is always in range, therefore the bounds check is unnecessary. The check disappears, and the access executes with whatever index the caller supplied.

This is often described as the compiler “going back in time”. The description is a metaphor for a real property of the abstract machine: the standard constrains only executions, not statements, and an execution that contains undefined behaviour is not constrained at all. There is no requirement that the constraint apply from the point of the violation forward.

A related pattern appears with loops. C11 introduced a forward-progress guarantee: a loop whose controlling expression is not a constant expression, which performs no I/O, does not access volatile objects, and does not perform synchronisation, may be assumed to terminate. A loop that spins on a flag written by a signal handler and never otherwise exits can therefore be removed entirely, since the standard does not require the program to observe the write. Marking the flag volatile or using an atomic restores the guarantee.

Buying back determinism

Every guarantee has a flag that trades performance for predictability, and knowing them is part of reading the contract. GCC and Clang both accept -fwrapv, which defines signed overflow as two’s-complement wraparound and removes the licence to fold comparisons that depend on its absence. -fno-strict-aliasing disables the type-based alias analysis, at the cost of weaker load and store reordering. -ftrapv inserts overflow checks that abort, which is a debugging aid rather than a semantic fix.

The sanitizers take the other approach: instead of changing the semantics, they instrument the program to detect violations. UndefinedBehaviourSanitizer instruments the operations the standard leaves undefined and reports them at the point of occurrence. That is closer to the programmer’s mental model, but instrumentation changes the code enough that some violations that survive in optimised builds are masked, and some that would be silently ignored are reported. The gap between what a sanitizer observes and what an optimised build does is treated in What Sanitizers and Miri Cannot Prove.

The trade-off is not performance against safety in the abstract. It is precision against cost. A compiler that defined the result of every operation would have to emit the checks needed to produce those results, which for signed arithmetic means a branch per addition, and for aliasing means proving disjointness or accepting a conservative order. The standard’s choice is to let the programmer assert the invariants and to let the optimiser use the assertions without verification.

Why the contract is not enforced

Nothing in the toolchain checks that a program honours the assumptions it benefits from, because checking would require exactly the analysis the assumptions replace. A compiler cannot prove that a pointer is non-null at every dereference in a program that computes addresses at runtime, so it accepts the assertion instead.

That leaves the burden on whoever writes the unsafe code, and the burden is documentation-shaped. The Rust model makes this explicit by requiring a marker at each site where the compiler has stepped back, as described in What the Unsafe Keyword Actually Disables. C and C++ have no such marker, so the assertion is implicit in every line and the count of unchecked assumptions is not greppable. The practical difference is not that one language has undefined behaviour and the other does not. Both do. The difference is whether the places where it can arise are enumerated.

References

  1. ISO/IEC 9899:2011: Programming Languages - C — ISO/IEC
  2. LLVM Language Reference Manual: Undefined Values — LLVM project
  3. What Every C Programmer Should Know About Undefined Behavior — LLVM project blog
  4. The Rust Reference: Behavior considered undefined — Rust project