Undefined Behaviour

Some C code has no defined meaning at all: the standard says the program may do anything. This undefined behaviour (UB) isn't just "a crash" — optimising compilers assume it never happens and can transform your program in surprising ways. Knowing the common cases is essential for writing correct C.

Three kinds of "not fully defined"

CategoryMeaningExample
Implementation-definedEach compiler chooses, and documents, the behaviourThe size of int; whether char is signed
UnspecifiedOne of several outcomes; may differ each timeThe order function arguments are evaluated
UndefinedNo requirements at allSigned overflow, out-of-bounds access, null dereference

Implementation-defined and unspecified behaviour are fine as long as your program doesn't depend on a particular outcome. Undefined behaviour must be avoided completely.

Why UB is so dangerous

Because the compiler may assume UB never happens, it can remove checks that only make sense if it did. A classic example is an overflow check written after the overflow:

C
int will_overflow(int x) {
    return x + 1 < x;     // signed overflow is UB, so the compiler may
}                         // assume x + 1 < x is never true and return 0

With optimisation on, GCC and Clang typically compile this to "return 0". The program then behaves differently with -O0 and -O2, or after a compiler upgrade — the hallmark of UB bugs. The correct check compares before the operation: x == INT_MAX (lesson 8).

The most common cases

Undefined behaviourLesson
Signed integer overflow8
Division or remainder by zero13
Shifting by a negative amount or by the type's width or more14
Reading an uninitialised variable6
Accessing an array or buffer out of bounds26, 38
Dereferencing a null, dangling or freed pointer31, 38
Modifying a string literal28
Modifying a variable twice without sequencing: i = i++13
Mismatched printf/scanf format and argument types11
A non-void function falling off the end when its value is used21
Converting an out-of-range float to an integer12
Accessing an object through a pointer of an incompatible type31

Catching UB with the sanitizer

-fsanitize=undefined (UBSan) adds run-time checks for many of these and reports each one with its location. The program keeps running afterwards, so you see every problem:

C
#include <stdio.h>
#include <limits.h>

volatile int sink;     // results are stored here, never printed: their values are meaningless

int main(void) {
    int big = INT_MAX;
    sink = big + 1;                 // signed overflow

    int shift = 40;
    sink = (int) (1u << shift);     // shift wider than the type

    int numbers[4] = { 1, 2, 3, 4 };
    int index = 4;
    sink = numbers[index];          // out of bounds

    printf("the program kept running\n");
    return 0;
}
With UndefinedBehaviorSanitizer
the program kept running
main.c:8:16: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
main.c:11:22: runtime error: shift exponent 40 is too large for 32-bit type 'unsigned int'
main.c:15:12: runtime error: index 4 out of bounds for type 'int[4]'
$
gcc -g -fsanitize=undefined main.c -o main && ./main

Each message names the exact line and column and what went wrong. (The program stores its results in a volatile variable instead of printing them, because undefined results can be anything — even different on every run.) Combine it with AddressSanitizer while testing: -fsanitize=address,undefined.

Unsequenced modifications

Changing a variable twice in one expression, or changing and reading it, without a sequence point between, is undefined. Compilers warn about the obvious cases:

C
#include <stdio.h>

int main(void) {
    int i = 1;
    i = i++ + 1;
    int a[3] = { 0 };
    int j = 0;
    a[j] = j++;
    printf("%d %d\n", i, a[0]);
    return 0;
}
Compiler warnings
main.c:5:10: warning: multiple unsequenced modifications to 'i' [-Wunsequenced]
main.c:8:13: warning: unsequenced modification and access to 'j' [-Wunsequenced]

The fix is always the same: split it into separate statements so the order is obvious.

Strict aliasing

Reading an object through a pointer to a different type (other than char/unsigned char) breaks the strict aliasing rule, and optimisers rely on it. To reinterpret bytes — say, the bits of a float — copy them with memcpy, which compilers optimise into a single instruction:

C
#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(void) {
    float f = 1.0f;
    uint32_t bits;
    memcpy(&bits, &f, sizeof bits);      // well-defined, unlike *(uint32_t *) &f
    printf("1.0f is stored as 0x%08X\n", (unsigned) bits);
    return 0;
}
Output
1.0f is stored as 0x3F800000

Defences

  • Turn on warnings (-Wall -Wextra, and consider -Wconversion) and treat them as errors.
  • Test with sanitizers — -fsanitize=address,undefined — on every build of your test suite.
  • Check before you compute: validate divisors, indexes, shift amounts and ranges first.
  • Prefer simple code: one side effect per statement, explicit casts, unsigned types for bit manipulation.
  • Use static analysers such as clang --analyze, cppcheck or GCC's -fanalyzer, which find bugs without running the code.

Try it yourself

Write a safe average function that can't overflow, even for very large int values, by doing the arithmetic in a wider type.

Try it yourself · main.c
Edit the code, copy it into main.c, then compile and run it with cc -std=c17 -Wall main.c -o main && ./main. How to set up a C compiler
Output
2147483646
-2