There is a particular kind of bug report that ends a good week. The code has been on the bench for months. It passes unit test, it passes integration, it has been through a soak. Then someone bumps the compiler for an unrelated reason, rebuilds at the same optimisation level, and a function that has been correct since 2019 starts returning nonsense.
Nobody edited that function. Nothing in the source changed. What changed is that the program was always relying on undefined behaviour, and the new compiler stopped being polite about it.
This article is about what undefined behaviour actually is according to the language standards, the classes of it an embedded team really meets, why a toolchain change can move the ground under working code, and where compiler and standard library qualification fits into a safety-critical verification chain. The spelling in the ISO text is “undefined behavior”; we use the British form in our own prose and keep the American form inside direct quotations.
”It Works on My Bench” Is Not Evidence
The intuition most engineers carry is that undefined behaviour means “the program will crash”. It is a comforting model because crashes are observable. It is also wrong, and the standard says so in plain language.
Every C quotation in this article comes from N1570, the publicly available C11 committee draft, because the published ISO text is not free to redistribute. ISO/IEC 9899 defines undefined behaviour at clause 3.4.3 paragraph 1:
behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this International Standard imposes no requirements
Imposes no requirements. Not “requires a diagnostic”, not “requires a trap”. The note attached at paragraph 2 spells out the permitted range:
Possible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message).
“Ignoring the situation completely” is the dangerous end of that range, because it is indistinguishable from working. And the standard’s own worked example, at paragraph 3, is the one embedded teams hit most: “An example of undefined behavior is the behavior on integer overflow.”
So a passing test run is not evidence that a program is free of undefined behaviour. It is evidence that one build, of one compiler, at one optimisation level, on one target, did something you liked, once.
The Harder Problem Is Deciding Whether Something Is Undefined
Even a team that accepts all of the above tends to assume that finding undefined behaviour in the standard is a lookup exercise. It is not, and the standard is explicit about why. Clause 4, paragraph 2:
If a “shall” or “shall not” requirement that appears outside of a constraint or runtime-constraint is violated, the behavior is undefined. Undefined behavior is otherwise indicated in this International Standard by the words “undefined behavior” or by the omission of any explicit definition of behavior. There is no difference in emphasis among these three; they all describe “behavior that is undefined”.
Read that again with an auditor’s eye. Two of the three ways the C standard tells you something is undefined never use the phrase “undefined behavior” at all. One of them is a “shall” sentence sitting in ordinary descriptive prose. The other is silence.
Colin Rullkotter, an engineer at Solid Sands, published an essay in June 2026 that is the best recent account we have read of what that costs in practice. He works on SuperGuard, the company’s C and C++ standard library test suite, and he describes his job as four steps: take a class or group of functions from a library header, consult the ISO standard for the language version or versions that introduced or amended the feature, distill the semantic specifications into traceable requirements that can be individually tested, then write the tests.
His thesis is that this process is as much a lawyerly discipline as an engineering one. Step two is where that bites. In his words, the task brought into sharp relief “the philosophy of the standard as a legal text, where precision is prioritized over readability; explication over implication; and everything over the happiness of test suite developers.”
The <mutex> Example, and Why It Is Worth Your Time
Rullkotter’s worked example is worth following closely, because the shape of it recurs everywhere.
He was assigned to write tests for the <mutex> header to supplement the 1.3 release of SuperGuard. The starting point is how the standard defines ownership, at N4140 clause 30.4.1.1 [thread.mutex.requirements.general] paragraph 1: “An execution agent owns a mutex from the time it successfully calls one of the lock functions until it calls unlock.” The lock() member function then carries a precondition that the calling thread does not already own the mutex, and a postcondition that after the call the calling thread owns the mutex. As Rullkotter notes, “Requires” is standard-ese for “violating this is undefined behavior”. So far this is intuitive: locking the same non-recursive mutex twice on one thread is undefined.
C++14 then split ownership into exclusive, for read and write access, and shared, for read-only, and introduced shared_timed_mutex, which supports both. Now the trap. Take a shared_timed_mutex, call lock_shared() to acquire shared ownership, then immediately call lock(). Is that undefined? The precondition does not stipulate whether the prohibited ownership is exclusive or shared.
Rullkotter’s characterisation of what the committee did to the precondition is that “it stayed exactly the same”. Checking the C++14 working draft ourselves, the sentence is not word-for-word identical: N4140 clause 30.4.1.2 [thread.mutex.requirements.mutex] paragraph 7 does explicitly enumerate std::shared_timed_mutex in its list of types. What did not change is the part that decides the question. The word “own” is left unqualified in the precondition, and equally unqualified in the postcondition at paragraph 9.
Engineering reasoning gets you to the wrong answer here, and Rullkotter says so with more grace than most of us would manage. lock() conferred exclusive ownership in C++11 because no other kind existed; changing that would wreak havoc on existing code; it would seem insane for “own” to mean one thing in the precondition and another in the postcondition. That is “thinking like an engineer. Big mistake.”
The answer is not in the requirements section at all. It is in the class subsection for shared_timed_mutex, at N4140 clause 30.4.1.4.1 [thread.sharedtimedmutex.class] paragraph 3, which states that the behaviour of a program is undefined if, among other things, “a thread attempts to recursively gain any ownership of a shared_timed_mutex”. Any ownership. So the precondition forbids the call when the thread holds exclusive or shared ownership, while “owns” in the postcondition refers only to exclusive ownership. As Rullkotter puts it, the concept of ownership has a different scope in each case.
He adds that this collision between engineering assumption and the letter of the standard is not confined to hard features, and is as common in language features as in library ones. auto and explicitly typed enums get name-checked as further offenders. His closing point is the one we would put on a wall: in the functional safety world as in the legal one, due diligence is the enemy of assumption.
The Undefined Behaviour Classes Embedded Teams Actually Meet
Below is the short list that accounts for most real findings in embedded C, with the clause each traces to.
| Class | What the standard says | Clause (N1570) |
|---|---|---|
| Signed integer overflow | ”If an exceptional condition occurs during the evaluation of an expression (that is, if the result is not mathematically defined or not in the range of representable values for its type), the behavior is undefined.” | 6.5 p5 |
| Strict aliasing | ”An object shall have its stored value accessed only by an lvalue expression that has one of the following types”, followed by a six-item list. A “shall” outside a constraint, so violating it is undefined by clause 4 p2. | 6.5 p7 |
| Unsequenced side effects | ”If a side effect on a scalar object is unsequenced relative to either a different side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined.” | 6.5 p2 |
| Invalid pointer dereference | ”If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined.” A footnote lists a null pointer, a misaligned address and the address of an object after its lifetime among the invalid values. | 6.5.3.2 p4 |
| Uninitialised automatic read | Undefined where the object “could have been declared with the register storage class (never had its address taken)” and is uninitialised. Note the address-never-taken condition: this is narrower than “all uninitialised reads”. | 6.3.2.1 p2 |
| Out-of-bounds subscript | ”An array subscript is out of range, even if an object is apparently accessible with the given subscript (as in the lvalue expression a[1][7] given the declaration int a[4][5])“ | Annex J.2 |
| Lifetime and division | Annex J.2 also lists an object referred to outside its lifetime, use of a pointer whose object’s lifetime has ended, use of an automatic object while its value is indeterminate, and a second operand of / or % that is zero. | Annex J.2 |
Two of these deserve a note, because they are routinely overstated.
The uninitialised read is narrower than folklore has it. The C11 wording turns on whether the object could have been declared register, meaning its address was never taken. Take the address and you are in different territory. If a coding standard or a static analyser flags every uninitialised read as undefined behaviour, that is a defensible engineering rule, but it is not what clause 6.3.2.1 p2 literally says, and a reviewer who knows the difference will ask.
The unsequenced-side-effect rule is the one behind the classic interview questions. Footnote 84 in N1570 names the exact expressions it renders undefined:
i = ++i + 1;
a[i++] = i;
while explicitly allowing i = i + 1; and a[i] = i;.
Why a Compiler Upgrade Can Change Code You Never Touched
Here is where the abstraction becomes an engineering schedule risk. Optimisers do not go looking for undefined behaviour in order to punish you. They assume it does not occur, because the standard entitles them to, and then propagate that assumption through dataflow analysis. The result can be the removal of code you wrote on purpose.
The clearest way to see it is to read what the vendor documents about its own defaults. From the GCC manual’s optimize options:
-fstrict-aliasingis documented as: “Allow the compiler to assume the strictest aliasing rules applicable to the language being compiled. For C (and C++), this activates optimizations based on the type of expressions. In particular, accessing an object of one type via an expression of a different type is not allowed, unless the types are compatible types, differ only in signedness or qualifiers, or the expression has a character type.” And, decisively: “The-fstrict-aliasingoption is enabled at levels-O2,-O3,-Os.” If you ship at-O2, you have already opted in.-fdelete-null-pointer-checksis documented as: “Assume that programs cannot safely dereference null pointers, and that no code or data element resides at address zero… other optimization passes in GCC use this flag to control global dataflow analyses that eliminate useless checks for null pointers; these assume that a memory access to address zero always results in a trap, so that if a pointer is checked after it has already been dereferenced, it cannot be null.” GCC adds that “in some environments this assumption is not true”, and points at-fno-delete-null-pointer-checks.-fisolate-erroneous-paths-dereferencedetects paths that dereference a null pointer or divide by zero, isolates them from the main control flow, and turns the offending statement into a trap. GCC states this “is enabled by default at-O2and higher”.
GCC also documents the escape hatches, which is the useful half of the story. -fwrapv “instructs the compiler to assume that signed arithmetic overflow of addition, subtraction and multiplication wraps around using twos-complement representation”, -fwrapv-pointer makes the same assumption for “pointer arithmetic overflow on addition and subtraction”, and -ftrapv instead “generates traps for signed overflow on addition, subtraction, multiplication operations”.
On type punning, GCC’s documentation draws a line that matters for teams sharing headers between C and C++ code. Reading a different union member than the one last written is allowed in C even with -fstrict-aliasing, provided the memory is accessed through the union type. Taking the address of a member and dereferencing the resulting pointer “might not” work. And GCC states directly: “In ISO C++, type-punning through a union type is undefined behavior, but GCC supports it as an extension.” A pattern that is portable C, an extension in C++, and unsupported through a pointer cast in either.
Two Upgrade Moments the Vendor Wrote Down
Neither of the following is folklore. Both are in GCC’s own release documentation.
GCC 4.2, released 13 May 2007. The release notes introduce -fstrict-overflow and say it “tells the compiler that it may assume that the program follows the strict signed overflow semantics permitted for the language: for C and C++ this means that the compiler may assume that signed overflow does not occur.” Their example:
for (i = 1; i > 0; i *= 2)
GCC’s own commentary is that this loop “is presumably intended to continue looping until i overflows. With -fstrict-overflow, the compiler may assume that signed overflow will not occur, and transform this into an infinite loop.” The notes then state that -fstrict-overflow “is turned on by default at -O2”. A team that moved to 4.2 and rebuilt at their existing optimisation level inherited that transformation without touching a line of source.
GCC 4.9, released 22 April 2014. The porting guide carries a section headed “Null pointer checks may be optimized away more aggressively”, with this example:
int copy (int* dest, int* src, size_t nbytes) {
memmove (dest, src, nbytes);
if (src != NULL)
return *src;
return 0;
}
GCC’s explanation: “The pointers passed to memmove (and similar functions in <string.h>) must be non-null even when nbytes==0, so GCC can use that information to remove the check after the memmove call. Calling copy(p, NULL, 0) can therefore deference a null pointer and crash.” The typo in that last sentence is GCC’s, quoted as published. The suggested fix is to guard the call with if (nbytes != 0). GCC notes the same optimisation can affect implicit null checks such as the one the C++ runtime performs for delete[].
What a Heavily Reviewed C Codebase Does About It
If these optimisations were purely theoretical risks, large C projects would leave them on. The Linux kernel does not. Its top-level Makefile, checked against the mainline tree in July 2026 at version 7.2.0-rc5, adds -fno-strict-aliasing, -fno-delete-null-pointer-checks and -fno-strict-overflow to KBUILD_CFLAGS, the last under the comment “disable invalid ‘can’t wrap’ optimizations for signed / pointers”.
That is not an argument for copying those flags into your build. It is an argument that the interaction between undefined behaviour and optimisation is a real, current engineering consideration in a codebase reviewed at that scale, and that on your project it deserves to be a decision rather than a default nobody examined.
When the Optimiser Meets a Real Bug
The most instructive published case is the Linux TUN driver in kernel 2.6.30, documented by Jonathan Corbet at LWN.net in July 2009.
A patch added a line to tun_chr_poll() that read struct sock *sk = tun->sk; before the function’s existing if (!tun) return POLLERR; check. Dereference, then check. In LWN’s words: “the GCC compiler will, by default, optimize the NULL test out. The reasoning is that, since the pointer has already been dereferenced (and has not been changed), it cannot be NULL. So there is no point in checking it.”
On its own that is a kernel oops, a crash and a scary traceback. What made it an exploit was that the attacker could arrange for the zero page to be mapped, so the dereference did not fault, and could then reach code further into the same function using an attacker-controlled sk. Because the constant SOCK_ASYNC_NOSPACE is zero, a test_and_set_bit call there could be used to set the low bit of the TUN driver’s null mmap function pointer, turning it into the value one. Calling mmap() on the device then jumped the kernel into attacker-mapped memory at address one.
LWN’s own summary of the chain is worth quoting, because it resists the temptation to blame any single layer: “a NULL pointer was dereferenced before being checked, the check was optimized out by the compiler, and the code used the NULL pointer in a way which allowed the attacker to take over the system.” Several other things also had to go wrong, including a policy that permitted the low-memory mapping. We are not citing a CVE identifier here because the LWN article does not carry one and we have not verified one against a primary advisory.
The engineering lesson is narrow and worth keeping narrow: a null check that a reviewer can see in the source is not necessarily a null check that exists in the binary.
Detecting What You Can, Qualifying What You Cannot
Runtime detection helps and is worth knowing about. Clang’s UndefinedBehaviorSanitizer documentation describes it as “a fast undefined behavior detector” that “modifies the program at compile-time to catch various kinds of undefined behavior during program execution”, covering array subscripts out of bounds where the bounds can be statically determined, out-of-bounds bitwise shifts, dereferencing misaligned or null pointers, signed integer overflow and overflowing float conversions, with “small runtime cost and no impact on address space layout or ABI”.
Note what the same page reveals about the difficulty of the underlying question. Float division by zero “is undefined per the C and C++ standards, but is defined by Clang (and by ISO/IEC/IEEE 60559 / IEEE 754) as producing either an infinity or NaN value, so is not included in -fsanitize=undefined”. Even the people building the detector have to make a judgement call about what the standard leaves undefined and what the hardware defines. That is Rullkotter’s point arriving from a completely different direction.
The structural limit of any runtime detector is that it only sees paths that execute. It is a strong bug-finding technique. It is not conformance evidence, and it says nothing about whether the compiler and library you ship with implement the standard correctly in the first place.
Where This Lands in India
Two situations make this an India-shaped problem rather than an abstract one.
The first is new silicon. India has a growing population of teams bringing up RISC-V cores and custom instruction sets, and a new backend is exactly where undefined behaviour assumptions bite hardest. On a mature target, teams lean on years of accumulated experience about how a given toolchain treats a given construct. On a brand new code generator there is no such folklore to fall back on. An immature backend may do the naive, obliging thing today and begin exploiting signed-overflow or aliasing assumptions the moment its optimiser matures. Conformance evidence has to substitute for institutional memory, which is why exercising a code generator methodically during bring-up, with something like the Solid Sands Code Generator Trainer, is engineering work rather than paperwork.
The second is toolchain migration. Indian engineering centres frequently inherit long-lived codebases and are the ones asked to move a product from an older GCC to a newer one, or from a vendor compiler to a mainline one, for a variant programme or a cost reduction. The GCC 4.2 and GCC 4.9 notes above are precisely what that job looks like from the inside. “The code did not change, the compiler’s assumptions did” is a risk an Indian verification team is regularly asked to own without having written the original software, and the honest way to discharge it is evidence rather than a regression suite that was written against the old compiler’s habits.
Underneath both sits the assessor’s question. When an ISO 26262, DO-178C or IEC 62304 assessment asks how you know your compiler translated that source correctly, the team answering is very often in Bengaluru, Pune, Hyderabad or Chennai.
The Verification Chain, and What Each Layer Does Not Do
It is worth being blunt about the division of labour, because undefined behaviour articles have a gravitational pull towards a tidy and false conclusion.
Undefined behaviour in the code you write is a static analysis and dynamic test problem. Perforce Helix QAC enforces MISRA and AUTOSAR rules across your source, which is how most aliasing violations, unsequenced side effects and questionable pointer conversions surface before anything is built. Razorcat TESSY exercises the code that survives, with unit, module and integration tests and MC/DC coverage, which is what turns a rule violation list into demonstrated behaviour. Our guides on MISRA, secure C coding, combining static and dynamic testing and shift-left testing for automotive software go into both in detail.
Whether the compiler and the standard library are entitled to do what they are doing is a different question, and neither of those tools answers it. That is the layer Solid Sands addresses. SuperTest tests a C or C++ compiler against the exact ISO standard version a project targets, with requirements-based, negative and stress tests. Solid Sands describes it as “the world’s most comprehensive C and C++ compiler test and validation suite” and says it has tracked the ISO language specifications for more than 40 years; both are the company’s characterisations, and we attribute them as such. SuperGuard does the equivalent for a C or C++ standard library implementation, breaking the ISO library specification into requirements and tests with traceability and MC/DC coverage, which is how a library that IEC 62304 would otherwise treat as Software of Unknown Provenance gets justified. The SuperTest Qualification Suite packages that work as tool-confidence evidence for a safety assessment.
To say it plainly, because the distinction is easy to blur in marketing and expensive to blur in a project: neither SuperTest nor SuperGuard scans your application source for undefined behaviour. They are the toolchain-trust layer beneath the analysis stack, not a replacement for it.
There is also a part of this that is not a product at all. Deciding which standard version a project targets, which library headers are actually in scope, and what evidence an assessment will ask for is the lawyerly reading Rullkotter describes, done against your codebase rather than a test suite. That is consulting work, and it is where our functional safety engineers spend most of their time on these engagements.
Talk to GSAS About Your Toolchain
GSAS Micro Systems is the India engineering partner for Solid Sands. Our functional safety engineers in Bengaluru, Hyderabad, Chennai, Pune, Mumbai and Delhi NCR work with automotive, aerospace and defence, medical device and semiconductor teams on the parts of this that are genuinely hard: scoping a compiler qualification against the exact ISO standard version a programme targets, justifying the standard library a safety build depends on, planning a toolchain migration so the risk is understood before the rebuild rather than after it, and validating a code generator during bring-up on a new RISC-V or custom target. If you are staring at a compiler upgrade you did not ask for, or an assessor’s question you cannot yet answer with evidence, talk to us.
Sources
- Solid Sands, Objection! Undefined behavior! by Colin Rullkotter, 19 June 2026
- ISO/IEC JTC1/SC22/WG14, C11 committee draft N1570 (clauses 3.4.3, 4, 6.3.2.1, 6.5, 6.5.3.2, Annex J.2)
- ISO/IEC JTC1/SC22/WG21, C++14 working draft N4140, 30.4.1 thread.mutex.requirements, 30.4.1.2 thread.mutex.requirements.mutex and 30.4.1.4.1 thread.sharedtimedmutex.class
- GNU Project, GCC Optimize Options and GCC Code Gen Options
- GNU Project, GCC 4.2 Release Series changes, Porting to GCC 4.9 and GCC Releases index
- Jonathan Corbet, Fun with NULL pointers, part 1, LWN.net, 20 July 2009
- Linux kernel, top-level Makefile, mainline tree (v7.2.0-rc5, retrieved July 2026)
- LLVM Project, Clang UndefinedBehaviorSanitizer documentation
- Solid Sands, SuperTest Compiler Test and Validation Suite
- GSAS Micro Systems, GSAS Partners with Solid Sands to Bring Compiler and Library Qualification to India
Also appears in:
Interested in Solid Sands tools?
Talk to our application engineers for personalized tool recommendations.
More from Solid Sands
View all →