🧬 Types & Qualifiers, In Depth
Undefined behavior: here be nasal demons
▶ Open the interactive lesson — free, no signupIn 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
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
*pthe 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
#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;
}$ 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 behavior | typical crime scene |
|---|---|
| signed integer overflow | INT_MAX + 1, absolute value of INT_MIN |
| out-of-bounds access | a[n] on an n-element array — the beloved off-by-one |
| use after free / double free | any pointer used after its object's lifetime ends |
| reading uninitialized variables | int sum; sum += x; |
| NULL dereference | unchecked malloc, unchecked find() |
| data races | two threads, one non-atomic object, no sync |
| shift ≥ width, or negative shift | 1 << 32 on 32-bit int |
| modifying a string literal / const object | char *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 + 1int i = INT_MAX; i + 1int 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:
- Performance: checking every array index, every pointer, every add for validity would cost cycles on every operation. UB says: no mandatory checks — programs pay only for what they use. It also licenses big optimizations: assuming
i++never wraps is what lets loops vectorize. - Portability (of the language, across weird machines): C runs on hardware that traps on overflow, on machines where NULL isn't bit-pattern zero. By defining nothing, the standard lets each platform do what's natural and fast there.
🤔 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
#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;
}$ 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- Always:
-Wall -Wextra(and treat warnings as errors with-Werrorin CI). Free, instant, catches whole bug classes. - During development:
-fsanitize=address,undefined— ASan catches out-of-bounds and use-after-free at the moment they happen; UBSan pinpoints overflow, bad shifts, misaligned access with file:line precision. - Occasionally: Valgrind (no recompile needed), and
-O2vs-O0behavior differences as a smoke alarm: if optimization "breaks" your program, suspect UB in your code first, not a compiler bug.
🧠 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)