Debugging and undefined behavior · about 55 min

Undefined behavior

Why this matters

You have met the phrase "undefined behavior" many times in this track. It is the single most important idea separating C from safer languages, and the source of most C security holes. It does not mean "the program crashes" or "you get a random number". It means the C standard places no requirements at all on what the program does, and modern compilers take that literally: they assume undefined behavior never happens and optimize on that basis. The result can be code that works at -O0, breaks at -O2, deletes your safety checks, or loops forever. Understanding the rules is how you write C that keeps working when the compiler, the flags or the input change.

Three kinds of "not fully specified"

The C standard sorts loose ends into three categories:

The usual suspects

undefined behaviorexamplesafe alternative
signed integer overflowINT_MAX + 1check limits first; use unsigned for wrapping
array index out of boundsa[n] on an n-element arraykeep 0 <= i < n
dereferencing NULL or a dangling pointer*p after free(p)set to NULL after free, check
using an uninitialized localint x; if (x > 0)always initialize
shift by negative or >= width1 << 32 on 32-bit intkeep shift in 0..width-1, shift unsigned
shifting into or out of the sign bit1 << 31, -1 << 1shift unsigned values
modifying a string literalchar *s = "hi"; s[0] = 'H';char s[] = "hi";
modifying a variable twice without sequencingi = i++ + 1;split into statements
division by zero, INT_MIN / -1x / 0check the divisor
using a function's value when it fell off the endmissing return-Wreturn-type
accessing an object through an incompatible pointer type*(float *)&some_intmemcpy

Here are a few of them in one program, compiled with warnings:

#include <stdio.h>

int sign(int x)
{
    if (x > 0)
        return 1;
    if (x < 0)
        return -1;
}

int main(void)
{
    int i = 1;
    i = i++ + 1;
    char *s = "hello";
    printf("%d %d\n", i, sign(5));
    s[0] = 'J';
    printf("%s\n", s);
    return 0;
}
$ gcc -std=c17 -Wall -Wextra -O0 -o prog seq.c
seq.c: In function 'main':
seq.c:14:7: warning: operation on 'i' may be undefined [-Wsequence-point]
   14 |     i = i++ + 1;
      |     ~~^~~~~~~~~
seq.c: In function 'sign':
seq.c:9:1: warning: control reaches end of non-void function [-Wreturn-type]
    9 | }
      | ^
$ ./prog
Segmentation fault (core dumped)

The compiler warned about two of the three bugs. The third, writing to the literal "hello", crashed because the linker put string literals in a read-only memory page. Notice also that the first printf ran but its output never appeared: when output goes to a pipe it is buffered, and the crash discarded the buffer.

How the optimizer uses UB

Compilers are not malicious; they are logical. Their job is to produce the fastest code that behaves as the standard requires for every valid execution. Executions that contain UB are not valid, so the compiler need not care what happens in them. Two consequences you can see in the sandbox:

Overflow checks vanish.

int will_overflow(int x)
{
    return x + 1 < x;
}

gcc turns this into return 0; even at -O0: if signed overflow cannot happen, x + 1 is always greater than x. The check is gone. Compiled with -fsanitize=undefined, the same call reports runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'.

Loops stop terminating.

int count = 0;
for (int i = 1; i > 0; i *= 2)
    count++;
printf("%d\n", count);
$ gcc -std=c17 -O0 -o prog loop.c && ./prog
31
$ gcc -std=c17 -O2 -o prog loop.c && ./prog
^C          (runs forever)

At -O0 the doubling happens to wrap to a negative number and the loop ends after 31 steps. At -O2 gcc reasons that i starts positive and is only ever doubled, and since overflow "cannot happen" it stays positive, so i > 0 is always true. The loop becomes infinite. Both behaviors are permitted; the program was never meaningful.

Warning: "it works on my machine" means nothing for code with UB. A new compiler version, a different flag, or an unrelated change elsewhere in the function can change the outcome.

Writing UB-free code

You cannot test UB away; you avoid it by construction and catch slips with tools:

  1. Check before you operate. Before a + b, test a > INT_MAX - b (for positive b). Before a[i], test i < n. Before x / y, test y != 0.
  2. Use unsigned types for bit manipulation and wrapping counters. Unsigned overflow is defined (it wraps modulo 2^N), and shifts on unsigned values have no sign-bit trap. For shifts, write 1u << n, not 1 << n.
  3. Initialize every variable at its declaration.
  4. One side effect per variable per expression. i++ on its own line is always clear.
  5. Turn on warnings (-Wall -Wextra) and treat them as errors.
  6. Run tests under -fsanitize=address,undefined, which turns silent UB into loud reports.
  7. Copy bytes with memcpy when you need to reinterpret an object as another type, instead of casting pointers (the "strict aliasing" rule lets the compiler assume an int * and a float * never point at the same memory).

Common mistakes

Recap

Going further: read John Regehr's "A Guide to Undefined Behavior in C and C++" (three blog posts) and the GCC manual's description of -fwrapv, a flag that makes signed overflow wrap, used by the Linux kernel. Try gcc -O2 -S on will_overflow and find the xorl %eax, %eax that returns 0.

Exercises

Exercise 1 · quiz

Spot the undefined behavior

Assume int is 32 bits and int a[4]; is in scope. Which lines have undefined behavior? Select all that apply.

Answer
  • int x = INT_MAX; x = x + 1;

  • int y = a[4];

  • int s = 1 << 31;

  • char *p = "text"; p[0] = 'T';

Signed overflow is undefined. Unsigned arithmetic is defined to wrap modulo 2^N, so UINT_MAX + 1 is exactly 0. a[4] reads past the end of a 4-element array. 7 / 2 is simply 3. 1 << 31 shifts a 1 into the sign bit of a 32-bit int: the result is not representable, which is undefined in C17 (use 1u << 31). String literals may live in read-only memory, and modifying one is undefined; on Linux it usually crashes with a segmentation fault.

Exercise 2 · quiz

Why did the check disappear?

int will_overflow(int x)
{
    return x + 1 < x;
}

will_overflow(INT_MAX) returns 0, even at -O0. Why?

Answer
  • Signed overflow cannot happen in a valid program, so the compiler may assume x + 1 is always greater than x and replace the whole expression with 0

If x + 1 wrapped to INT_MIN, the comparison would be true and return 1. But the compiler reasons from the rules: signed overflow is undefined, so a correct program never does it, so for every x where the expression means anything, x + 1 > x. It folds the expression to 0. The overflow check is deleted because it relies on the very thing it is trying to detect. Check before the operation instead: x == INT_MAX, or x > INT_MAX - 1.

Exercise 3 · write code

A midpoint that cannot overflow

Binary search computes the middle of a range with (lo + hi) / 2. When lo and hi are both large, lo + hi overflows int: undefined behavior. (This exact bug sat in the Java standard library's binary search for nine years.)

Rewrite midpoint so it returns the same value as (lo + hi) / 2 would with unlimited precision, rounded down, for any 0 <= lo <= hi <= INT_MAX, without ever overflowing. Use only int arithmetic. The check calls it with values near INT_MAX in a build with -fsanitize=undefined -fno-sanitize-recover, which stops at the first overflow. main prints:

5
2000000000

File: main.c

Starting point

#include <stdio.h>

int midpoint(int lo, int hi)
{
    return (lo + hi) / 2;
}

int main(void)
{
    printf("%d\n", midpoint(0, 10));
    printf("%d\n", midpoint(1900000000, 2100000000));
    return 0;
}

Example

expected output

5
2000000000
Hint 1

Start from lo and add half of the distance to hi.

Hint 2

The distance hi - lo is never bigger than INT_MAX when both are non-negative and lo <= hi.

Hint 3

return lo + (hi - lo) / 2;

Reference solution
#include <stdio.h>

int midpoint(int lo, int hi)
{
    /* hi - lo cannot overflow when 0 <= lo <= hi */
    return lo + (hi - lo) / 2;
}

int main(void)
{
    printf("%d\n", midpoint(0, 10));
    printf("%d\n", midpoint(1900000000, 2100000000));
    return 0;
}

Exercise 4 · write code

Checked addition

Write int add_checked(int a, int b, int *result). If a + b fits in an int, store the sum in *result and return 1. If it would overflow, leave *result alone and return 0. The function must never actually perform an overflowing addition (so if (a + b < a) style checks are wrong: the overflow has already happened, and the compiler may delete the test).

Use INT_MAX and INT_MIN from <limits.h>: if b > 0, the addition overflows exactly when a > INT_MAX - b; work out the b < 0 case yourself. Do not use a wider type such as long long; the point is to reason about the limits.

The program reads two integers and prints either the sum or overflow:

$ echo "2147483000 600" | ./prog
2147483600
$ echo "2147483000 700" | ./prog
overflow

File: main.c

Starting point

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

int add_checked(int a, int b, int *result)
{
    int sum = a + b;
    if ((b > 0 && sum < a) || (b < 0 && sum > a))
        return 0;
    *result = sum;
    return 1;
}

int main(void)
{
    int a, b, sum;
    if (scanf("%d %d", &a, &b) != 2)
        return 1;
    if (add_checked(a, b, &sum))
        printf("%d\n", sum);
    else
        printf("overflow\n");
    return 0;
}

Example

input

2147483000 600

expected output

2147483600

Example

input

2147483000 700

expected output

overflow
Hint 1

INT_MAX - b cannot overflow when b > 0. INT_MIN - b cannot overflow when b < 0.

Hint 2

For b < 0, the sum is too small exactly when a < INT_MIN - b.

Reference solution
#include <stdio.h>
#include <limits.h>

int add_checked(int a, int b, int *result)
{
    if (b > 0 && a > INT_MAX - b)
        return 0;
    if (b < 0 && a < INT_MIN - b)
        return 0;
    *result = a + b;
    return 1;
}

int main(void)
{
    int a, b, sum;
    if (scanf("%d %d", &a, &b) != 2)
        return 1;
    if (add_checked(a, b, &sum))
        printf("%d\n", sum);
    else
        printf("overflow\n");
    return 0;
}