Skip to main content
An ISO language standard open beside an embedded development board and debug probe, the toolchain qualification work GSAS Micro Systems supports in India

Undefined Behaviour in Embedded C and C++

GSAS Engineering · · 16 min read

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.

ClassWhat the standard saysClause (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 readUndefined 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 divisionAnnex 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-aliasing is 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-aliasing option is enabled at levels -O2, -O3, -Os.” If you ship at -O2, you have already opted in.
  • -fdelete-null-pointer-checks is 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-dereference detects 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 -O2 and 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

Interested in Solid Sands tools?

Talk to our application engineers for personalized tool recommendations.

Frequently asked questions

What is undefined behaviour in C and C++?
ISO/IEC 9899 defines undefined behaviour as behaviour, upon use of a nonportable or erroneous program construct or of erroneous data, for which the standard imposes no requirements. The standard's own note says the outcome can range from ignoring the situation completely with unpredictable results, through behaving in a documented manner characteristic of the environment, to terminating translation or execution with a diagnostic. It is not a promise of a crash and not a promise of a warning.
Why does code with undefined behaviour still pass all my tests?
Because a conforming implementation is free to do the convenient thing today. Undefined behaviour is a property of the program text, not of the run you observed. The C standard signals it three ways: an explicit 'undefined behavior', a violated 'shall' outside a constraint, and the plain omission of any definition of behaviour, with no difference in emphasis between them. A passing test tells you what one build of one compiler at one optimisation level did once.
Can a compiler upgrade change the behaviour of code I never edited?
Yes, and the vendors document it. GCC 4.2 (13 May 2007) turned -fstrict-overflow on by default at -O2, and its release notes give the example of a loop that was presumably intended to run until a signed variable overflows being transformed into an infinite loop. GCC 4.9 (22 April 2014) shipped a porting note headed 'Null pointer checks may be optimized away more aggressively', with a worked example where a null check after a memmove call is removed. In both cases the source did not change, the compiler's assumptions did.
Is signed integer overflow undefined behaviour in C?
Yes. ISO/IEC 9899 clause 6.5 paragraph 5 states that if an exceptional condition occurs during the evaluation of an expression, meaning the result is not mathematically defined or not in the range of representable values for its type, the behaviour is undefined. The standard's own example attached to the definition of undefined behaviour is the behaviour on integer overflow. Unsigned arithmetic is different: it is defined to wrap.
Does SuperTest or SuperGuard find undefined behaviour in my application code?
No, and it is important to be precise about this. SuperTest qualifies a C or C++ compiler against the ISO language standard, and SuperGuard qualifies a C or C++ standard library implementation. Neither scans your application source. Undefined behaviour in the code you write is a static analysis and dynamic test problem, addressed by tools such as Perforce Helix QAC and Razorcat TESSY. Solid Sands addresses the layer underneath: whether the compiler and library you trust actually conform to the standard version your project targets.
Why does undefined behaviour matter more on a new RISC-V or custom ISA target?
On a mature target, teams lean on institutional memory about how a given toolchain behaves. On a new backend there is no such folklore. An immature code generator may do the naive thing today and start exploiting signed-overflow or aliasing assumptions once the optimiser matures, so conformance evidence has to substitute for accumulated experience. That is why compiler bring-up and code generator validation matter to Indian teams working on custom silicon.
How do I show a safety assessor that my compiler is trustworthy?
Functional safety standards treat the compiler and the standard library as development tools whose output ends up in the shipped executable, so their behaviour has to be verified rather than assumed. In practice that means conformance evidence against the exact ISO standard version the project targets, with traceability from language requirements to tests and results. GSAS functional safety engineers in Bengaluru, Hyderabad, Chennai, Pune, Mumbai and Delhi NCR work through that scoping with Indian teams.

Stay in the Loop

Get monthly compliance updates, product insights, and engineering best practices delivered to your inbox.