C Part I · Module 13 · Writing Correct and Reliable C
Undefined Behavior in C
This phrase has been appearing since Module 2 with a working definition of roughly anything could happen. That is good enough to make you careful and not good enough to reason with, because it suggests a program with undefined behaviour is one whose output is unpredictable — a program that might print the wrong number or crash. The reality is both more precise and considerably stranger. The standard's wording is that it imposes no requirements on the behaviour of the program, and a compiler is entitled to read that as a promise from you that the situation never arises. This chapter measures what a compiler does with such a promise, and the answer is visible in two instructions: a safety check, written by the programmer, deleted from the program it was meant to protect.
Advanced20 min readCPart IFundamentalsCorrectness
Part I · Module 13 · Chapter 13.2 · Undefined Behavior in C
1. The Definition
The standard's wording is short enough to quote, and the exact words matter:
C17 §3.4.3 — undefined behavior: behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this International Standard imposes no requirements.
And a note: Possible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message).
Three phrases do all the work.
"Imposes no requirements" — on what? On the behaviour of the program. Not on the value of one expression, not on the result of one operation. The standard stops constraining the whole program.
"During translation or program execution" — so the consequences are permitted at compile time as well as at run time. A compiler may act on undefined behaviour while building the program, which is §3's subject.
"With or without the issuance of a diagnostic message" — the implementation is under no obligation to tell you. Chapter 13.1 measured how often it does not: fourteen of fifteen defects silent.
Undefined behaviour is not a kind of result. It is the absence of any requirement on the program at all.
That is why "it worked when I ran it" carries no information. A program with undefined behaviour that produced the right answer is not a working program; it is a program with no defined meaning that happened to produce the right answer this time, on this compiler, at this optimisation level.
2. What It Is Not
Three things a reader usually believes, each wrong in a way that matters.
Not "the value is unpredictable". If x had an unpredictable value, you could still reason about the program: it would do one of the things it does for some value of x. Undefined behaviour removes that guarantee, as §3 shows.
Not "it will crash". A crash is one permitted outcome among all of them, and it is the lucky one because you find out. Chapter 9.10 §4 measured why use-after-free so often does not crash: the freed bytes are usually still there, so the program sails on with wrong data.
Not "it is the same thing as unspecified or implementation-defined". Those are two different categories, and they are weaker in ways that genuinely matter — one of them is something you may legitimately rely on. Chapter 13.3 is about the distinction; what matters here is only that undefined is the strongest of the three and the only one with no constraints at all.
3. The Measurement
/* the dereference happens BEFORE the null check */
int check_after_use(int *p)
{
int v = *p;
if (p == 0) { return -1; }
return v;
}
/* the check first, which is defined for every p */
int check_first(int *p)
{
if (p == 0) { return -1; }
return *p;
}Nothing was reported at -Wall -Wextra -Wpedantic. Now compile check_after_use at each optimisation level and count what came out:
-O0 17 instructions conditional/unconditional branches: 4
-O1 2 instructions conditional/unconditional branches: 0
-O2 2 instructions conditional/unconditional branches: 0
-O3 2 instructions conditional/unconditional branches: 0
-Os 2 instructions conditional/unconditional branches: 0The entire function at -O2:
_check_after_use:
ldr w0, [x0]
retThe null check is not in the program. There is no comparison, no branch, and no path that returns −1. The code the programmer wrote to handle a null pointer does not exist in the compiled function.
The reasoning that produced it is short and valid:
*pis undefined ifpis null.- The standard imposes no requirements on a program that does it, so the compiler may assume it does not happen.
- Therefore
pis not null at line 3. - Therefore
p == 0on line 4 is false. - Therefore the
return -1is unreachable, and dead code is removed.
Every step follows from the one before, and step 2 is the standard's own wording. The compiler is not being hostile; it is taking you at your word.
4. Why "Anything Could Happen" Understates It
Section 3 demonstrates something the usual phrasing does not prepare you for.
Undefined behaviour is not confined to the operation that caused it, and it is not confined to code that runs after it. A compiler may propagate the assumption backwards and forwards through the function, deleting checks, folding branches and removing code that would otherwise have run — including code written specifically to prevent the problem.
The deleted check in §3 sits after the dereference in the source. A reader tracing the program by eye sees a dereference, then a test, then a return. The compiled program has no test. The consequence of the undefined operation reached code the programmer had already written to handle it.
Chapter 9.12 §3 measured the extreme version of this without naming it:
int contradiction(void)
{
int x;
if (x == 0) { return 1; }
if (x != 0) { return 2; }
return 3;
}_contradiction:
brk #0x1The whole function became a trap instruction. Not a wrong return value — no return at all. Both tests together cover every possible int, so under the random-number theory the function must return 1 or 2. It returns nothing, because reading an uninitialised automatic object is undefined and the compiler concluded the body was unreachable.
Two consequences follow that are worth stating as rules.
You cannot test your way out. Section 3's program behaves differently at -O0 and -O1. If you test a debug build and ship a release build, you tested a different program — and there is no diagnostic marking the difference.
You cannot reason locally. "This line is wrong but the rest of the function is fine" is not available. The rest of the function was compiled under an assumption that line made.
5. "Not Even the Behaviour You Observed Last Run"
c.ts asks this chapter to establish that specifically, and §3 does it: 17 instructions or 2, depending on one flag.
The same program can change behaviour when any of these changes:
| What changed | Why it matters |
|---|---|
| optimisation level | measured — the check exists at -O0 and not at -O1 |
| the compiler version | new optimisations exploit assumptions older ones did not |
| a different compiler | each makes its own choices, all of them legal |
| inlining context | the same function compiled differently at two call sites |
| surrounding code | an edit elsewhere changes what the optimiser can prove |
| the data | the undefined operation may not be reached on your inputs |
An observation about a program with undefined behaviour is a fact about one build, on one machine, on one input. It is not a fact about the program, because the program does not have the property you are trying to observe.
This is why "I tested it and it works" is not evidence, and why a bug that "appeared after we upgraded the compiler" was almost always present and dormant. The upgrade did not introduce it; it changed which of the unconstrained outcomes you got.
6. The Common Sources
You have met all of these. Collected here as a checklist, each with the chapter that measured it.
| Source | Measured in |
|---|---|
| dereferencing a null pointer | 7.4 §3 |
| reading or writing outside an array | 6.4 |
using a pointer after free | 9.10 |
calling free twice on one block | 9.10 |
| using a pointer to an object whose lifetime ended | 9.2, 9.11 |
| reading an uninitialised automatic object whose address was never taken | 9.12 §2 |
| signed integer overflow | 2.11, and 13.4 |
| shifting by a negative amount, or by the width or more | 3.8 |
a printf conversion that disagrees with its argument | 12.2 §2 |
overlapping regions passed to memcpy | 12.9 §2 |
| modifying a string literal | 8.2 |
| two external definitions of one identifier | 11.1 §5 |
Two patterns are worth noticing in that list.
Most of them are about memory and lifetime — which is why Module 9 is where this curriculum spends its effort, and why Chapter 13.10's tools are mostly memory tools.
Several are arithmetic, and arithmetic UB is the kind people least expect because the operation looks so ordinary. a - b in Chapter 12.9's comparator is a subtraction of two ints; nothing about it looks dangerous, and it is undefined. Chapters 13.4 and 13.5 are about that family.
7. What to Do About It
Four things, in order of how much they help.
Do not write it. The list in §6 is finite and most of it is covered by habits this curriculum has already measured: check pointers before dereferencing, carry a length with every pointer, one owner per allocation, initialise at the point of declaration. Chapter 13.11 collects them.
Put the check before the use. Section 3's two functions differ only in line order, and only one of them has a check in the shipped binary. A test after the operation it was meant to guard is not a test.
Run the tools. Chapter 13.1 measured that static analysis plus sanitizers found eleven of fifteen defects where the default warnings found one. Chapter 12.9's comparator overflow and Chapter 9.10's use-after-free were both invisible to the compiler and both reported immediately by a sanitizer. Chapter 13.10 is the proper treatment.
Treat any report as a defect, not a style note. A -Wformat warning (Chapter 12.2 §3) is reporting undefined behaviour. So is a sanitizer diagnostic. Neither is advice.
And one habit of mind that is worth more than any of them:
When something inexplicable happens — a check that does not seem to run, a value that changes with
-O2, a bug that appears after a compiler upgrade — suspect undefined behaviour first. Those three symptoms have very few other causes, and all of them are what this chapter describes.
8. What You Should Be Able to Say Now
- C17 §3.4.3: undefined behaviour is behaviour for which the standard imposes no requirements — on the behaviour of the program, not on one value.
- The consequences are permitted during translation as well as execution, and with or without a diagnostic.
- It is not "an unpredictable value", not "it will crash", and not the same as unspecified or implementation-defined (13.3).
- Measured: a null check written after the dereference was deleted —
-O017 instructions with 4 branches,-O1and above 2 instructions with none. - The whole
-O2body wasldr w0, [x0]andret. No warning was issued. - The compiler's reasoning is valid: the dereference implies
pis not null, so the later test is dead code. - So UB is not confined to the offending operation, and reaches code written after it — including the check meant to prevent it.
- Chapter 9.12 §3's extreme case: a function with two exhaustive tests compiled to
brk #0x1— the whole function. - You cannot test your way out (
-O0and-O1are different programs) and cannot reason locally. - An observation about a UB program is a fact about one build, one machine, one input — hence "it worked last run" is not evidence.
- The common sources are mostly memory and lifetime, plus a few arithmetic ones that look entirely ordinary.
- Check before you use; a test after the operation it guards is not a test.
9. Exercises
Exercise 1 — undefined or not. For each, say whether it is undefined behaviour and name the chapter.
int a = INT_MAX; a = a + 1;
unsigned u = UINT_MAX; u = u + 1;
int x = 1; x = x << 31;
char *s = "abc"; s[0] = 'A';
int v[4]; int n = v[4];
free(p); free(p);
printf("%d\n", 3.0);
int *q = NULL; if (q != NULL) { *q = 1; }Exercise 2 — predict the deletion. For each, say whether a compiler may remove the check and why.
int f(int *p) { int v = *p; if (!p) return -1; return v; }
int g(int *p) { if (!p) return -1; int v = *p; return v; }
int h(int *p) { if (!p) return -1; return *p + *p; }
int k(int n) { int r = 1000000 / n; if (n == 0) return -1; return r; }Exercise 3 — explain the reasoning. Write out, in five numbered steps as in §3, why a compiler may delete the check in check_after_use, and identify which step uses the standard's wording.
Exercise 4 — the test that was not a test. A reviewer says the null check in §3 proves the function is safe. Explain in three sentences why the shipped binary disagrees, and what the reviewer should have looked at.
Exercise 5 — why the upgrade broke it. A program worked for five years and failed after a compiler upgrade, with no source change. Give the most likely explanation, say what was true of the program for all five years, and describe how you would confirm it.
Exercise 6 — the trade. Explain, using §6's callout, why C does not define signed overflow, and name one concrete optimisation that definition would prevent.
Exercise 7 — order the defences. Rank the four responses in §7 by how much they help, and for each say what it cannot do.
Exercise 8 — write the rule. In two sentences, state what "it worked when I ran it" tells you about a program that contains undefined behaviour, and what it would take to make a real claim about that program.
10. Summary
- Undefined behaviour: the standard imposes no requirements on the behaviour of the program — during translation as well as execution, with or without a diagnostic.
- It is not an unpredictable value, not necessarily a crash, and not unspecified or implementation-defined.
- Measured: a null check written after the dereference was deleted — 17 instructions at
-O0, 2 at-O1and above, whole bodyldr w0, [x0]; ret, no warning. - The compiler's reasoning is valid; it takes you at your word.
- So UB reaches code written after it, including the guard itself — and Chapter 9.12's
brk #0x1is the extreme form. - You cannot test your way out and cannot reason locally.
- An observation is a fact about one build, not about the program.
- Sources are mostly memory and lifetime, plus ordinary-looking arithmetic.
- Check before you use. Suspect UB when a check does not run, a value changes with
-O2, or an upgrade "introduced" a bug.
This chapter has been careful to say that undefined behaviour is the strongest of three categories without explaining the other two, and that evasion has to end now, because the distinction is where a great deal of practical C actually lives. Some things the standard declines to define are genuinely dangerous in the way just described. Others it merely declines to choose — leaving an implementation free to pick, sometimes requiring it to document the choice, sometimes not even that. The difference decides whether a construct is a defect, a portability hazard, or something you may rely on completely once you have named your compiler and your target. Three categories, three different answers, and most programmers conflate all three. The next chapter separates them.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
