This page is the hands-on lab for the UB topic note . The parent listed the categories ; here we run the actual experiments. We enumerate every kind of case UB can throw at you — each sign, each zero, each degenerate input, each limiting value — then work examples that hit every cell of that map. You guess first ("Forecast"), then we reason step by step, then we verify with real arithmetic.
Before line one: a quick vocabulary anchor so no symbol is unearned.
Definition Three words we will lean on
==bit width n == :: how many binary digits a number type has. An int is usually n = 32 bits. Picture 32 little on/off switches in a row.
two's complement :: the standard way hardware stores signed integers. The top switch means "negative". With n = 32 the values run from − 2 31 to 2 31 − 1 .
==INT_MAX / INT_MIN== :: the biggest and smallest values a signed int can hold: INT_MAX = 2 31 − 1 = 2147483647 and INT_MIN = − 2 31 = − 2147483648 .
Notice the range is lopsided : one more negative number than positive. That single asymmetry is the seed of several UB bugs below — keep it in mind.
Every UB bug you meet in practice falls into one of the cells below. The job of the worked examples is to land on each cell at least once .
Cell
Case class
Degenerate / limiting input
Example that hits it
A
Signed overflow — positive side
x near INT_MAX
Ex 1
B
Signed overflow — the INT_MIN trap
INT_MIN / -1
Ex 2
C
Divide / modulo by zero
denominator = 0
Ex 3
D
Shift out of range
shift ≥ n or shift < 0
Ex 4
E
Out-of-bounds — read past the end
index = length
Ex 5
F
Sequencing — modify twice, no order
a[i] = i++
Ex 6
G
Lifetime — dangling pointer
pointer after scope ends
Ex 7
H
Varargs type mismatch (real-world)
%d fed a double
Ex 8
I
Unsigned wrap is DEFINED (the control case)
UINT_MAX + 1
Ex 9
J
Exam twist — overflow check that vanishes
x + 1 < x
Ex 10
Prerequisite links live here so you can climb down whenever a cell confuses you:
Integer Types and Overflow , Sequence Points and Operator Precedence , Pointers and Memory in C , malloc and free — Dynamic Memory , Compiler Optimizations and -O flags , Sanitizers — ASan and UBSan , Strict Aliasing Rule .
The figure below is the map — the number line of a signed int, with the danger points marked. Every arithmetic example refers back to it.
Look at the two red cliffs at each end: stepping off either edge is UB. The mint region in the middle is the safe zone.
Worked example Ex 1 — Adding near the ceiling
int x = 2000000000 ; // 2e9, below INT_MAX = 2147483647
int y = x + 200000000 ; // 2.2e9 — over the top
Forecast: what is y? Is it 2.2 × 1 0 9 ? Is it a big negative number? Or is it "no defined answer at all"?
Steps:
Compute the mathematical sum: 2000000000 + 200000000 = 2200000000 .
Why this step? We need the true integer value before asking whether the type can hold it.
Compare with the ceiling: INT_MAX = 2147483647 . Since 2200000000 > 2147483647 , the result does not fit . Look at the s01 figure — we stepped off the right red cliff .
Why this step? Overflow is defined by escaping the range , not by any wrap formula.
Because the type is signed , escaping the range is undefined behavior . There is no guaranteed value for y — not the wrapped number, not anything.
Why this step? The parent note's rule: signed overflow imposes no requirements. A debug build might show a wrapped value, but the standard promises nothing.
Fix — check before you compute:
if (x > INT_MAX - 200000000 ) { /* would overflow */ }
else y = x + 200000000 ;
Why this step? Rearranging to x > INT_MAX − 200000000 keeps every quantity inside the safe mint zone — the subtraction 2147483647 − 200000000 = 1947483647 never overflows.
Verify: the true sum 2200000000 exceeds INT_MAX = 2147483647 by 52516353 → confirms overflow. The guard threshold INT_MAX − 200000000 = 1947483647 , and x = 2000000000 > 1947483647 → guard correctly fires.
Worked example Ex 2 — Dividing the smallest number by minus one
int a = INT_MIN; // -2147483648
int b = a / - 1 ; // "should be +2147483648"
Forecast: division looks harmless — no zero anywhere. Safe or UB?
Steps:
The mathematical answer is − ( − 2147483648 ) = 2147483648 .
Why this step? Negating a value flips its sign; we must know the true result to test the range.
Check the ceiling: INT_MAX = 2147483647 . But 2147483648 > 2147483647 by exactly one .
Why this step? Here is the lopsided range biting us — there is no positive twin for INT_MIN.
So the result of a division overflows the type → UB , even though we never divided by zero.
Why this step? Overflow is about the result not fitting, wherever it comes from.
Fix: guard the single poisonous pair.
if (a == INT_MIN && divisor == - 1 ) { /* special-case it */ }
Verify: − INT_MIN = 2147483648 = INT_MAX + 1 → exactly one past the ceiling, confirming overflow.
Worked example Ex 3 — The empty divisor
int n = 10 , d = 0 ;
int q = n / d; // and int r = n % d;
Forecast: on paper "divide by zero" is infinity. What does C do?
Steps:
Mathematically 10/0 has no finite value — there is no integer q with q × 0 = 10 .
Why this step? Division is "how many times does d fit"; if d = 0 the question is meaningless.
The standard therefore declares both / 0 and % 0 undefined behavior . On many CPUs it raises a hardware trap (crash), but that's not guaranteed — a compiler may optimize the whole path away.
Why this step? "It crashes reliably" is a myth; UB means even the crash isn't promised.
Fix — validate the denominator:
if (d == 0 ) return ERR;
int q = n / d;
Verify (defined companion case): when d = 2 , 10/2 = 5 and 10%2 = 0 — the operation is only well-defined once d = 0 .
Worked example Ex 4 — Shifting by too much
unsigned int u = 1 u ;
unsigned int big = u << 40 ; // int is 32 bits
int neg = - 1 ;
int oops = neg << 3 ; // left-shifting a negative value
Forecast: 1u << 40 — does it just become zero? And can you left-shift a negative number?
Steps:
For a 32-bit type the valid shift counts are 0 ≤ count ≤ 31 . We asked for 40 .
Why this step? The rule: shifting by ≥ n (here n = 32 ) is UB. 40 ≥ 32 → off the map.
So u << 40 is UB , not a well-defined zero. Look at s01: think of it as shifting bits so far they fall off and the standard stops guaranteeing anything .
Why this step? Beginners assume overshifting yields 0 — false; it's undefined.
neg << 3 left-shifts − 1 . Left-shifting a negative signed value is also UB .
Why this step? Sign bit behaviour under shift is unspecified/undefined per the standard.
Fix: keep count in range and shift only non-negative or unsigned values.
if (count < 32 ) big = u << count;
unsigned int safe = ( unsigned )neg << 3 ; // define the wrap intent
Verify (defined case): a legal shift 1 u ≪ 4 = 16 and 1 u ≪ 31 = 2147483648 ; count 31 is the last legal one because 31 < 32 .
Worked example Ex 5 — Reading index = length
int a [ 3 ] = { 10 , 20 , 30 }; // valid indices: 0, 1, 2
int v = a [ 3 ]; // one past the end
Forecast: the array "seems" to continue — will a[3] give a random number, or is it UB?
Steps:
An array of length 3 has indices 0 , 1 , 2 . The last valid index is length − 1 = 2 .
Why this step? Indices are zero-based ; the count and the top index differ by one — another off-by-one trap.
a[3] reads the slot at the length, which is outside the object → UB . (You may form a one-past pointer a+3, but you may not dereference it.)
Why this step? The parent note stressed: one-past pointers are legal, one-past reads are not.
Symptom: it may print adjacent stack garbage, corrupt a neighbour, or crash — silently.
Why this step? "Printed fine" is the most dangerous UB symptom.
Fix / detect: validate 0 <= i && i < 3, and run ASan which flags this instantly.
Verify: valid indices are { 0 , 1 , 2 } ; the count is 3 ; the max legal index = 3 − 1 = 2 , so index 3 is out of bounds by exactly 1 .
a[i] = i++
int i = 0 ;
int a [ 3 ] = { 0 };
a [i] = i ++ ; // UB
Forecast: does this set a[0] = 0 and then make i = 1? Guess the intended-looking answer.
Steps:
Two things happen to i in one statement: it is read (to pick the index a[i]) and it is modified (by i++).
Why this step? We must locate every place the same object changes or is used.
There is no sequence point ordering "use i for the index" against "increment i". Modifying i and using it for an unrelated purpose without ordering = UB .
Why this step? The parent's Category 3: this is undefined , not merely "5 or 6".
So it is not "obviously a[0]=0" — the compiler could write to a[0] or a[1], and i could be anything.
Why this step? Never assume a "natural" outcome for a sequencing UB.
Fix — split it:
a [i] = i;
i ++ ;
Verify: after the fixed code with i = 0 : a[0] = 0, then i becomes 1 — a well-defined single modification per statement.
Worked example Ex 7 — Returning a local's address
int * make ( void ) {
int local = 42 ;
return & local; // local dies at return
}
int * p = make ();
int val = * p; // UB
Forecast: the value 42 was just there — surely *p reads 42?
Steps:
local is an automatic variable — its storage is a stack slot that exists only while make() runs.
Why this step? Lifetime, not the raw byte value, decides legality.
When make() returns, that slot is free for reuse. The pointer p now points to expired storage — a dangling pointer . Reading *p is UB .
Why this step? Accessing an object outside its lifetime is undefined regardless of what bytes linger.
It may print 42 once , then break after the next function call overwrites the slot — the classic "works once" trap.
Fix — return by value or use heap allocation :
int make ( void ) { return 42 ; } // by value
// or: int *p = malloc(sizeof *p); *p = 42; // caller frees
Verify: the intended value is 42 ; the fixed by-value version returns exactly 42 with no lifetime issue.
Worked example Ex 8 — A temperature logger that lies
A weather program stores a temperature as a double and logs it:
double temp = 3.5 ;
printf ( "temp = %d\n " , temp); // %d expects int, got double — UB
Forecast: does it print 3? 3.5? A junk number?
Steps:
%d is the format for an int; temp is a double. printf reads its extra arguments blindly using the format string.
Why this step? Varargs functions have no type checking — the format string is the only contract.
The bytes of a double are laid out differently from an int, so reading them as an int gives UB — the printed number is not 3 , not 3.5 , but undefined garbage.
Why this step? Type/format mismatch in varargs is explicit UB (parent Category 5).
Fix: match the specifier to the type.
printf ( "temp = %f\n " , temp); // %f for double -> prints 3.500000
Verify: %f on 3.5 yields the correct 3.5 ; the intended integer part, if you truly wanted an int, would be (int)3.5 == 3.
UINT_MAX + 1 (not a bug!)
unsigned int u = 4294967295 u ; // UINT_MAX for 32-bit
unsigned int w = u + 1 ;
Forecast: if signed overflow was UB, is unsigned overflow UB too?
Steps:
UINT_MAX = 2 32 − 1 = 4294967295 . Adding 1 gives 4294967296 = 2 32 .
Why this step? Establish the true sum before applying the wrap rule.
Unsigned arithmetic is defined to wrap modulo 2 n . So w = 4294967296 mod 2 32 = 0 .
Why this step? This is the parent's key contrast: unsigned wrap is guaranteed, signed overflow is UB.
This is not a bug — it is the tool to use when you want wraparound.
Why this step? Reach for unsigned/uint32_t deliberately for ring-counter behaviour.
Verify: ( UINT_MAX + 1 ) mod 2 32 = 4294967296 mod 4294967296 = 0 . Defined and exact.
Worked example Ex 10 — Why the "safety check" disappears at
-O2
int over ( int x ) {
if (x + 1 < x) return - 1 ; // "detect overflow"
return x + 1 ;
}
Forecast: at INT_MAX, does the return -1 line ever run?
Steps:
The programmer hopes: when x = INT_MAX , x + 1 overflows to a small number, so x + 1 < x is true → return − 1 .
Why this step? Surface the intended (wrong) mental model first.
But signed overflow is UB , and the optimizer is allowed to assume UB never happens . Under that assumption, mathematically x + 1 < x is always false .
Why this step? The compiler reasons in the defined world where overflow is impossible.
Being always false, the compiler deletes the if and its return -1. The safety net is gone — at -O2 the function just does return x + 1, undefined at the top.
Why this step? This is the "UB propagates and removes code" behaviour from the parent's optimizer model.
Fix — reason in the defined domain (compare before adding):
if (x == INT_MAX) return - 1 ;
return x + 1 ;
Why this step? The comparison x == INT_MAX never overflows, so the compiler can't delete it.
Verify: the only x for which x + 1 overflows is x = INT_MAX = 2147483647 ; the fixed guard fires exactly there and nowhere else. For x = 5 , the fixed code returns 6 .
Recall Which cell was each?
Ex 1 :: Cell A — positive signed overflow
Ex 2 :: Cell B — INT_MIN / -1
Ex 3 :: Cell C — divide/modulo by zero
Ex 4 :: Cell D — shift out of range
Ex 5 :: Cell E — out-of-bounds read
Ex 6 :: Cell F — sequencing double-modify
Ex 7 :: Cell G — dangling pointer lifetime
Ex 8 :: Cell H — varargs %d vs double
Ex 9 :: Cell I — unsigned wrap (defined control case)
Ex 10 :: Cell J — the vanishing overflow check
Mnemonic One line to carry away
Before the edge, not after it. Check the range before you add, the divisor before you divide, the index before you read, and the lifetime before you dereference — because after the edge, the standard owes you nothing.