Proven C Book←↑→

54 Undefined behaviour

What to know first

chapter 14, Compiler optimisation · the abstract machine and observable behaviour
chapter 53, Errors and contracts · what it means to break a contract

Looking back

Chapter 14 said that violating strict aliasing is “the act of telling the compiler something untrue as if it were true.” Then what has the standard permitted the implementation by leaving some behaviour “undefined”?

A. It decided to demand nothing at all. The standard’s sentence is cold — for undefined behaviour the standard “imposes no requirements”. That is, such a program has no correct execution result whatever. The common misunderstanding is “dangerous behaviour, but it mostly works as expected”, whereas in the eye of the contract the whole program loses its meaning. That difference is this chapter entire.

The need for this chapter, and its context

What this book has deferred for fifty chapters under the name “outside the contract” is now faced directly. The deferral was not to avoid alarm but because talking about it without specimens turns it into superstition. By now the reader has met overflow, null dereference and aliasing in person, so UB becomes a name for things already seen rather than an abstraction.

By the end of this chapter

We face head on the world this book has kept deferring under the name “outside the contract”. What UB exactly is and why it exists, why it is not “a little dangerous” but “anything at all is possible” — and how to avoid and catch it in practice. The contract narrative begun in Part II is completed here.

The questions this chapter answers

  1. Must all the UB be memorised? I hear the standard has hundreds of them.

54.1 Three grey zones — UB, unspecified, implementation-defined#

Let us first separate three confusable words. Organised through the cases this book has met so far:

kindthe standard’s attitudeexamples met in this book
implementation-definedthe implementation decides and documents itthe size of int (chapter 28), the signedness of char
unspecifiedone of a fixed set of choices, with no duty to documentthe order of evaluating subexpressions in one expression (chapter 34)
undefined behaviour (UB)it imposes no requirements at allsigned overflow (chapter 6), boundary violation (chapter 39), null dereference (chapter 37)

Table 54.1 — Kinds of undefined behaviour and the standard’s stance

The first two are worlds where “there are several answers but there is an answer.” Only UB is a world where there is no answer at all.

The words again, nailed down. The three names in Table 54.1 — implementation-defined, unspecified, undefined behavior — are the standard’s own terms, written in the document itself. The phrase “grey area”, used in this chapter’s title and body, is, as chapter 13 said, a name this book adopted for convenience; it is not in the standard. When talking to anyone else, translate it into the exact word — not “that’s a grey area” but “that’s unspecified” or “that’s UB”.

54.2 Why it exists#

There are two reasons for leaving an outside-the-contract region. First, because machines differ — chapter 6′s shift of at least the width is the representative case. x86 and older ARM respond differently, and had the standard chosen one side the other machine would have to insert correction code every time. Instead of taking sides it said “do not write that code.” Second, to obtain premises for optimisation — as chapter 6 showed, the premise that “signed integers do not overflow” is what lets the compiler analyse and reorder loops (chapter 14). These clauses are the price of C remaining the language of speed for half a century.

54.3 The real face of “anything at all”#

UB’s result is not only a collapse. The pattern seen in chapter 14 is more frightening — the compiler reads UB as “a thing that cannot happen” and deletes code. A null check disappears entirely (if the pointer was already dereferenced, it infers “it cannot be null”), an overflow check disappears (since signed overflow is premised not to happen), a loop becomes infinite or vanishes altogether. So UB’s representative symptom is not “dying on the spot” but a bug that appears somewhere unrelated, disappears when the optimisation level changes, and is hard to reproduce.

In practice. The vanished null check — Linux kernel CVE-2009-1897

There was an incident in which this pattern really went off in the kernel. The code went roughly like this — the pointer tun was dereferenced first to take a value out, and below that a null check if (!tun) return ...;. The order was a mistake, but to a human eye it looks like “the check is still there, so a null will be caught.” The compiler’s inference was different: it was already dereferenced → had it been null that would have been UB at that moment → UB is premised not to happen → therefore tun is not null → the null check below is dead code. The check was removed entirely by optimisation and, combined with an environment in which the null page could be mapped, became a privilege-escalation vulnerability. The compiler worked by the rules; what collapsed was the contract.

54.4 Before the computation even begins — the UB of a file’s shape#

Undefined behaviour usually brings to mind an accident during execution, such as an overflow or a null dereference. Yet read the standard’s list (annex J.2) from the top and something surprising appears — the second entry is about the last character of a file.

what the standard requiresbreak it and
a non-empty source file must end in a new-line characterundefined behaviour
that new-line must not be one preceded by a backslashundefined behaviour
the file must not end in a partial preprocessing token or commentundefined behaviour

Table 54.2 — What the standard requires, and what breaking it means

That is, a file whose last line has no new-line at the end is outside the contract however perfect its grammar. This provision has been there since C89 and remains in C23 (ISO/IEC 9899:2024) — it is the second entry of annex J.2. C++, for reference, dropped the clause in 2011 (deciding that a missing new-line counts as one appended). It is a rare place where the two languages parted.

Why should such a thing be UB? Recall the translation phases touched on in chapter 8 — all eight are laid out in chapter 61 — and the answer appears. The preprocessor works by lines, and one directive is complete only when a new-line ends it. If the file ends with no new-line, the last line is left unfinished, and what happens next differs by implementation. The third row’s “partial token” is the same circumstance — if the file ends with an unclosed string literal or a comment with no */, the preprocessor has no ground on which to judge whether to keep reading into the next file.

Today’s compilers mostly append a new-line quietly (older GCC gave warning: no newline at end of file). So the place this clause makes trouble in practice is not the compiler but the other tools that handle the file.

In practice. The practical noise one new-line makes — git and the Unix tools

POSIX defines a line as “a string ending in a new-line”. So a file missing the final new-line becomes, in the eyes of the tools, “a file whose last line is unfinished”, and the following happens.

  • A mark is left in git’s diff — that famous \ No newline at end of file line. If somebody later adds the new-line, a line whose content did not change is caught as a changed line, making the diff dirty and making conflicts likely at that place when branches are merged. The red mark on the last line in GitHub’s web view is the same thing.
  • Joining files runs lines together — with cat a.txt b.txt, a’s last line and b’s first line become one line. It is especially tiresome in builds that make source or configuration by joining fragments.
  • Tools that count lines miss one — wc -l counts new-lines, so an unfinished last line is not counted.

So today’s practice is one line — end a text file with a new-line. An editor setting (add a final new-line automatically), .editorconfig’s insert_final_newline, and the formatting tools of chapter 101 do that work for you. The C standard’s clause is, in effect, the oldest ground for that practice.

54.5 Other curious pieces of UB#

As of C23, annex J.2 lists 221 kinds of undefined behaviour. Most are things one will never meet in a lifetime, but among them are several entries that make one ask “even this?”. We pick out those that happen in the world of characters and names, unrelated to computation at run time.

this codewhat is wrongthe standard’s place
a file ending with no new-linethe one seen in the previous section5.1.1.2
a /* comment left unclosed at end of fileending in a partial comment is the same entry5.1.1.2
a string with its quote unclosed at end of filea partial preprocessing token5.1.1.2
#include "dir\file.h"a \ inside a header name is UB — writing a Windows path as it stands hits this6.4.7
#include <a//b.h>//, /*, ' and " likewise are UB6.4.7
#define defined(x) …using defined as a macro name6.10.9
using assert after #undef asserterasing a standard library macro and then using it7.1.3
int _Value;, int __x;trespassing on the reserved name space (chapter 87)7.1.3
memcpy(p, q, 0) with p nulleven at size 0 a null pointer is outside the contract (see below)7.26.1p3
printf("%s", NULL)passing null as a string7.23.6.1
short a[10]; short *p = &a[15];merely making an out-of-range pointer is UB, without dereferencing6.5.7
if (p > q) on unrelated objectscomparing with a relational operator (chapter 38)6.5.9
towctrans under another localeUB if LC_CTYPE differs from when wctrans was called7.31.3.2

Table 54.3 — Common UB code and where the standard puts it

The first three are the other faces of the “file shape” entry seen in the previous section. A file ending with an unclosed comment or string falls under the same clause — it looks as though it would fail to compile anyway, but in the standard’s eyes it is a place where not even a diagnosis is required.

The two rows in the middle are especially practical. Writing #include "utils\str.h" on Windows is undefined behaviour as far as the standard goes — in reality MSVC handles it for you, but it becomes a problem the moment you port. What the standard guarantees is / alone, and happily the Windows compilers accept / too. Hence the advice always to use / in header paths.

The short *p = &a[15]; row surprises people too. Without reading or writing anything, merely making the pointer is outside the contract (only up to one past the array’s end is permitted). It is why “I only compute the address and never use it” does not hold, and the ground on which chapter 38 drew a boundary round pointer arithmetic.

The last row shows this list’s character well. One wide-character conversion function carries the condition that “the locale must be the same as when wctrans was called”, and breaking it is UB. Most of the 221 are of this grain — very narrow, very specific, and never met in a lifetime.

In practice. UB sometimes shrinks — the story of memcpy(NULL, NULL, 0)

The ninth row of the table was long a matter of dispute. “The size is 0 so nothing will happen — what does it matter whether the pointer is null?” one thinks, but that is not what the standard said. The rule does not sit in the description of memcpy but in the general clause of <string.h>: §7.26.1p3 states that the length argument n may be zero, but that unless stated otherwise the pointer arguments on such a call shall still have valid values — and §7.1.4 gives a null pointer as an example of exactly such an invalid value. So code handling an empty array slipped outside the contract through no fault of its own.

void copy(int *dst, const int *src, size_t n) {
    memcpy(dst, src, n * sizeof *dst);   /* UB if n == 0 and both are null */
}

There was real damage too. The compiler gains the premise that memcpy’s arguments are not null, and so can erase a null check that follows — the pattern seen in chapter 14 and in this chapter. Sanitizers (chapter 18) catch it as well.

Yet this clause has been settled to go away. The committee accepted proposal N3322 (“Allow zero length operations on null pointers”) into the working draft of the next edition (C2y); once that edition is published, giving null pointers to zero-length operations becomes defined behaviour — memcpy(NULL, NULL, 0), memcmp(NULL, NULL, 0), (int *)NULL + 0, (int *)NULL - 0 and (int *)NULL - (int *)NULL all become legal.

It is not only memcpy that changes. The table attached to the proposal lists the functions that take a length argument, one after another — memmove, memset, memccpy, strncpy, strncat, strndup, strncmp, bsearch, qsort and others. Conversely strcpy, strcat and strdup are not affected, because they have no length argument: the condition of the rule is “length zero”, so a function with nowhere to write a zero was never in scope.

The way it was adopted is worth noting too. Along with the vote, the committee recommended that implementers apply the change retroactively to older editions of the standard. That recommendation is not written in the proposal itself but came with the vote, so the accurate phrasing is “it was decided so”, not “the document says so”.

This story leaves two lessons. First, the UB list is not a fixed scripture — clauses that are useless for optimisation and merely torment people do get tidied away over time. Second, even so, whether the compiler you are using now reflects that change is another matter. For the time being, code that checks n == 0 first is still right.

A common misconception. “These are theoretical quibbles; nothing actually happens”

In most places nothing really does happen. But that is precisely this chapter’s theme — nothing happening is not a guarantee. A file with no new-line is quiet at the compiler and makes noise down the tool chain, and memcpy(NULL, NULL, 0) is fine until the day the optimisation level is raised and the null check vanishes.

The practical attitude is this. Keep the clauses that can be kept for free. Put a new-line at the end of a file, use / in header paths, do not begin a name with two underscores — the cost of these is zero, and in exchange you gain one thing: “in this place I need not suspect anything.”

54.6 UB you create on purpose — unreachable#

Everything so far has been undefined behaviour to avoid. C23 brought in one word that runs the other way — a device for promising the compiler that “control never comes here”.1

#include <stddef.h>

const char *name_of(enum color c)
{
    switch (c) {
    case RED:   return "red";
    case GREEN: return "green";
    case BLUE:  return "blue";
    }
    unreachable();          /* those three are all of them --- arriving is my bug */
}

The standard’s wording is exactly that shape — it “indicates that the particular flow control that leads to the invocation will never be taken”, and program execution “shall not reach such an invocation”. Reaching it is undefined behaviour.

★ So this is a contract, not a check (chapter 53). Set beside assert, the character is plain.

WhatOn arrivalWhat you get
assert(0)prints a diagnosis and stops (nothing at all under NDEBUG)you find the accident
unreachable()undefined behaviourthe compiler deletes that branch

Table 54.4 — Two ways to write “control must not come here”

What you gain is size and speed. In the function above the compiler learns that there is no path out of the switch, and drops the handling for a return-less path. What you lose is the safety net — add a colour later and forget to update the switch, and a place that used to return something odd becomes a place that can do anything at all.

The working rule is therefore short. Write it only when you can truly prove it. Where the judgement is not firm, return an error or call abort() instead — both stay inside the contract.

54.7 How to avoid it — discipline, tools, and components#

Defence in practice is three layers.

Discipline — the rules this book has passed through are the list: initialise before use (chapter 24), keep boundaries (chapter 39), check for null (chapter 37), change one variable only once in one statement (chapter 34), do not take shortcuts outside the contract (pointer casts, assumptions about representation) (chapters 12 and 38).

Tools — the nets equipped in chapter 18. Compiler warnings catch at compile time; UBSan and ASan catch at run time. The checked arithmetic brought in by C23 is a tool of this layer too — functions that report overflow as a value instead of making it UB:

examples-en/ch52/checked.c

#include <stdckdint.h>
#include <stdio.h>

/* C23's checked arithmetic: on overflow it returns true, and the result holds the wrapped value. */
int main(void)
{
    int a = 2000000000;
    int b = 2000000000;
    int sum = 0;

    if (ckd_add(&sum, a, b)) {
        printf("%d + %d overflows int (we stayed inside the contract)\n", a, b);
    } else {
        printf("sum: %d\n", sum);
    }

    int small = 0;
    if (ckd_add(&small, 20, 22)) {
        printf("overflow\n");
    } else {
        printf("20 + 22 = %d\n", small);
    }
    return 0;
}

Output

2000000000 + 2000000000 overflows int (we stayed inside the contract)
20 + 22 = 42

ckd_add returns “did it overflow?” as its return value — the standard’s answer to the trap learned in chapter 6 (“signed overflow is outside the contract”), governed by the discipline learned in chapter 53 (“errors are values”).

Components — using an API in which violating the contract is difficult to begin with (chapter 44′s proven is that layer). Chapter 18′s metaphor — tools are nets, good components are footholds — is completed here.

Q. Must all the UB be memorised? I hear the standard has hundreds of them.

A. Memorising is not the goal — the list is vast and continually refined. What works in practice is an instinct: the habit of asking “are my grounds for saying this code is correct in the contract, or is it that it ran on my computer?” And backing that instinct with tools — turning warnings on, running tests under sanitizers, cross-checking with two compilers (chapter 18). The reason this book has repeated “is it correct on the abstract machine” since Part II is precisely to plant that instinct.

54.8 Closing Part IX#

The part of precision is over — how to read an operator as a contract (chapter 50), how to put bits to work (chapter 51), how to handle approximation (chapter 52), how to handle failure (chapter 53), and how to know the world outside the contract (chapter 54). The five chapters share one theme: C is a language that entrusts much to the programmer, and the person who knows what has been entrusted writes safe code.

The last parts remain. Every program so far has been a single file — now it grows into several files (chapter 56), we face the layer of preprocessing and translation (chapter 61), we learn the terrain of the standard library (chapter 66), we treat proven head on (Part XII), and we close the book with the practices of modern C (chapter 105).

The next part is the story of composing a program. Its first chapter is the place we have used only as a six-line convention until now — main itself. We see its three forms, and where the value it returns goes.

Notes

  1. A function-like macro in <stddef.h> (C23 §7.21.1). It takes no arguments and expands to a void expression. ↩