06 — Undefined Behavior

Every senior C++ study partner will, at some point, ask you a question whose real answer is “that is undefined behavior.” If you say “it segfaults” you sound junior. If you say “it might do anything the compiler wants, including deleting the check that would have caught it, because the standard says the compiler may assume this cannot happen” — you sound like someone who has read the spec. This file gets you there.

UB is not a bug. It is a contract violation. The C++ standard is a treaty between you and the optimizer: you promise never to do certain things; in exchange, the optimizer gets to assume you kept the promise and generate faster code. Break the treaty and the compiler owes you nothing — not a crash, not a wrong value, not even consistent behavior between runs.


1. The UB catalog you must know cold

The list of UB in C++ is over 200 items long (see cppreference’s dedicated UB page). You do not need all of them. You need these — every one of them shows up in studies or production incidents.

#

Kind

Example

What the compiler is allowed to do

1

Signed integer overflow

int x = INT_MAX; x + 1;

Assume overflow never happens; delete range checks that depend on it.

2

Null pointer dereference

int* p = nullptr; *p;

Assume every dereferenced pointer is non-null; delete null checks after first use.

3

Out-of-bounds array access

int a[10]; a[10];

Anything. Often SEGV; often silent memory corruption.

4

Use-after-free / use-after-return

Returning a pointer to a local

Reads may return old data, garbage, or a stack frame belonging to another function.

5

Double free

delete p; delete p;

Heap corruption, later crash in an unrelated allocation.

6

Data race

Two threads, one writes, no sync

Any observable value including impossible ones (torn reads, ghost writes).

7

Strict aliasing violation

float f; int i = *(int*)&f;

Optimizer assumes different types don’t alias; may reorder loads/stores.

8

Uninitialized read

int x; if (x > 0)

The value need not be consistent between reads; two if(x>0) in a row may disagree.

9

Invalid shift (>= width or negative)

int x = 1 << 40;

Any bit pattern.

10

Reaching end of a value-returning function without return

Missing return in non-void

Anything. Often returns whatever was in the return register.

11

Modifying a const object through a const_cast

const int x = 5; *const_cast<int*>(&x) = 6;

The object may still read as 5; compiler may have constant-folded.

12

Violating iterator invalidation rules

for (auto& x : v) if (x==0) v.push_back(1);

Anything. vector reallocation invalidates references.

13

Overlapping restrict pointers

memcpy(dst, src, n) with dst == src

Anything (that’s why memmove exists).

14

Infinite loop without side effects

while(true) {} with no I/O or volatile access

Compiler may delete the loop entirely (this is legal since C++11; a well-known one).

15

Signed / unsigned mixing that overflows to a large positive

for (size_t i = v.size() - 1; i >= 0; --i)

Infinite loop; size_t underflow wraps to a huge value.

Category (15) is only implementation-defined on unsigned overflow (defined as modular) but the use of it is a bug 90% of the time. Learn to spot it.

What most people get wrong: they think UB “usually just crashes.” Modern optimizers (clang 18, gcc 14) do things that look like clairvoyance. If your code has if (p != nullptr) *p = 1; and you call it with nullptr, the null check may have been deleted at -O2 because a later *p “proved” p cannot be null. The check is gone. Your assert is gone. The bug lives.

2. Signed overflow — the study classic

bool safe_add(int a, int b) {
    if (a + b < a) return false;   // check for overflow
    return true;
}

This does not work. a + b < a is UB when it would overflow, so the compiler assumes it never overflows, so the whole check folds to false ? false : truetrue. Clang / GCC both do this at -O2.

Correct form:

#include <limits>
bool safe_add(int a, int b) {
    if (b > 0 && a > std::numeric_limits<int>::max() - b) return false;
    if (b < 0 && a < std::numeric_limits<int>::min() - b) return false;
    return true;
}
// Or use the compiler builtin:
bool safe_add2(int a, int b, int* out) {
    return !__builtin_add_overflow(a, b, out);   // GCC/Clang; MSVC has SafeInt
}

__builtin_*_overflow is the pragmatic answer. In C++26 you’ll get std::add_sat, std::add_overflow, etc. Standardized in P0543. Until then, builtins.

3. Strict aliasing — the silent optimizer weapon

C++ says two pointers of different (unrelated) types cannot alias. If you punch through with a cast, the optimizer is allowed to reorder loads and stores across the punch. This kills float↔int bit-cast hacks and network-header casts.

Wrong:

uint32_t bits_of(float f) { return *reinterpret_cast<uint32_t*>(&f); } // UB

Right (C++20+):

#include <bit>
uint32_t bits_of(float f) { return std::bit_cast<uint32_t>(f); }        // well-defined

Right (pre-C++20):

uint32_t bits_of(float f) { uint32_t u; std::memcpy(&u, &f, sizeof u); return u; }

memcpy and std::bit_cast are the only correct type-punning tools. Unions work in C, are formally UB in C++ (though most compilers accept them). Do not fight it — write bit_cast or memcpy and move on.

4. Uninitialized reads

int x;
if (x > 0) do_a(); else do_b();

The value of x is indeterminate. The optimizer is allowed to assume both branches are possible and inconsistent between reads. A single if (x > 0) may take one branch, and a later if (x > 0) in the same function may take the other. This is not a theoretical concern; MSan catches it in the wild every day.

Habit: always initialize. int x = 0; or int x{};. -Wuninitialized -Wmaybe-uninitialized in your CI. Turn on MSan for a full run once a week.

5. Sequence points → sequenced-before rules (C++11 model)

Old C++ said “there are sequence points, and between them, the evaluation order is unspecified.” C++11 replaced it with a per-expression sequenced-before graph. The practical rules that catch bugs:

  • i = i++; — UB. Two writes to i in the same expression, unsequenced.

  • f(i++, i++); — UB before C++17; implementation-defined since C++17 (function arguments are indeterminately sequenced, not unsequenced, but their side effects can still race in surprising ways).

  • a[i] = i++; — UB.

  • ++i + ++i — UB.

Rule of thumb: never modify the same variable twice in an expression without a sequence point between (,, &&, ||, ?:, ;).

6. Data race — precisely defined

The C++ standard’s definition:

Two actions on the same memory location conflict if at least one is a write and the actions are not synchronized by happens-before.

If a data race exists, the entire program has UB. Not just the racy operation — the whole program. That is why -fsanitize=thread catches races and refuses to continue; the standard has already given up on the run.

The one exception: std::atomic<T> operations never race with each other. Everything you touch from multiple threads either lives inside std::atomic, behind a mutex, or has an explicit acquire/release chain (see file 01).

7. Sanitizers — your UB detectors

You cannot audit UB by reading. You must instrument. Clang and GCC ship four sanitizers you should treat as compile flags for a second, “audit” build target:

Sanitizer

Flag

What it catches

Cost

AddressSanitizer

-fsanitize=address

Heap/stack buffer overflow, use-after-free, use-after-return, double free

~2x slower, ~3x memory

UndefinedBehaviorSanitizer

-fsanitize=undefined

Signed overflow, null deref, alignment, vla-bound, shift, unreachable

~10-20% slower

ThreadSanitizer

-fsanitize=thread

Data races, deadlocks

~5-15x slower, ~8x memory

MemorySanitizer

-fsanitize=memory

Uninitialized reads. Clang only; needs instrumented libc++

~3x slower

Enable in your CMakePresets.json or Makefile as a sanitize build type:

# CMakeLists.txt sketch
option(SAN_ADDRESS "AddressSanitizer" OFF)
option(SAN_UB      "UBSan"             OFF)
option(SAN_THREAD  "ThreadSanitizer"   OFF)

if(SAN_ADDRESS)
  add_compile_options(-fsanitize=address -fno-omit-frame-pointer -g -O1)
  add_link_options(-fsanitize=address)
endif()
if(SAN_UB)
  add_compile_options(-fsanitize=undefined -fno-omit-frame-pointer -g -O1)
  add_link_options(-fsanitize=undefined)
endif()
if(SAN_THREAD)
  add_compile_options(-fsanitize=thread -fno-omit-frame-pointer -g -O1)
  add_link_options(-fsanitize=thread)
endif()

Rules for using them:

  1. ASan + UBSan are compatible. Combine them: -fsanitize=address,undefined.

  2. TSan is not compatible with ASan. Use a separate build.

  3. MSan requires an MSan-built libc++, which is not shipped by Apple. On macOS use ASan/UBSan/TSan; run MSan in a Linux VM if you need it.

  4. Add sanitizer runs to CI (P3.1 and P3.2 both require this). A weekly make sanitize on your main branch catches drift.

Runtime flags worth knowing:

ASAN_OPTIONS=halt_on_error=1:detect_leaks=1:abort_on_error=1
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1

8. Compiler warnings that catch UB before sanitizers do

-Wall -Wextra -Wpedantic is table stakes. Add these:

-Wuninitialized -Wmaybe-uninitialized
-Wnull-dereference
-Warray-bounds
-Wcast-align
-Wshadow
-Wconversion            # noisy but catches signed↔unsigned bugs
-Wold-style-cast
-Wnon-virtual-dtor
-Wdouble-promotion
-Wformat=2

Static analysis on top: clang-tidy with the bugprone-*, cert-*, cppcoreguidelines-* check groups. Run it on every push in CI.

9. Reading list — the ones that actually change how you think

  • John Regehr’s blog — blog.regehr.org. The single best UB writer alive. Start with “A Guide to Undefined Behavior in C and C++” parts 1-3. Then “Finding Undefined Behavior Bugs by Finding Dead Code”. Then his integer overflow paper.

  • LLVM blog — Chris Lattner’s “What Every C Programmer Should Know About Undefined Behavior” parts 1-3 (blog.llvm.org, 2011). Still the clearest explanation of why the optimizer treats UB the way it does. Age has not diminished it.

  • cppreference — the UB page (en.cppreference.com/w/cpp/language/ub). The canonical enumeration.

  • Jonathan Müller — “Undefined behavior can result in time travel” (his blog and CppCon talk). The “the check gets deleted before the check that would catch the UB” phenomenon, in detail.

  • Herb Sutter — GotW #100+ on undefined behavior. Short, sharp, opinionated.

10. study-ready UB questions to be able to answer

You will be asked these. Practice out loud until each answer is 60 seconds tight:

  1. “What is undefined behavior?” — contract violation; the standard permits any outcome; the optimizer exploits it.

  2. “What happens if you signed-integer-overflow?” — UB. Compiler may assume it doesn’t happen; that deletes overflow checks. Show __builtin_add_overflow as the fix.

  3. “Why is strncpy still around?” — because strcpy on unbounded input is a buffer overflow (UB), and everyone learned the wrong fix. Real fix: std::string or std::string_view.

  4. “What is a data race, precisely?” — two conflicting accesses to the same memory, at least one write, not synchronized by happens-before. Program-wide UB.

  5. “Why does -O2 make my bug disappear / appear?” — UB. Different optimizer decisions expose or hide the contract violation.

  6. “How would you detect a use-after-free in a large codebase?” — ASan in CI plus a targeted AddressSanitizerLite run on release-shaped binaries; static analysis via clang-tidy bugprone-use-after-move; code review guardrails around unique_ptr/shared_ptr.

  7. “What is strict aliasing and why is reinterpret_cast<int*>(&f) dangerous?” — same-type-family rule; UB across unrelated types; use std::bit_cast (C++20) or memcpy.

If any of the seven above take you longer than a minute to answer cleanly, that’s your next study block.


Exercises

  1. Write a 20-line program with signed overflow, compile at -O0 and -O2, and confirm the behavior changes. Add UBSan; watch it print the exact source line.

  2. Write a program that reads an uninitialized local and use MSan (in a Linux VM if needed) to prove the read is caught.

  3. Take the SPSC queue from file 04 and deliberately break the memory ordering on the head store to relaxed. Run under TSan on Linux/ARM (or qemu-aarch64). Confirm the race is caught.

  4. Turn on -Wshadow -Wconversion on your NeetCode-150 repo (P2.1). Count how many new warnings appear. Fix or annotate all of them.


Nav: ← 05 Linux Syscalls for C++ · Phase 3 README · → Projects