Undefined behaviour

The standard says what a program must do, and it also lists things a program must not do. For the second list it promises nothing: the behaviour is undefined, so the compiler may assume it never happens and optimise accordingly. Lesson 6 showed the arithmetic case; this lesson collects the memory cases and the tool that finds them.

Signed overflow

Adding one to INT_MAX is undefined for a signed int, even though the machine would wrap. UndefinedBehaviorSanitizer adds checks around the operation and reports the exact expression, which is the fastest way to prove that a suspicious line really overflows.

#include <limits.h> #include <stdio.h> static int add(int a, int b) { return a + b; } int main(void) { int x = INT_MAX; printf("%d\n", add(x, 1)); return 0; }

gcc -fsanitize=undefined -g -Wall -Wextra signed.c -o signed
./signed

signed.c:6:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
-2147483648

The wrapped result appears after the report and is not guaranteed; the sanitizer keeps running by default. Lesson 35 shows how to make the run stop on the first error and how the flags combine.

Out of bounds

An index equal to the array size is one past the last element. A loop that uses <= therefore reads memory that is not part of the array, and the optimiser is allowed to assume the loop stops before that. GCC recognises the pattern and reports the iteration:

#include <stdio.h> int main(void) { int values[3] = {1, 2, 3}; for (int i = 0; i <= 3; i++) { printf("%d\n", values[i]); } return 0; }

gcc -O2 -Wall -Wextra bounds.c -o bounds

bounds.c: In function 'main':
bounds.c:8:9: warning: iteration 3 invokes undefined behavior [-Waggressive-loop-optimizations]
8 | printf("%d\n", values[i]);
| ^~~~~~~~~~~~~~~~~~~~~~~~~
bounds.c:7:23: note: within this loop
7 | for (int i = 0; i <= 3; i++) {
| ~~^~~~

The program is not run here, because the fourth read may return a value from the next variable or crash, depending on the build. The warning is enough to find the boundary: the loop condition should be i < 3, as in lesson 22.

Reading the bits safely

A float and an int have different types, and reading one through a pointer to the other breaks the strict-aliasing rule the optimiser relies on. The standard function memcpy copies the bytes and is the portable way to look at a representation. The result is the IEEE 754 pattern of 1.0f.

#include <stdio.h> #include <string.h> int main(void) { float value = 1.0f; int bits; memcpy(&bits, &value, sizeof bits); printf("%d\n", bits); return 0; }

gcc -Wall -Wextra bits.c -o bits
./bits

1065353216

1.0f is 0x3F800000 in IEEE 754, which is 1065353216 as a signed int. The copy is defined for any two object types of the same size, and the compiler usually turns it into a single move, so the safe version costs nothing.

Type punning and literals

The cast version of the bit reading compiles, runs, and prints the same number on this machine, which is exactly what makes undefined behaviour dangerous. With the aliasing warning enabled, GCC explains which line the optimiser may miscompile:

#include <stdio.h> static float make_value(void) { return 1.0f; } int main(void) { float value = make_value(); int *bits = (int *) &value; printf("%d\n", *bits); return 0; }

gcc -O2 -Wall -Wextra -Wstrict-aliasing=2 alias.c -o alias

alias.c: In function 'main':
alias.c:11:25: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
11 | int *bits = (int *) &value;
| ^~~~~~

The second mistake is a write into a string literal. The literal may be placed in read-only memory, so the store is undefined; here the process dies with a segmentation fault before printing anything:

#include <stdio.h> int main(void) { char *text = "hello"; text[0] = 'H'; printf("%s\n", text); return 0; }

gcc -Wall -Wextra literal.c -o literal
./literal; echo "exit: $?"

exit: 139

The fix for the first is memcpy; the fix for the second is an array initialised from the literal, as lesson 23 showed: char text[] = "hello"; owns its bytes and can be changed.

Exercises

  1. Compile the bounds program with -O0 and -O2 and compare the warnings and the output.
  2. Write a program that shifts a signed int into its sign bit and run it with UBSan. Which line does the report name?
  3. Replace the pointer cast in the alias program with memcpy and check that the warning disappears.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does the standard promise for undefined behaviour?

    Answer

    Nothing at all

    The compiler may assume the operation never happens, so any result is possible and no diagnostic is required.

  2. Why is signed integer overflow undefined while unsigned overflow is defined?

    Answer

    The standard defines only unsigned

    Unsigned arithmetic is specified modulo one more than the maximum, while signed overflow has no defined result.

  3. What is wrong with for (int i = 0; i <= 3; i++) over int values[3]?

    Answer

    It reads values[3], one past the array

    The valid indexes are 0 to 2, so the final pass reads outside the array and the behaviour is undefined.

  4. What is the portable way to inspect the bits of a float?

    Answer

    Copy the bytes with memcpy

    memcpy copies the representation without violating strict aliasing, and the compiler usually reduces it to one move.

  5. This program is built with gcc -std=c17 -fsanitize=undefined -g -Wall -Wextra signed.c -o signed. Which runtime error does UBSan report? #include <limits.h> #include <stdio.h> static int add(int a, int b) { return a + b; } int main(void) { int x = INT_MAX; printf("%d\n", add(x, 1)); return 0; }

    Answer

    signed integer overflow

    Adding one to INT_MAX overflows a signed int, which UBSan names directly.

  6. What does this program print? #include <stdio.h> #include <string.h> int main(void) { float value = 1.0f; int bits; memcpy(&bits, &value, sizeof bits); printf("%d\n", bits); return 0; }

    Answer

    1065353216

    The IEEE 754 pattern of 1.0f is 0x3F800000, which is 1065353216 as a signed int.

  7. This program is built with gcc -std=c17 -O2 -Wall -Wextra bounds.c -o bounds. Which warning flag does GCC print? #include <stdio.h> int main(void) { int values[3] = {1, 2, 3}; for (int i = 0; i <= 3; i++) { printf("%d\n", values[i]); } return 0; }

    Answer

    -Waggressive-loop-optimizations

    The optimiser proves the final loop iteration is out of bounds and reports it under -Waggressive-loop-optimizations.

  8. This program is built as literal and run. What does the shell report? #include <stdio.h> int main(void) { char *text = "hello"; text[0] = 'H'; printf("%s\n", text); return 0; }

    Answer

    exit: 139

    The literal may live in read-only memory, so the store is undefined and the process dies with a segmentation fault, reported as 139.

  9. Which warning flag reports a type-punned pointer under optimisation?

    Answer

    -Wstrict-aliasing

    The optimiser relies on the aliasing rules, and -Wstrict-aliasing=2 points at the cast that breaks them.

  10. What is the fix for writing into a string you need to modify?

    Answer

    Declare it as char text[] = "hello";

    An array initialised from the literal owns its bytes, so the characters can be changed safely.

The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev