Recommended resources
The freely available resources this lesson will direct you to.
- Undefined Behavior in C++: What Every Programmer Should Know and Fear - Fedor Pikus - CppCon 2023www.youtube.com
- Advanced C: The UB and optimizations that trick good programmers.www.youtube.com
- [PDF] Programming in C and C++ - Lecture 10: Undefined Behaviourwww.cl.cam.ac.uk
- Arithmetic operators - cppreference.comen.cppreference.com
Welcome back. Last lesson, you separated an expression’s type from an object’s lifetime and learned why structure layout cannot be guessed from source alone. Today, you’ll make a sharper distinction: does C define this operation, does the implementation choose and document its behavior, or does the program have no language-level guarantee at all?
This distinction matters in a code review or interview because “it worked on my machine” answers none of those questions. Plan on about 40 minutes, including a short compiler experiment on your Linux VM.
Four labels for reading a C program
The C standard describes an abstract machine: a model of what a conforming program means, independent of the instructions a compiler eventually emits. When you inspect a line of C, classify its behavior before predicting a value.
| Classification | What you can conclude |
|---|---|
| Defined | C specifies the relevant behavior. |
| Implementation-defined | The implementation chooses a behavior and must document that choice. The code may need adapting for another target. |
| Unspecified | C permits a choice among defined possibilities, without requiring the implementation to document or consistently repeat the choice. |
| Undefined behavior (UB) | C imposes no requirements on an execution that reaches the offending operation. A particular output or crash is not guaranteed. |
“Implementation-dependent” is a useful informal warning, but implementation-defined and unspecified are different technical labels. For example, the size of int in bytes depends on the implementation. The order of evaluation of function arguments is generally unspecified. Neither fact, by itself, makes a program undefined.
[PDF] Programming in C and C++ - Lecture 10: Undefined Behaviour
Read the Cambridge lecture’s classification of compiler freedom and its compact catalogue of common C errors. Its examples also show why a null check placed too late may not protect a program.
Read slides 7–14. Begin at slide 7, “Freedom to Optimize”; continue through “Implementation-defined Behaviour” (slide 8), “Unspecified Behaviour” (slide 9), and “Undefined Behaviour” and its continuations (slides 10–14). Focus on the difference between an unspecified order and unsequenced modifications of the same object. Then skip to “Case Analysis in Linux kernel,” slide 26, and read the null-check analysis. Note that sizeof(int) reports a number of bytes, even though the size is target-dependent.
A reliable interview response gives the condition as well as the label. “This is UB” is less informative than “If p is null when *p is evaluated, that execution has UB; otherwise this particular access may be valid.”
Find the operation that breaks the guarantee
Last lesson’s lifetime example illustrates one category: accessing an automatic object after its lifetime ends is not a way to retrieve an old value. Similar reasoning applies to bounds. Given int data[3];, data + 3 is a valid one-past-the-end pointer. Forming data + 4 is outside the permitted pointer-arithmetic range, and dereferencing data + 3 is invalid. A plausible numerical address does not change either rule.
Null checks must likewise guard the first access, not merely a later one:

The condition on customAssert() matters. The declaration shown does not tell us whether it returns. We can establish that the function has an undefined path, not that every call is undefined. A safe implementation would either return or report an error on the null path, or use an assertion function whose non-returning behavior is genuinely guaranteed by its interface and implementation.
Other frequent triggers are reading an uninitialized ordinary local value, accessing freed storage, dividing an integer by zero, and overflowing a signed integer. For these, do not supply a “likely output” as though it were C’s answer.
There is a particularly important boundary between unspecified order and UB:
combine(mark(1), mark(2)); /* argument evaluation order unspecified */
int i = 0;
combine(i++, i++); /* UB: unsequenced modifications of i */
In the first call, mark(1) and mark(2) can execute in either order; if they print, the order of those messages can vary. In the second, choosing an argument order does not rescue the expression: the two modifications of i are unsequenced relative to each other. Split them into separate full expressions if their order matters.
Arithmetic: portable result, documented choice, or UB?
Unsigned arithmetic has defined wraparound in its unsigned type; signed arithmetic does not. If x is an int holding INT_MAX, evaluating x + 1 has UB. On a machine whose addition instruction wraps, you might observe a negative result in one build, but that observation is not a C guarantee.
This is more than a theoretical objection. An optimizer can reason that, on executions with defined behavior, x + 1 is greater than x. A check written after a potentially overflowing addition may therefore fail to serve as the protection its author intended.
Undefined Behavior in C++: What Every Programmer Should Know and Fear - Fedor Pikus - CppCon 2023
Watch “Undefined Behavior in C++: What Every Programmer Should Know and Fear” from CppCon for a concrete optimizer demonstration. Although the talk uses C++, its signed-overflow versus unsigned-wraparound example applies to the C expressions discussed here.
Watch the overflow comparison. Follow why the compiler can simplify a signed comparison but must account for wraparound in the unsigned version. The point is the language guarantee, not a prediction that every compiler will emit identical machine code.
Pointer arithmetic and shifts have equally sharp boundaries. The following reference is useful when a short interview snippet combines them.
Arithmetic operators - cppreference.com
Use this C reference to check the exact limits behind three common claims: “unsigned wraps,” “one past is allowed,” and “a shift just does what the CPU does.”
In “Overflows,” read the overflow rules. In “Additive operators” → “Pointer arithmetic,” read the array-bound rule. In “Shift operators,” start at the shift limits: distinguish an invalid shift count from right-shifting a negative signed value. For this lesson’s C11 examples, the latter result is implementation-defined, not automatically UB.
For shifts, inspect the promoted left operand’s width. A negative count or a count at least that width is UB. In contrast, with a valid count, right-shifting a negative signed int is an implementation-defined case in C11. When working with bit masks or storage-protocol fields, unsigned types make the intended bit operations easier to state precisely—but unsigned wraparound still needs an explicit check when wraparound would corrupt a length or offset.
Verify classifications on your Linux VM
Save this as c_rules.c. Its ordinary path contains a defined one-past-pointer use, implementation-dependent observations, and an unspecified argument order. A separate command-line path deliberately triggers signed overflow so a sanitizer can report it.
#include <limits.h>
#include <stdio.h>
static int mark(int value)
{
printf("mark %d\n", value);
return value;
}
static int combine(int a, int b)
{
return 10 * a + b;
}
int main(int argc, char **argv)
{
int data[3] = {7, 8, 9};
int *past = data + 3;
printf("last element: %d\n", past[-1]);
printf("sizeof(int): %zu\n", sizeof(int));
printf("negative right shift: %d\n", -3 >> 1);
printf("combined: %d\n", combine(mark(1), mark(2)));
if (argc > 1 && argv[1][0] == 'o') {
volatile int max = INT_MAX;
printf("overflow: %d\n", max + 1);
}
return 0;
}
Compile and run it in your VM:
cc -std=c11 -O1 -g -Wall -Wextra -Wpedantic \
-fsanitize=undefined -fno-sanitize-recover=undefined \
c_rules.c -o c_rules
./c_rules
./c_rules overflow
On the ordinary run, past[-1] accesses data[2], so it prints 9; *past would instead dereference the one-past pointer. Your implementation determines the reported sizeof(int) and documents its choice for the negative signed right shift. The two mark messages may appear in either order, while combine still receives the values 1 and 2.
The second run should report signed integer overflow and stop under these sanitizer options. The volatile local helps keep the addition as a runtime operation for this demonstration; volatile does not make overflow safe. UBSan reports faults on paths you execute and that it instruments. A clean run is not proof that every possible path is valid.
The repair is to check before adding:
if (max == INT_MAX) {
fputs("cannot increment max\n", stderr);
return 1;
}
printf("incremented: %d\n", max + 1);
That ordering is the useful production habit: establish an operation’s preconditions before performing it. It also gives you a concise way to explain a fix aloud, rather than merely naming a defect.
Key takeaways
Classify a C expression by its guarantee, then state the inputs and path on which your classification holds. Unspecified order allows a choice among valid executions; implementation-defined behavior requires a documented choice; UB provides no promised result for an execution that reaches it. Bounds, object lifetime, unsequenced modifications, and signed overflow are especially worth checking before predicting output.
Next, you’ll use this discipline to build small heap-allocated C objects with clear ownership, cleanup, and error paths—where avoiding invalid accesses starts with a sound design rather than a sanitizer report.