The Undefined Behaviour Catalogue
The full list in one place, from uninitialized reads and signed overflow to out-of-bounds access, null and dangling dereference, double free, and unsequenced modification: why the standard promises nothing, and which of these the sanitizer can and cannot catch.
A Property of the Program, Not of the Run
Chapter 4 closed by gathering AddressSanitizer's reports into one list, and last lesson did the same for gcc's warnings. This lesson is the third and last of those catalogues, and it is the one the other two were pointing at: the list of things the language itself refuses to define. Start with the words, because they are chosen precisely and almost everyone softens them. Undefined behaviour means the standard imposes no requirement at all. Not that the result is unpredictable, not that you get whatever the bytes happened to hold, and not that the program crashes. Those are descriptions of what sometimes happens, and each of them is a promise the standard never made. There is no requirement on the value, no requirement on the statements around it, and no requirement that the program stop, continue, or produce the output it produced yesterday.
That framing has a consequence worth stating on its own line, because it is the sentence this whole lesson exists to deliver. Undefined behaviour is a property of the program, not of a run. A program that reads an uninitialized int has no defined meaning, and that remains true on the run where it printed the number you wanted, on the run under the debugger, and on the thousand runs in your test suite that all passed. You have not been observing the program behaving correctly; you have been observing one thing that a program with no defined behaviour was permitted to do. This is why chapter 1 lesson 2's int count; printing count is 0 three times in a row was presented as a trap rather than a result, and why the honest report of any UB experiment is "here is what one build did once", never "here is what this does". Nothing you can observe distinguishes code that works from code that has not failed yet, which is exactly why the catalogue below is worth memorizing rather than deriving from experience.
Three Kinds of "It Depends"
Chapter 2 lesson 1 separated three terms that get used interchangeably everywhere except in the standard, and the catalogue only makes sense once they are apart. The difference is what the implementation owes you.
| Kind | What is required | Examples from this course |
|---|---|---|
| Undefined | Nothing whatsoever | Signed overflow; reading an uninitialized object; indexing outside an array |
| Unspecified | One of several valid behaviours, chosen freely, no documentation owed | Which of f() and g() runs first in printf("%d %d\n", f(), g()) |
| Implementation-defined | One of several valid behaviours, chosen freely, and the choice must be documented | Whether plain char is signed; -8 >> 1, which gcc answers as -4 here |
Only the first row is a hole in the language, and the practical difference is what you are allowed to do about each. Implementation-defined behaviour can be looked up and relied on if you accept that the reliance is on your compiler rather than on C, which is why chapter 2 could tell you that -8 >> 1 is -4 on this platform and still tell you to do bit work on unsigned types. Unspecified behaviour cannot be looked up, so the only move is to write code that does not depend on the choice: one side effect per statement, and no argument that modifies what another argument reads. Undefined behaviour offers neither option. There is no set of outcomes to choose from and nothing to depend on, so the only response is not to write it.
Why C Has the Hole
It would be easy to read the catalogue as a list of oversights. It is closer to a price list. C's bargain is that the language emits no check you did not ask for: values[i] is an address computation and nothing else, and a + b is one instruction rather than an instruction plus a test and a branch. Defining what values[i] means out of bounds would require knowing the bounds at run time, and defining a + b at INT_MAX would require a test on every addition in every program. C declines to charge everyone for those, and the unchecked cases have to be called something. They are called undefined.
The second half of the bargain is the one that surprises people, and chapter 2 lesson 1 promised it: the compiler is permitted to generate code on the assumption that undefined behaviour never occurs. That is not the compiler being aggressive, it is the only reading available, since a program that performs undefined behaviour has no meaning for the compiler to preserve. You saw one consequence in chapter 3 lesson 4, where gcc at -O2 returned a null pointer instead of the address of a dead frame, and another in chapter 2 lesson 5, where a loop counter cannot be allowed to climb past INT_MAX because the compiler may assume the increment never overflows. Here is that assumption doing its work where you can watch it. The next program is wrong on purpose.
#include <stdio.h>
int main(void)
{
int x = 0;
if (scanf("%d", &x) != 1)
{
fprintf(stderr, "expected an integer\n");
return 1;
}
int next = x + 1;
printf("next > x is %d\n", next > x);
return 0;
}
Every value of x except one makes that a boring program. The exception is INT_MAX, where the addition overflows, and overflowing a signed int is undefined behaviour rather than a wrap. Compiled with this platform's flags and given 2147483647, it prints the first line below. Compiled from the same file, not a character changed, with -O0 in place of -O2 and given the same input, it prints the second.
next > x is 1
next > x is 0
Take the -O2 answer first, because its reasoning is fully legitimate. Signed overflow is undefined, so gcc may compile as though x + 1 never overflows; under that assumption next is greater than x for every x, the comparison is a constant, and the printed value can be decided while compiling without any comparison instruction being emitted at all. The value of x is never consulted, which is why feeding it INT_MAX changes nothing. Now resist the obvious reading of the -O0 answer, because it is the trap. That 0 is not the true result that optimization concealed. It is what you get when the compiler declines to reason about the addition and the hardware wraps, and the standard required that no more than it required the other. Same program, two flags, two answers, and no third thing to appeal to. That is what "no requirements" means in practice, and it is the arithmetic twin of chapter 1 lesson 2, where the same source printed count is 0 at -O2 and count is 2 at -O0. Neither build warned, and AddressSanitizer had nothing to say about either.
The fix is not a flag. It is asking the question before the overflow rather than after, which is chapter 2 lesson 4's rule about dividing by zero in another costume: you check before, or not at all.
#include <limits.h>
#include <stdio.h>
int main(void)
{
int x = 0;
if (scanf("%d", &x) != 1)
{
fprintf(stderr, "expected an integer\n");
return 1;
}
if (x == INT_MAX)
{
printf("x is already the largest int\n");
return 0;
}
int next = x + 1;
printf("next > x is %d\n", next > x);
return 0;
}
Given 2147483647 that prints x is already the largest int, and given 5 it prints next > x is 1. Both answers are now the same at -O2 and at -O0, which is the only sense in which a program can be said to have an answer at all.
The Catalogue: Memory
Here is the list, split the way the tools split it. These eight are memory errors, and this is AddressSanitizer's territory.
| Undefined behaviour | One-line example | Met in | gcc | AddressSanitizer |
|---|---|---|---|---|
| Reading an uninitialized object | int n; printf("%d\n", n); |
Ch1 L2, Ch4 L3 | -Wuninitialized or -Wmaybe-uninitialized when it can see the path |
Nothing |
| Indexing outside an array | values[count] on count elements |
Ch4 L1, Ch4 L5 | -Warray-bounds, constant bounds only |
stack- or heap-buffer-overflow |
| Dereferencing one past the end | *(values + count) |
Ch4 L2 | -Warray-bounds, constant bounds only |
stack-buffer-overflow |
| Use after free | free(p); p[0] = 1; |
Ch4 L3, Ch5 L5 | -Wuse-after-free sometimes |
heap-use-after-free |
| Double free | free(p); free(p); |
Ch4 L3, Ch4 L4 | Sometimes | attempting double-free |
| Using a dangling pointer to an automatic | return &local; |
Ch3 L4 | -Wreturn-local-addr |
SEGV, or stack-use-after-scope for the inner-block case |
| Dereferencing a null pointer | *p where p is NULL |
Ch1 L4, Ch3 L3 | Nothing | SEGV on unknown address 0x000000000000 |
| Modifying a string literal | char *s = "hi"; s[0] = 'H'; |
Ch5 L1 | Nothing through a char * |
SEGV, the literal being on a read-only page |
Two rows deserve a second look. The first is the one entry in the memory group that the sanitizer does not catch, and it is the one people assume is safest: reading an uninitialized object. Chapter 4 lesson 3 printed a fresh malloc element and got -1094795586, which is the sanitizer's own fill byte repeated rather than an answer, along with a -Wmaybe-uninitialized warning and no report at all; chapter 1 lesson 2's local drew a -Wuninitialized warning and then quietly printed 0 at -O2. Both times the compiler was the tool that caught it, and both times it caught it only because the mistake was visible in the text. The second is the one-past-the-end row, where the rule is finer than "out of bounds is bad". Chapter 4 lesson 2 established that forming the address one past the last element is explicitly legal, because loops need it as a limit; it is dereferencing it that is undefined. values + count is a pointer you may compute and compare against, and *(values + count) is a read outside the array like any other.
The Catalogue: Arithmetic and Everything Else
These seven are the gap. AddressSanitizer watches addresses, and none of these touch an address illegally, so its column has one entry repeated.
| Undefined behaviour | One-line example | Met in | gcc | AddressSanitizer |
|---|---|---|---|---|
| Signed integer overflow | x + 1 where x is INT_MAX |
Ch2 L1, Ch2 L5 | Nothing for run-time values | Nothing |
| Shifting by a negative amount or by at least the width | 1 << 32 |
Ch2 L3 | -Wshift-count-overflow, constant counts only |
Nothing |
| Left-shifting a signed value into its sign bit | 1 << 31 on int |
Ch2 L3 | Nothing | Nothing |
| Integer division or remainder by zero | total / count with count at 0 |
Ch2 L2, Ch2 L4 | Nothing for run-time values | Nothing |
| Modifying an object twice between sequence points | i = i++; |
Ch2 L2 | -Wsequence-point |
Nothing |
Mismatched printf conversion |
printf("%d\n", 3.0); |
Ch1 L1 | -Wformat |
Nothing |
Falling off the end of a non-void function and using the result |
a path with no return |
Ch3 L1 | -Wreturn-type |
Nothing |
Three of those need a sentence each. Integer division by zero is undefined; floating-point division by zero is not, and this chapter's first lesson is where you saw the difference, with 1.0 / 0.0 producing inf and 0.0 / 0.0 producing nan as ordinary values you can test and print. Do not let the shared shape of the expression collapse the two: it is the operand types that decide. i = i++ is not an evaluation-order puzzle with a hard answer. Chapter 2 lesson 2 was explicit that modifying an object twice with nothing sequencing the two modifications is undefined behaviour rather than something you work out from precedence, and printf("%d %d\n", i++, i) is worse still, since the unspecified argument order and the unsequenced modification are two separate problems in one line. And the entry the course has never demonstrated: calling a function through a pointer of an incompatible type is undefined behaviour too. Function pointers are beyond this course, so take that one on trust rather than from a demo, and note it as evidence that the catalogue continues past what a first course can show you.
The Toolkit, Completed
Last lesson ended by saying that gcc catches what is provably wrong at compile time, AddressSanitizer catches memory errors at run time, and the undefined behaviour in between belongs to neither. The two tables above are that sentence with the names filled in. Read the gcc column and the pattern is that it fires when the mistake is visible in the text: a constant array bound, a constant shift count, a format string it can match against its arguments. Read the AddressSanitizer column and the pattern is that it fires when a bad address is touched, which is most of the memory group and none of the rest. Put the columns beside each other and the honest summary is that a warning-free compile and a clean sanitizer run rule out roughly half of this lesson, and are silent about the other half.
So the third check is not another tool, and it is the one this whole course has been building toward: write code whose meaning is defined in the first place. That is what "initialize every variable and every pointer", "carry a count beside every pointer", "set a pointer to NULL after freeing it", "one side effect per statement", "do bit work on unsigned types", and "check the divisor before you divide" have all been. None of them are style preferences, and now you can say exactly what each one buys: it keeps a program out of a category where the standard promises nothing, the compiler may assume you never went there, and no run you can perform will tell you whether you did.
Key Takeaways
- Undefined behaviour means the standard imposes no requirement at all on the program. It is not "unpredictable", not "garbage", and not "a crash", and describing it that way makes it sound survivable.
- Undefined behaviour is a property of the program, not of a run. A program with UB has no defined meaning even on the runs where it prints exactly what you expected, so a passing test proves nothing about it.
- Undefined means nothing is required; unspecified means the implementation picks and need not say which, as with argument evaluation order; implementation-defined means it picks and must document, as with the signedness of
char. Only the first is a hole you cannot write around. - The compiler may generate code assuming undefined behaviour never happens, which is why the same source can print
next > x is 1at-O2andnext > x is 0at-O0forINT_MAXwith no warning from either build. Neither answer is the real one. - AddressSanitizer covers most of the memory group and none of the arithmetic group. Signed overflow, shift UB, integer division by zero and unsequenced modification pass straight through it, and so does reading an uninitialized object, which sits in the memory group and is caught only when gcc can see it.
- gcc fires when the mistake is visible in the text, which is why constant bounds and constant shift counts are diagnosed and the same mistakes with values from
scanfare not. A clean compile and a clean run are necessary, never sufficient, and the third check is writing defined code.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
The Undefined Behaviour Catalogue - Quiz
Test your understanding of the lesson.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!