GCC 16.2 and clang show C++ undefined behavior is not just a segfault

The author of blog.habets.se follows up on an earlier post about undefined behavior (UB) in C. Some readers had insisted that UB is demonstrably fine: we know what the code will do, and at worst we get a segfault. The author's answer is short: "This is false."

The first example is a small C++ program. A volatile int, device_command, stands in for a memory-mapped device that controls motors and gears. The function serve_hot_chocolate() writes 42 to it, then calls std::unreachable(), which the code comment describes as invoking undefined behavior at a given point. A second function, explode(), writes 3 to the same variable. Nothing calls explode(). Compiled with GCC 16.2 at -O1, the output for serve_hot_chocolate() contains only the store of 42 and no ret instruction. Execution therefore falls into whatever sits next in memory, which is explode(), and the device receives the command it was never meant to get. The author calls this "exploding chocolate".

The second example targets the claim that dereferencing a null pointer is fine because it just touches address zero and, if nothing is mapped there, segfaults. The author replaces std::unreachable() with int* ptr = nullptr; *ptr = 99;. GCC now emits the store of 42, then a store of zero (not 99) to address zero, then ud2, an invalid instruction that crashes the program. The number 99 never appears in the generated program. The author guesses the null write is there to improve debugger error messages. Checked on godbolt, clang again produced the exploding-chocolate output: the store of 42, then explode()'s store of 3.

The third example adds pointer arithmetic, ptr += 10. The author lists three reasons this is UB: using a null pointer for anything other than comparison with other pointers; creating a pointer outside an object (or beyond one past the end of an array) even if an object were valid at null; and dereferencing the address even if the earlier steps were valid. Here GCC emitted a store of 99 to address 40 followed by ret, which is what a naive reader would expect from the code taken at face value. The author calls that lucky.

Why did the compilers not treat *(ptr + 10) = 99 like *ptr = 99? The author says that is the wrong question: no compiler is obligated to "detect" UB at all. Anthropomorphizing, the author reads the behavior as the compilers deciding a null dereference cannot happen and so stopping instruction emission for the function, to save binary size. Because of the halting problem, a compiler cannot in general know whether code will ever run.

The author sums up three things UB did at the same place in the code: run whatever happens to be next in memory; store an effectively random value at address zero (zero, not 99) and then deliberately crash; or do what one would naively expect and write the value to the address seen in the code. The post ends on a joke about people who do not believe in nasal demons.

Key facts

  • With GCC 16.2 at -O1, std::unreachable() after a write of 42 left serve_hot_chocolate() without a ret, so execution fell into explode(), which was never called.
  • Replacing it with a null pointer write of 99 made GCC store zero (not 99) at address zero and then hit ud2, an invalid instruction that crashes the program.
  • Clang, checked on godbolt, also produced the fall-through into explode() for the null dereference case.
  • Adding ptr += 10 made GCC emit a plain store of 99 to address 40 and return, which the author calls lucky.
  • The author's point: no compiler is obligated to detect UB, and three different behaviors appeared from UB at the same place.

Why it matters

The post attacks a common belief, that UB in practice means a predictable crash. The examples show the compiler is free to drop instructions, including the return, so the effect can land in code unrelated to the bug. Here a function that was never called runs, and the program writes a value that appears nowhere in the source. The single piece of invalid code produced three different results depending on small changes.

Who it affects

Anyone writing C or C++ who relies on how UB "usually" behaves, for example expecting a null dereference to segfault. The author frames the example around a pretend memory-mapped device controlling motors, to show why stray execution matters for code that drives hardware. The device is explicitly imaginary; no real incident is described.

How to use it

The post gives the full code and the setup: GCC 16.2 at -O1, and clang checked on godbolt. Readers can paste the snippets into godbolt and compare the assembly themselves, then try the nullptr and ptr += 10 variants. The practical lesson is to treat UB as a bug to remove rather than as behavior you can predict from the source.

How solid is it

The evidence is concrete: assembly output for each case, reproducible with small programs. It is a personal blog post, and the author's explanations of why the compilers behave this way are hedged: "Looks to me like", "if we want to anthropomorphize them", and "Presumably" for the debugger-message guess. Only GCC 16.2 at -O1 is specified; no clang version or flags are given.

Risks and caveats

The results are specific to the compilers and settings shown, and the author does not say why the ptr += 10 case compiled to a plain store beyond calling it luck. No compiler bug report, vendor response or standards committee statement is mentioned, and the post does not claim the compilers are wrong. Its position is that these outputs are permitted because the code has UB.

“No compiler is obligated to “detect” UB at all.”

— Blog post at blog.habets.se