Undefined Behavior: the double-edged sword
Why C and C++ are riddled with undefined behavior, how it buys speed and detonates security holes, and how Zig's safety-checked builds, sanitizers, and the newer systems languages defuse it.
Most surprising things a program does have a cause you can point to. A logic bug has a wrong line. A crash has a faulting address. Undefined behavior is different in kind: it is the standard's official shrug. When a C program reads past the end of an array, the language does not say "this crashes" or "this returns garbage." It says nothing - and that silence is a license the optimizer is entitled to cash in.
That single design decision is the source of both C's legendary speed and its legendary danger. The same rule that lets a compiler delete a redundant bounds check is the rule that lets it delete the security check guarding your kernel. On a site about memory management, undefined behavior is the throughline: nearly every memory bug - use-after-free, buffer overflow, uninitialized read, signed-overflow-in-a-size-calculation - is undefined behavior, which is precisely why those bugs are silent, non-deterministic, and weaponizable. This article is about what UB actually is, why C and C++ have so much of it, how it both optimizes and destroys, and how Zig's safety-checked builds, the sanitizers, and the newer languages (Hare, Odin) attack it from the other side.
What "undefined" actually means
The C and C++ standards describe an abstract machine and sort every program behavior into a few buckets. The vocabulary matters, because people use "undefined" loosely when the standard is precise:
- Well-defined. The standard says exactly what happens.
2 + 2is4everywhere. - Implementation-defined. The result varies but the implementation must document it and be consistent - e.g. the size of
int, or whethercharis signed. - Unspecified. The standard allows a set of behaviors and the implementation need not document which it picks - e.g. the order in which function arguments are evaluated.
- Undefined behavior (UB). The standard imposes no requirements whatsoever. The program may crash, may produce wrong output, may appear to work, may corrupt unrelated data, or may travel back in time and break code that ran before the UB. Anything is permitted.
The phrase that makes UB lethal is in the standard's own definition: a program containing UB has no defined behavior at all - not "from that point onward," but as a whole. The compiler is entitled to assume UB never happens, and to optimize on that assumption. This is the hinge of the entire topic.
int access(int *p) {
int v = *p; /* (1) dereference p - so p MUST be non-null... */
if (p == NULL) /* (2) ...therefore the compiler may DELETE this check */
return -1;
return v;
}
A human reads this as "load, then guard." The compiler reads line (1) as a promise that p is non-null (dereferencing null is UB, which "cannot happen"), so the guard on line (2) is provably dead code and is removed. The check vanishes. This is not a bug in the compiler - it is the compiler doing exactly what the standard licenses.
Why C and C++ have so much of it
There are roughly 200 distinct undefined behaviors in the C standard. That number is not an accident or an oversight; it falls directly out of C's founding goals, and C++ inherited every one.
- Portability across wildly different hardware. C was designed to compile to one's-complement machines, sign-magnitude machines, word-addressed machines, and segmented memory. Rather than mandate one behavior (and make C slow on hardware that disagreed), the committee left the disputed cases undefined and let each machine do what was natural. Signed integer overflow is the canonical example: it is UB partly because not every 1970s CPU wrapped two's-complement.
- Pointers are just integers. A
T*is an address with no attached provenance, lifetime, or bounds. The hardware has no notion of "this address is no longer valid," so dereferencing a dangling or out-of-bounds pointer can't be defined - there is nothing to define it to. - No runtime bookkeeping by default. C refuses to pay for bounds tags, lifetime tracking, or null checks you didn't ask for. The "you don't pay for what you don't use" ethos means the checks that would turn UB into a defined error simply aren't there.
- UB as an optimization contract. Over the decades, compiler writers reinterpreted "undefined" from "do whatever the hardware does" into "assume it never happens and optimize accordingly." This was lucrative for benchmarks and quietly changed C from a portable assembler into a language where the source no longer reliably predicts the machine code.
C++ piles its own UB on top of C's: reading through a dangling reference, invalidated iterators, violating the one-definition rule, data races, calling a pure virtual during construction, and more. Every abstraction (templates, RAII, move semantics) adds defined power and new ways to trip into the undefined.
The sword's good edge: real optimizations UB enables
UB is genuinely useful, and any honest account has to grant that. The compiler turns "this never happens" into faster code:
Signed overflow is UB → loops vectorize and induction variables widen. Because i + 1 can be assumed not to overflow, the compiler can promote a 32-bit loop counter to a 64-bit register, prove a loop terminates, and reorder arithmetic freely:
/* Because signed overflow is UB, the compiler may assume i never wraps,
so it can prove this loop runs exactly n times and vectorize the body. */
long sum(int *a, int n) {
long s = 0;
for (int i = 0; i < n; i++) /* if i could wrap, i < n might never end */
s += a[i];
return s;
}
If signed overflow were defined to wrap (as -fwrapv makes it), the compiler would have to consider the case where i wraps to a huge negative value and the loop misbehaves - defeating the analysis. The same "assume no UB" rule lets the compiler:
- delete a null check after a dereference (the kernel example above - usually you want this);
- assume
*pand*qof different types don't alias (strict aliasing), keeping values in registers across stores; - treat reaching the end of a non-
voidfunction withoutreturnas unreachable, pruning branches; - assume a
restrict-qualified pointer is the only path to its data, enabling aggressive reordering.
// Strict aliasing: int* and float* "cannot" point at the same bytes,
// so the compiler may keep *pi in a register across the store to *pf.
int scale(int *pi, float *pf) {
int x = *pi; // load once...
*pf = 3.14f; // ...this store "can't" touch *pi...
return x + *pi; // ...so *pi may be reused from the register. UB if they alias.
}
Every one of these is a measurable win on real code. That is why UB persists: it is the price C and C++ pay for being, on a good day, as fast as hand-written assembly.
The sword's bad edge: when "can't happen" happens
The catastrophes share one shape: the programmer wrote a safety check, the optimizer proved it redundant using a UB assumption, and deleted it - so the very check meant to prevent disaster is the thing that disappears.
CVE-2009-1897 - the Linux kernel tun driver. The function tun_chr_poll dereferenced tun->sk and then checked if (!tun). GCC reasoned: tun was already dereferenced, so by the no-UB rule it cannot be null, so the check is dead - and removed it. On a kernel where a user could mmap the zero page, an attacker mapped address 0, made tun legitimately null, and turned the deleted check into a privilege-escalation primitive. The fix in the kernel build was the flag -fno-delete-null-pointer-checks, which tells GCC to stop exercising this perfectly legal optimization.
/* Shape of the bug: the order of these two lines makes the null check vanish. */
struct sock *sk = tun->sk; /* dereference => "tun is non-null" assumed */
if (!tun) /* provably dead under that assumption => DELETED */
return POLLERR;
The classic overflow check that compiles to nothing. A natural way to test for overflow is to do the addition and see if the result went backwards - but the addition itself is the UB, so the check is dead:
/* WRONG: if a + b overflows, that's UB; the compiler assumes it didn't,
so (a + b < a) is "always false" and this entire guard is removed. */
int safe_add(int a, int b) {
if (a + b < a) return -1; /* deleted by the optimizer */
return a + b;
}
The function silently returns a garbage sum in optimized builds and "works" in debug builds - the signature of UB, where the bug depends on optimization level, compiler version, and phase of the moon. (The correct check uses __builtin_add_overflow or unsigned math.)
The deeper problem is non-locality: UB is allowed to corrupt the program as a whole. A use-after-free in one module can manifest as wrong output three functions away; an uninitialized read can make a branch go both ways. This is why UB bugs are so hard to reproduce - adding a printf, compiling at -O0, or running under a debugger changes the assumptions the optimizer made, and the bug moves or vanishes.
The C/C++ defense: make UB loud with sanitizers
You cannot remove UB from C, but you can instrument the program so that hitting UB crashes immediately, at the scene, with a stack trace - instead of silently miscompiling. This is detection, not prevention, and it only covers the code paths your tests actually exercise, but it is transformative.
UndefinedBehaviorSanitizer (UBSan) - -fsanitize=undefined in Clang and GCC - inserts checks for the arithmetic and pointer UBs specifically: signed overflow, shifts past the bit width, null dereference, misaligned access, and out-of-bounds where the size is known.
// clang -fsanitize=undefined -g add.c && ./a.out
int safe_add(int a, int b) {
return a + b; // UBSan at runtime: "signed integer overflow: a + b cannot be
// represented in type 'int'", with a trace
}
AddressSanitizer (ASan) - -fsanitize=address - surrounds allocations with poisoned redzones and shadow memory to catch the memory UBs: heap/stack buffer overflow, use-after-free, use-after-return, double-free. Roughly 2x slowdown.
MemorySanitizer (MSan) - Clang-only - tracks bit-level definedness to catch the one ASan misses: branching or computing on uninitialized memory.
// Run the test suite under all three (ASan+UBSan compose; MSan is separate):
// clang++ -fsanitize=address,undefined -g -O1 ...
// clang++ -fsanitize=memory -g -O1 ...
std::vector<int> v(4);
return v[10]; // ASan: heap-buffer-overflow read, 24 bytes past the block
Pair these with Valgrind/Memcheck (no recompile, heavier slowdown, tracks uninitialized values), the static analyzers (-fanalyzer, clang --analyze), -Wall -Wextra -Werror, and _FORTIFY_SOURCE for compile-time-checked memcpy/strcpy. None of this makes C safe; together they turn most UB from silent corruption into a loud, fixable abort on the paths you test.
Zig: UB renamed Illegal Behavior, and checked by default
Zig is the sharpest rebuttal to C's UB model, and it starts with the vocabulary. Zig does not say "undefined behavior"; it says Illegal Behavior (IB), and splits it by when it is caught:
- Compile-time detectable IB is a compile error - the program never builds.
- Runtime-detectable IB (integer overflow, index out of bounds, unwrapping null, etc.) panics in
DebugandReleaseSafebuilds, where the compiler inserts safety checks. The same operation is genuine, unchecked IB only inReleaseFastandReleaseSmall, where the checks are dropped for speed. - Non-detectable IB - reading a value that is
undefinedis the notable case - has no runtime check in any mode: it is always IB. (In safe builds Zig fillsundefinedmemory with the0xaabyte pattern as a debugging aid, but this is an implementation detail, not a guaranteed safety check, so the bug is not reliably caught.)
The crucial inversion versus C: safety is the default, and you opt out per region. Where C's overflow check silently vanished, Zig's overflow traps:
const std = @import("std");
pub fn main() void {
var a: u8 = 250;
a += 10; // Debug/ReleaseSafe: panic "integer overflow", with a stack trace
// ReleaseFast/ReleaseSmall: Illegal Behavior (no check) - your choice
std.debug.print("{d}\n", .{a});
}
Overflow is not the only one. Zig makes the intent explicit at the operator level so you never accidentally rely on UB: if you actually want two's-complement wraparound, you say so with the wrapping operators +%, -%, *%; if you want a saturating add, you use +|. Bounds checks and null-unwrap checks behave the same way - panic in safe modes, elided in fast modes (reading undefined is the exception: it is never checked at runtime):
const xs = [_]u8{ 1, 2, 3 };
const i: usize = 5;
_ = xs[i]; // safe modes: panic "index out of bounds: index 5, len 3"
const p: ?*u32 = null;
_ = p.?; // safe modes: panic "attempt to use null value"
var u: u32 = undefined; // explicit: this is intentionally uninitialized
_ = &u; // (silences "never mutated"; u stands in for real later use)
_ = u + 1; // reading `undefined` is Illegal Behavior - and unlike the
// cases above it is NOT checked at runtime in any mode; safe
// builds only fill it with 0xaa as a debugging aid
And when a measured hot loop cannot afford the checks, you disable them locally and visibly with @setRuntimeSafety(false), rather than turning the whole program unsafe. The philosophy is the exact opposite of C's: the compiler may not assume IB never happens in safe builds - it inserts a check to find out - so the source you read predicts the machine code you get. You buy speed by explicitly waiving safety in the small, not by hoping you never tripped a global landmine.
Hare and Odin: define what C left undefined
The other modern C-family languages take a third path: rather than rely solely on runtime checks, they narrow the set of undefined cases in the language spec itself, so whole categories of C's UB simply don't exist.
Hare repurposes the word "undefined" almost entirely into compile errors, and gives defined semantics to the arithmetic C leaves open. Signed overflow and underflow are defined (two's-complement wraparound); shifting by more than the width is defined; a byte is always 8 bits. Null is opt-in at the type level - a pointer is non-nullable unless declared with the nullable attribute, and a nullable pointer cannot be dereferenced until you match it against null, which closes the entire C null-deref UB class at compile time:
fn use(p: nullable *int) int = {
// p as *int is a COMPILE ERROR - you must test for null first.
match (p) {
case null =>
return -1;
case let q: *int =>
return *q; // here q is statically known non-null
};
};
Hare's arrays and slices are bounds-checked at runtime; the only escape is the explicitly "unbounded" [*] array type, which advertises that it has no length and no checks - UB you have to ask for by name.
Odin goes further still, aiming for (near) zero undefined behavior as a design goal. Two decisions stand out against C. First, integer overflow is well-defined: signed + - * / << may legally overflow with deterministic results, overflow does not panic, and - critically - a compiler may not optimize under the assumption that overflow does not occur. That single rule retroactively makes the C overflow-check disaster impossible. Second, slice and array indexing is bounds-checked by default (at compile time where the index is constant, at runtime otherwise), and you opt out locally with #no_bounds_check:
package main
import "core:fmt"
main :: proc() {
xs := []int{10, 20, 30}
i := 5
fmt.println(xs[i]) // runtime bounds-check panic: index 5 out of range 3
#no_bounds_check {
// here you have explicitly taken responsibility - no check, C-like
_ = xs[i]
}
}
Division by zero panics at runtime in Odin rather than being undefined. What neither language pretends to eliminate is the memory-lifetime UB that comes with manual management and no borrow checker: use-after-free, double-free, and data races are still on the programmer. They shrink the UB surface dramatically; they do not make it zero.
HolyC and Forth: no UB machinery, total trust
At the far end sit the two languages that don't fight UB at all - not by defining it away, but by removing the entire apparatus that would catch it.
HolyC, Terry A. Davis's C dialect and the native language of TempleOS, runs in a single 64-bit, ring-0, identity-mapped address space with no memory protection of any kind. In ordinary C an out-of-bounds write or wild pointer at least usually meets a page fault and a segfault - the OS turns UB into a contained crash. TempleOS has no such referee: a stray write doesn't fault, it silently corrupts whatever it lands on, up to and including the kernel and the running task's own JIT-compiled code. There is no ASan, no UBSan, no paging boundary. This is coherent on its own terms - Davis designed TempleOS as a "modern Commodore 64," a machine simple enough for one person to understand completely, where the price of total control is total responsibility. HolyC's answer to undefined behavior is not a guardrail; it is the deliberate absence of one.
// HolyC (highlighted as C): single ring-0 address space, no protection.
U8 *p = MAlloc(16); // from this task's data heap
Free(p);
p[0] = 'x'; // use-after-free: no fault, no sanitizer - silent corruption
// of whatever now owns that memory, possibly the kernel
Forth removes even the type system. Cells are untyped machine words; memory access is the raw @ (fetch) and ! (store), with no bounds, no provenance, no abstraction over the address space. Every UB in this article is trivially expressible and nothing in the language objects:
CREATE buf 16 ALLOT \ reserve 16 bytes named buf
buf 1000 + @ \ fetch from buf+1000 - far out of bounds, no check at all
0 @ \ dereference address 0 - whatever that means on this target
Forth's defense is not detection but radical smallness: a complete Forth fits in a few kilobytes, so you can hold the entire memory model in your head and audit it by hand. Like HolyC, it trades the safety net for total transparency and trust.
The spectrum, in one table
| Language | UB philosophy | Integer overflow | Bounds / null | Built-in detection |
|---|---|---|---|---|
| C | ~200 UBs; "assume it never happens" → optimize | UB (license to optimize) | none; null-deref UB | external: ASan / UBSan / MSan / Valgrind |
| C++ | inherits all of C's, adds its own (dangling refs, ODR, iterator invalidation) | UB | opt-in .at(); []/span unchecked by default |
same sanitizers; opt-in safe containers |
| HolyC | no UB machinery; ring-0, no memory protection | wraps (C-like) | none; corruption isn't even contained | none - by design |
| Zig | renamed Illegal Behavior; checked by default | panics in safe builds; +% to wrap |
bounds/null checked in safe builds | built-in: safe-mode panics, @setRuntimeSafety |
| Hare | "undefined" ≈ compile error; defines C's gaps | defined (wraps) | bounds-checked; null is opt-in, must be tested | runtime bounds checks; nullable types |
| Odin | goal of (near) zero UB | defined; compiler may not assume no overflow | bounds-checked by default; #no_bounds_check to opt out |
runtime bounds/div-by-zero panics |
| Forth | no types, no checks, no UB concept | wraps (machine word) | none; any address goes | none |
Takeaways
- Undefined behavior is the standard saying "no requirements." The compiler may assume UB never happens and optimize on it - which is simultaneously the source of C/C++ performance and of their most dangerous, non-local, hard-to-reproduce bugs.
- C has ~200 UBs by design, for 1970s portability and a "don't pay for checks you didn't ask for" ethos, later reinterpreted by compiler writers into an aggressive optimization contract. C++ inherits all of them and adds more.
- The good edge is real: assuming no signed overflow vectorizes loops and widens counters; strict aliasing and
restrictkeep values in registers. The bad edge is the same rule deleting your safety checks - CVE-2009-1897 erased a kernel null check; the naive overflow guarda + b < acompiles to nothing. - For C/C++ today the non-negotiable hygiene is sanitizers: test under
-fsanitize=address,undefined(and MSan/Valgrind for uninitialized reads). They are detection, not prevention, and only cover paths you exercise - but they turn silent miscompilation into a loud, located crash. - Zig inverts the default: "Illegal Behavior" is checked and panics in
Debug/ReleaseSafe, with explicit wrapping operators and@setRuntimeSafety(false)to opt out locally - you waive safety in the small, not by accident in the large. - Hare and Odin shrink the UB surface in the spec: both define signed overflow (so the optimizer can't exploit it), bounds-check by default, and make null/length explicit - though manual-memory UB (use-after-free, races) remains. HolyC and Forth remove the safety machinery entirely and bet on radical simplicity and trust.