The C Path — learn C, visually

🧬 Types & Qualifiers, In Depth

Undefined behavior: here be nasal demons

⏱ 16 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

In 2009, a compiler silently deleted a safety check inside the Linux kernel and turned a small bug into a security exploit — and it broke no rules doing so. That's undefined behavior: the reason C programs can "work for years", then fail the day you upgrade your compiler. This lesson is your survival kit, including the tools that catch these bugs at the exact line they happen.

The C standard defines what programs mean — but for certain operations it deliberately says: nothing. "Undefined behavior: behavior, upon use of a nonportable or erroneous program construct, for which this document imposes no requirements." No requirements. Your program may crash, print 42, silently corrupt a file, or appear to work perfectly for ten years and fail during the demo.

🤔

Usenet, 1992: a comp.std.c regular quipped that when a program hits UB, it's legal for the compiler "to make demons fly out of your nose". Nasal demons have been the mascot of UB ever since. The joke has a sharp point: anything is a conforming outcome.

The right mental model: a contract

Think of the standard as a contract between you and the compiler. You promise your program never executes UB; in exchange, the compiler generates blazingly fast code that only has to be correct for programs that keep the promise. UB isn't "an error the compiler catches" — it's a case the compiler is allowed to assume never happens and optimize accordingly. That assumption is where the shocking stories come from.

Exhibit A: the vanishing NULL check

vanish.c — the optimizer deletes your safety net
int get(int *p) {
    int v = *p;          /* deref first...                     */
    if (p == NULL)       /* ...check later. Compiler: "p was   */
        return -1;       /*  dereferenced, so it can't be NULL
                            here" — branch DELETED at -O2      */
    return v;
}

Read line 2: p is dereferenced. The compiler reasons: "if p were NULL, line 2 would be UB, and UB never happens — therefore p isn't NULL — therefore the if on line 3 is dead code." The safety check is deleted. This exact pattern (dereference above a check) caused a famous Linux kernel vulnerability in 2009: the compiler removed a NULL check and turned a bug into an exploit.

🧠 Checkpoint: Why may the compiler delete the NULL check in vanish.c?

  • NULL checks are deprecated
  • Dereferencing NULL is UB, so after *p the compiler may assume p is non-NULL — making the check dead code
  • The function is too small to keep the branch
  • It cannot — this would be a compiler bug
Show answer

Dereferencing NULL is UB, so after *p the compiler may assume p is non-NULL — making the check dead code — UB reasoning runs BACKWARDS from the deref: "well-defined executions never deref NULL, so in every execution I must care about, p != NULL." Deleting the check is then a correct optimization of all non-UB executions. The fix: check BEFORE dereferencing.

Exhibit B: one program, two answers

wraps.c
#include <stdio.h>

int wraps(int x) {
    return x + 1 < x;    /* "detect overflow"... via overflow  */
}

int main(void) {
    printf("%d\n", wraps(2147483647));   /* INT_MAX */
    return 0;
}
terminal
$ gcc -O0 wraps.c && ./a.out
1
$ gcc -O2 wraps.c && ./a.out
0
# same source, both "correct": the program's meaning was undefined

Since signed overflow is UB, the compiler may assume x + 1 never wraps — so x + 1 < x is simply false, at compile time, for all x. At -O0 the wrap physically happens and you see 1. Neither answer is "wrong": a program that executes UB has no defined answer at all.

The classics gallery

undefined behaviortypical crime scene
signed integer overflowINT_MAX + 1, absolute value of INT_MIN
out-of-bounds accessa[n] on an n-element array — the beloved off-by-one
use after free / double freeany pointer used after its object's lifetime ends
reading uninitialized variablesint sum; sum += x;
NULL dereferenceunchecked malloc, unchecked find()
data racestwo threads, one non-atomic object, no sync
shift ≥ width, or negative shift1 << 32 on 32-bit int
modifying a string literal / const objectchar *s = "hi"; s[0] = 'H';

This spot has an interactive memgrid widget — open the interactive lesson to play with it.

🧠 Checkpoint: Which of these is well-defined in C?

  • unsigned u = UINT_MAX; u + 1
  • int i = INT_MAX; i + 1
  • int a[3]; a[3]
  • char *s = "hi"; s[0] = 'H';
Show answer

unsigned u = UINT_MAX; u + 1 — Unsigned arithmetic wraps modulo 2ⁿ by definition — u + 1 is exactly 0. The others are all UB: signed overflow, out-of-bounds read, and writing a string literal.

Why does UB exist at all?

It's not sadism — it's the price of C's two core promises:

🤔 A colleague says: "I tested it — signed overflow just wraps on my machine, so I rely on it." What's wrong with this reasoning?

Think first

They observed one compiler, one flag set, one day. UB has no contract, so the observation predicts nothing: enable -O2, upgrade GCC, or inline the function into a new context, and the optimizer may assume the overflow never happens and restructure the code (see wraps.c). "Works today" is precisely how UB bugs incubate. If wrapping is wanted, use unsigned, or check limits before the operation.

Your defense kit

buggy.c — let the sanitizers point at the crime
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int a[3] = { 1, 2, 3 };
    int sum = 0;
    for (int i = 0; i <= 3; i++)    /* off by one! */
        sum += a[i];
    printf("%d\n", sum);

    int *p = malloc(sizeof *p);
    free(p);
    *p = 5;                          /* use after free! */
    return 0;
}
terminal
$ gcc -g -fsanitize=address,undefined buggy.c && ./a.out
buggy.c:8:16: runtime error: index 3 out of bounds for type 'int [3]'
=================================================================
==4242==ERROR: AddressSanitizer: heap-use-after-free on address
    0x602000000010 ... WRITE of size 4 at buggy.c:13
    freed by thread T0 here: #1 main buggy.c:12
# file and line of the bug AND of the free — debugging on easy mode

🧠 Checkpoint: Your program works at -O0 but misbehaves at -O2. The most likely culprit is…

  • A bug in the optimizer
  • Undefined behavior in your code that the optimizer's assumptions exposed
  • -O2 changes the C standard
  • Insufficient RAM for optimized code
Show answer

Undefined behavior in your code that the optimizer's assumptions exposed — Compiler bugs exist but are rare; UB is everywhere. Optimization doesn't create the bug — it acts on the "UB never happens" assumption your code violated. First move: rebuild with -fsanitize=undefined and listen.

This spot has an interactive editor widget — open the interactive lesson to play with it.

You now hold the full type system — every qualifier, every keyword, every trapdoor. Next stop, Part 4: the layer that rewrites your code before the compiler even sees it — the preprocessor.

▶ Practice this lesson interactively (with live gcc)