C++ Part I · Module 25 · Debugging, Undefined Behavior, and Performance
Undefined Behavior in C++
Undefined behaviour is usually described as unpredictable output, and that description is comforting and wrong. The standard does not say a program with undefined behaviour produces an arbitrary value; it says the program has no defined meaning, which licenses the compiler to assume the undefined thing cannot occur and to rewrite everything that follows from that assumption. The consequence is not a bad number. It is that the code which runs may not correspond to the code you wrote — a branch deleted because the compiler proved it unreachable, a loop entered because the compiler proved it must be, a function reduced to returning a constant because the only way to reach any other answer was through behaviour the standard forbids. This chapter measures exactly that: one three-line function whose compiled body is a comparison at one optimisation level, the constant false at another, and a different comparison at a third. It also records two experiments that failed to reproduce the textbook claim, because an argument about undefined behaviour is worth making only from what the compiler on your desk actually did.
Advanced19 min readC++Part IFundamentalsDebugging
Part I · Module 25 · Chapter 25.1 · Undefined Behavior in C++
1. The Four Categories, Precisely
The standard distinguishes four things, and conflating them is the source of most confusion:
| category | what the standard says | example |
|---|---|---|
| undefined behaviour | no requirements at all on the program | signed overflow, out-of-range access, use after free |
| unspecified behaviour | several outcomes permitted, no record of which | the order in which function arguments are evaluated |
| implementation-defined | one outcome, which the implementation must document | sizeof(int), whether char is signed |
| well-defined | exactly one outcome | unsigned arithmetic wrapping modulo 2ⁿ |
Only the first is dangerous in the way this chapter is about. Unspecified behaviour gives you one of a known set — Chapter 23.2's argument-evaluation note is an instance, and the program is still a program. Implementation-defined behaviour is documented and portable within a platform. Undefined behaviour imposes no requirement on the implementation whatsoever, including no requirement that the rest of the program behave sensibly.
The phrase worth memorising from the standard is that the behaviour is undefined "for which this document imposes no requirements". Not unspecified output — no requirements, which is a statement about the whole program and not about one expression's value.
2. Why That Licenses a Rewrite
Here is the mechanism that makes undefined behaviour different in kind from a bug.
A compiler optimises by proving things. If signed overflow is undefined, then in any program the compiler is required to compile correctly, signed overflow does not happen — because a program where it happens has no defined meaning and therefore places no constraint on the output. So x + 1 > x is a theorem the optimiser may use, exactly like 2 + 2 == 4.
That is not a compiler being hostile. It is the only reading under which the optimiser can do its job at all: without the assumption, every i + 1 in every loop would have to be treated as possibly wrapping, and the ordinary strength reduction and bounds analysis that make loops fast would be illegal.
The price is paid by programs that do overflow. They are not programs with a wrong value in them; they are programs the compiler has reasoned about using a false premise, and anything can follow from a false premise.
3. The Measurement
Three lines:
bool overflowCheck(int x) { return x + 1 < x; }Called with INT_MAX, through a volatile so the value reaches the function at run time. Three builds of the identical source, five runs each:
| build | overflowCheck(INT_MAX) | stable over 5 runs |
|---|---|---|
-O0 | 1 | yes |
-O2 | 0 | yes |
-O2 -fwrapv | 1 | yes |
True, false, and true again — from one source file. And the generated code for that single function explains all three:
sub sp, sp, #16
str w0, [sp, #12]
ldr w8, [sp, #12]
add w8, w8, #1
ldr w9, [sp, #12]
subs w8, w8, w9
cset w0, lt
add sp, sp, #16
retThat is -O0: it loads x, adds one, subtracts, and sets the result from the comparison. The hardware's add wraps, so INT_MAX + 1 is negative, which is less than INT_MAX, so the answer is 1.
mov w0, #0 ; =0x0
retThat is -O2. There is no arithmetic in it. No add, no compare. The compiler proved the function returns false for every input it is required to handle, and emitted exactly that. The comparison you wrote is not in the program.
mov w8, #2147483647 ; =0x7fffffff
cmp w0, w8
cset w0, eq
retAnd that is -O2 -fwrapv, which is the most instructive of the three. -fwrapv tells the compiler that signed overflow wraps — a defined extension — so the premise is withdrawn. The optimiser still optimises: under wrapping, x + 1 < x is true exactly when x == INT_MAX, so it emits a comparison against INT_MAX. One function, three bodies, and the third one is a correct optimisation of a now-defined operation.
The control matters as much as the experiment. The same test written in a wider type, where no overflow occurs:
bool wideCheck(int x) { return (long)x + 1 < (long)x; }returns 0 at -O0, -O1, -O2 and -O3 — no divergence anywhere. The difference between the two functions is not optimisation. It is that one of them is defined and the other is not.
4. What the Sanitiser Says
probe.cpp:5:64: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'-fsanitize=undefined names the file, the line, the column, the operands and the type. That is the best diagnostic available for this class of bug, and Chapter 25.6 is the full treatment.
Two things about it are worth knowing now. It is a run-time check, so it only reports behaviour a particular execution actually reached — it is not a proof of absence. And by default the process exits 0 after printing; -fno-sanitize-recover=all is what turns the report into a failure, which Chapter 24.7 §5 measured and which matters because a gate reading only the exit status would record a pass.
5. Two Experiments That Did Not Reproduce
The literature on undefined behaviour has a standard set of demonstrations. Two of them did not happen on this compiler, and recording that is more useful than repeating the claim.
The null check after a dereference was not removed.
int readId(Dev* d)
{
const int id = d->id; // dereference first -- so d cannot be null
if (d == nullptr) { report(); return -1; } // ...therefore this is dead code
return id;
}The reasoning is sound — a dereference of a null pointer is undefined, so after the dereference the compiler may assume d != nullptr and delete the branch. Measured at -O0, -O1 and -O2, the guard's string literal is still referenced inside readId in all three builds. Clang kept the check. The optimisation is permitted and this compiler, at these levels, on this function, did not take it.
The signed-overflow loop did not terminate under -fwrapv either.
for (int i = INT_MAX - 3; i <= INT_MAX; ++i) body();The textbook claim is that the compiler, knowing i + 1 > i, concludes i <= INT_MAX is always true and emits an infinite loop. Measured: the loop fails to terminate at -O0, -O1, -O2 and with -fwrapv — and that last column is what kills the demonstration. With wrapping defined, i goes from INT_MAX to INT_MIN, and INT_MIN <= INT_MAX is true, so the loop is genuinely infinite as written. The non-termination is a property of the condition, not an artefact of undefined behaviour. Had I published it as a UB demonstration, the claim would have been wrong in a way a careful reader could expose with one extra flag.
The general lesson is in §7's callout, and the practical one is that a UB demonstration needs the -fwrapv-style control: if the behaviour is unchanged when you define the operation, the behaviour was never about the undefinedness.
6. The Catalogue That Matters in This Domain
Undefined behaviour in C++ is a long list. These are the ones that actually occur in engineering code, each with the chapter that measures it:
| the mistake | why it is undefined | measured |
|---|---|---|
| signed integer overflow | §3 | 1 / 0 / 1 across three builds |
| shifting by ≥ the type's width | the standard excludes it | Chapter 24.7 §5 and Chapter 21.2 §3 |
| reading an uninitialised value | indeterminate value | Chapter 25.2 |
| out-of-range access | no object there | Chapter 25.3 |
| use after free, dangling reference | lifetime ended | Chapter 25.4 |
| a data race | unsequenced conflicting access | Chapter 25.6 |
returning no value from a non-void function | flowing off the end | -Wreturn-type |
delete on a pointer to an incomplete type | the destructor cannot be found | Chapter 24.4 §3 |
| violating strict aliasing | two types for one object | §8 |
| infinite loop with no side effects | forward progress | §8 |
memcpy on a non-trivially-copyable type | not an object copy | Chapter 23.6 §6 |
The shift row is worth pairing with §3 because the two together make the chapter's point from both directions. Chapter 24.7 §5 measured the curriculum's own mask() helper: executed at run time it gives 0x00000000 at every optimisation level, because arm64's shift instruction uses its amount modulo 32; constant-folded it gives a value that is stable within one source file and different between programs. So the same undefined expression has one answer when the hardware does it, another when the compiler does it, and no answer in the standard.
7. Why "It Works on My Machine" Is Not Evidence
8. Two More, Because They Are Less Known
Strict aliasing. Accessing an object through a pointer to an unrelated type is undefined, which licenses the compiler to assume an int* and a float* never refer to the same bytes and to reorder loads and stores accordingly. The honest note: the experiment in this chapter's program — write through an int*, then through a float* aliasing the same storage, then read the int* — returned the same answer at -O0 and -O2, so it is not a demonstration. The rule is real and the standard is clear; this compiler at these levels did not exploit it here, and -fno-strict-aliasing exists for code that depends on the violation. Chapter 23.6 §6's is_trivially_copyable gate and std::memcpy are the defined ways to reinterpret bytes.
Forward progress. A loop with no side effects that never terminates is undefined, which is why a compiler may delete an empty spin loop or assume a loop finishes. The practical consequence is for busy-wait code: a spin on a plain variable may be optimised away entirely, and the fix is std::atomic with the appropriate ordering — which is also what makes it not a data race, per Chapter 25.6.
9. What to Actually Do
- Treat undefined behaviour as "the compiler is allowed to assume this never happens", not as "the value is unpredictable". The rewrite is the danger, not the value.
- Run
-fsanitize=undefinedin your debug configuration, with-fno-sanitize-recover=allso a report fails the run. - Turn on
-Wall -Wextra, which Chapter 24.7 §2 measured catching two UB cases out of nine planted defects. - Use unsigned types for anything that wraps deliberately — unsigned overflow is defined, modulo 2ⁿ.
- Use a wider type for an intermediate that might overflow: the
longcontrol in §3 is defined and consistent at every level. - Never conclude anything from "it works". A reproducible answer from undefined behaviour is still one build's choice.
- Run the control.
-fwrapv,-fno-strict-aliasing, a wider type — if defining the operation does not change the behaviour, the undefinedness was not the cause. - Prefer
std::memcpyandis_trivially_copyableover pointer casts for reinterpreting bytes. - Use
std::atomicfor a spin, because a side-effect-free loop may be deleted.
10. Mistakes Worth Making Once
"It's technically UB but it works on our compiler." Measured: the same expression gave 1, 0 and 1 across three builds of one file.
Taking a reproducible result as a defined one. Every build in §3 was stable 5 runs out of 5.
Testing harder instead of using a sanitiser. A passing test on UB code establishes nothing that generalises.
Assuming -O0 shows the truth. It shows what the hardware does, which is one observation among several.
Repeating a textbook UB demonstration without the control. Two of mine did not reproduce.
Using a signed type for a counter that wraps. Unsigned wrapping is defined; signed is not.
A spin loop on a non-atomic variable. It may be removed entirely.
Reinterpreting bytes with a pointer cast. memcpy and a static_assert are the defined route.
11. Interview Relevance
The frozen curriculum marks this chapter Critical, and the question is almost always "what is undefined behaviour?". The answer that separates a good candidate is not the definition but the consequence: because the standard imposes no requirements, the compiler may assume the undefined thing never happens and rewrite the surrounding code accordingly — so the failure is not a bad value, it is that the program which runs need not correspond to the program you wrote.
The measurement is the best possible support for that, and it is short enough to quote: x + 1 < x with x == INT_MAX returns true at -O0, false at -O2, and true again with -fwrapv, and the -O2 body of that function contains no arithmetic at all — it is mov w0, #0; ret.
The follow-up worth preparing is the distinction from unspecified and implementation-defined behaviour, and the observation that a reproducible result from undefined behaviour is still just one build's choice — which is why -fsanitize=undefined is evidence and testing is not.
12. What You Should Be Able to Say Now
- The four categories, and that undefined behaviour means no requirements on the program, not unpredictable output.
- Why that licences a rewrite: the compiler may treat "signed overflow does not happen" as a theorem, because a program where it does has no defined meaning.
- The measurement:
x + 1 < xatINT_MAXgives 1 / 0 / 1 at-O0/-O2/-O2 -fwrapv, each stable 5 runs out of 5. - That the
-O2body ismov w0, #0; ret— no arithmetic — and the-fwrapvbody is a comparison againstINT_MAX, which is the correct answer under wrapping. - That the same test in
longis defined and returns 0 at every level, which is the control proving the divergence was about undefinedness. - That UBSan names the file, line, column, operands and type, and exits 0 unless you add
-fno-sanitize-recover=all. - That two standard demonstrations did not reproduce here — a null check after a dereference was kept at all three levels, and the
INT_MAXloop fails to terminate even with-fwrapv, so its non-termination is not a UB artefact. - The method: define the operation and re-measure; if nothing changes, the undefinedness was not the cause.
13. Exercises
Exercise 1 — the centrepiece. Build §3's function at -O0, -O2 and -O2 -fwrapv and reproduce all three answers. Then dump the assembly for each and find the three different bodies.
Exercise 2 — the control. Add the long version and confirm it is identical at every level. Then make the overflowing version unsigned and explain why that is also stable.
Exercise 3 — the failed demonstration. Write §5's INT_MAX loop and run it at -O2 and with -fwrapv. Explain why the second result means the demonstration does not show what it claims.
Exercise 4 — the sanitiser's exit code. Run the overflow under -fsanitize=undefined and check $?. Add -fno-sanitize-recover=all and check again. Write a shell gate that passes the first and fails the second.
Exercise 5 — the null check. Reproduce §5's readId and inspect the assembly at -O0, -O1 and -O2. Report whether your compiler keeps the branch. If it removes it, you have the demonstration this chapter could not produce.
14. Summary
- Undefined behaviour means the standard imposes no requirements on the program — not that one expression has an arbitrary value. That is what licences the compiler to assume the undefined thing never happens and to rewrite what follows.
- Measured, one function and one input:
x + 1 < xwithx == INT_MAXreturns 1 at-O0, 0 at-O2, and 1 again with-fwrapv— each stable across 5 runs. - The generated code shows why.
-O0computes it (add,subs,cset);-O2ismov w0, #0; retwith no arithmetic at all;-fwrapviscmp w0, #2147483647; cset w0, eq— the correct answer once wrapping is defined. - The control settles it: the same comparison in
long, where no overflow occurs, returns 0 at-O0,-O1,-O2and-O3. The divergence was about undefinedness, not optimisation. - Reproducibility is the trap. Every one of those builds was perfectly repeatable, which makes a one-build observation look like a fact about the program when it is a fact about that build.
- So testing does not generalise over undefined behaviour — only tools that look for the cause do. UBSan named it as
signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'atprobe.cpp:5:64, and exits 0 without-fno-sanitize-recover=all. - Two textbook demonstrations did not reproduce here, and are recorded as negatives: a null check after a dereference was kept at
-O0,-O1and-O2, and theINT_MAXloop fails to terminate even with-fwrapv, so its non-termination is a property of the condition rather than of overflow. - The method that caught both: define the operation and re-measure. If the behaviour does not change, the undefinedness was never the cause.
Chapter 25.2 takes the first entry in §6's catalogue and finds a bug that looks correct in the debug build and wrong under optimisation — and a second one that does the opposite.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 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.
