Undefined behaviour
The contract you did not know you signed, and how the optimizer uses it.
By the end of this chapter you can
- List the common sources of undefined behaviour
- Explain how UB can make code disappear
- Use sanitizers to catch UB before your users do
Every sample in this book compiles with AddressSanitizer and UndefinedBehaviorSanitizer switched on. You have seen them fire a dozen times. This chapter explains what they are catching, what they cannot catch, and why the language has this category at all.
What the words mean
The standard sorts bad situations into three kinds, and they are not interchangeable.
- Implementation-defined: the implementation must choose and document a
behaviour.
sizeof(int)is 4 on your machine, and your compiler says so. - Unspecified: the implementation chooses from a set of valid behaviours and need not tell you. The order in which function arguments are evaluated, for instance.
- Undefined: the standard imposes no requirements at all. Not “returns garbage”, not “crashes” — no requirements.
That last one is stronger than it sounds. A program with undefined behaviour anywhere in it has no defined meaning as a whole, and the compiler is entitled to assume it never happens.
The compiler assumes you did not do it
This is the part that surprises people. UB is not a run-time event the compiler guards against; it is a premise the optimizer reasons from.
#include <climits>
// If x + 1 overflows, that is UB — so the compiler may assume it never does.
// And if it never does, then x + 1 > x is always true.
bool always_true(int x) {
return x + 1 > x;
}Press Assembly and switch to -O2. The function does not add anything and
does not compare anything — it returns 1 unconditionally. The compiler reasoned:
signed overflow is undefined, therefore it cannot happen, therefore x + 1 is
always greater than x.
For every input where you would want an answer, that reasoning is correct. For
INT_MAX it produces a function that disagrees with arithmetic. Both are
allowed, because you promised not to pass INT_MAX.
The same reasoning removes null checks:
int deref_then_check(int* p) {
const int value = *p; // if p were null, this is already UB
if (p == nullptr) { // ...so p cannot be null, so this is dead code
return -1;
}
return value;
}At -O2 the comparison is gone. Dereferencing p promised it was not null, so
the check that follows can only ever be false. Writing the check after the
dereference is a common mistake in code that has been edited over time, and the
result is that the safety check silently stops existing.
The common sources
The list is long, but a handful account for nearly everything you will hit.
Out-of-bounds access
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
std::cout << "v[5] = " << v[5] << '\n'; // no bounds check, ever
}operator[] does not check. at() does, and throws. Chapter 2.6 covered the
array case; the sanitizer names the allocation and the offset.
Use after free, and after return
#include <iostream>
#include <memory>
int main() {
auto owner = std::make_unique<int>(42);
int* observer = owner.get();
owner.reset(); // the int is destroyed here
std::cout << "reading it anyway: " << *observer << '\n';
}Uninitialised reads
#include <iostream>
int main() {
int values[4];
int total = 0;
for (int i = 0; i < 4; ++i) total += values[i]; // reading indeterminate values
std::cout << "total: " << total << '\n';
std::cout << "(no sanitizer complained — see below)\n";
}That one is undefined, and neither sanitizer said a word. ASan tracks memory
that is out of bounds or freed; these bytes are neither. Catching uninitialised
reads at run time is MemorySanitizer’s job, and MSan is Clang-only and requires
every library it touches to be rebuilt with it — which is why it is rarely used.
Your defence here is -Wall, which catches the cases visible within one
function, and initialising at the point of declaration.
Signed overflow
#include <climits>
#include <iostream>
int main() {
int big = INT_MAX;
std::cout << "INT_MAX = " << big << '\n';
std::cout << "INT_MAX + 1 = " << big + 1 << '\n'; // undefined
}Unsigned overflow is defined — it wraps modulo 2ⁿ. Signed overflow is not, and that asymmetry is deliberate: it lets the compiler assume loop counters do not wrap, which enables a great deal of optimisation.
Invalid shifts, and division by zero
#include <iostream>
int main() {
int value = 1;
int shift = 32; // int is 32 bits: shifting by 32 is UB
std::cout << "1 << 32 = " << (value << shift) << '\n';
}Shifting by an amount greater than or equal to the width of the type is
undefined, as is shifting a negative value left. Integer division by zero is
undefined too — note that floating-point division by zero is not, and gives
you inf.
Lifetime and aliasing
Dangling references (Chapter 2.4), reading an object through a pointer to an unrelated type, and modifying an object twice in one expression without sequencing all belong here.
What the sanitizers catch
Run the samples above and a pattern emerges. Some UB is reported precisely, and some passes in silence.
| Kind of UB | Caught by |
|---|---|
| Out-of-bounds read or write | AddressSanitizer |
| Use after free, use after return | AddressSanitizer |
| Memory leak | LeakSanitizer (part of ASan) |
| Signed overflow, bad shift, division by zero | UndefinedBehaviorSanitizer |
| Null dereference, misaligned access | UndefinedBehaviorSanitizer |
| Data race | ThreadSanitizer (separate, Part 8) |
| Uninitialised read | MemorySanitizer only — rarely available |
| Reading the wrong union member | nothing |
| Most logic errors that happen to be UB-free | nothing |
The two bolded rows are the reason sanitizers are a net, not a proof. This
session’s own writing produced three examples where a genuine bug passed
silently: an uninitialised read printing a plausible 0, a self-assignment that
quietly replaced data with garbage, and 200 leaked file handles that
LeakSanitizer ignored because the C runtime still held them on a list.
g++ -std=c++20 -Wall -Wextra -fsanitize=address,undefined \
-fno-omit-frame-pointer -g -o program main.cppReading a sanitizer report
#include <cstdio>
#include <vector>
int main() {
std::vector<int> v(4, 7);
std::printf("about to read past the end\n");
std::printf("v[9] = %d\n", v[9]);
}The important lines are the first and the last. The first names the kind of
error and the address — heap-buffer-overflow here. The last section says where
the memory came from: which allocation, how large it was, and how far past it
you went. Between them is the stack trace of the access.
When a trace shows only addresses rather than function names, the binary was
built without -g. Add it — the whole value of the report is in the names.
Defending against it
In rough order of how much they buy you:
- Use the types that make it impossible.
std::vectorover raw arrays,std::unique_ptrovernew/delete,std::stringoverchar*,std::optionalover sentinels. Every one of them removes a class of UB by construction, and that is the real argument for Parts 3 and 4. - Turn on warnings.
-Wall -Wextracatches uninitialised reads, unused results, and sign-comparison mistakes within a function. - Run the sanitizers in CI, not just locally.
- Prefer checked accessors at boundaries.
at()where the index came from outside;operator[]in a loop you control. - Compile with
-fwrapvif you genuinely need wrapping signed arithmetic. It makes signed overflow defined at the cost of some optimisation, which is a fair trade when the alternative is being wrong.