The puzzle
The firmware checks that a buffer index is in range, and then, under -O2, the check is gone from the binary. The loop that waits for a counter to become negative never exits. The if (p == NULL) after a dereference is deleted. None of these are compiler bugs; each is a program that already contained a line the C standard refuses to give a meaning to. What is the compiler allowed to assume, where does firmware most often trip over it, and what does that have to do with the fact that a one-line REG |= BIT can lose an interrupt?
STEP 1
Three kinds of “it depends”
The C standard (§3.4) sorts everything it does not fully specify into three bins, and the difference between them is the whole lesson.
| Term | Meaning | Example |
|---|---|---|
| Implementation-defined | the compiler must choose and document the behaviour | the size of int; whether char is signed; converting an integer to a pointer; right-shifting a negative value |
| Unspecified | the compiler may choose, need not document, may choose differently each time | the order in which f() + g() evaluates its operands |
| Undefined | the standard imposes no requirements; the program has no meaning | signed overflow; shifting by the type’s width; reading past an array; using an uninitialised variable |
Implementation-defined behaviour is fine to rely on when you know your target; it is how (volatile uint32_t *)0x40021000 works at all. Undefined behaviour is different in kind. It is not “the result is unpredictable”; it is that the program containing it is not a valid C program, and the compiler may assume it never runs.
STEP 2
Why the compiler can delete your check
Optimisers reason from the assumption that undefined behaviour does not occur, because a correct program never triggers it. Regehr’s guide gives the canonical example:
int32_t x = …;
if (x + 1 < x) { /* overflow handler */ }
Signed overflow is undefined, so for a valid program x + 1 < x is always false, so the branch is dead, so it is removed. The check is gone precisely because it was written in terms of the thing it was trying to detect. Similarly, once *p has been executed, p cannot be NULL in a valid program, so a later if (p == NULL) is dead. A loop for (int i = 0; i >= 0; i++) can be compiled as an infinite loop, because i reaching INT_MAX and wrapping would be undefined.
None of this happens at -O0, which is why “it worked in debug” is the usual first symptom. The behaviour is not the compiler being malicious; it is the language being taken at its word.
STEP 3
The five that firmware hits
Signed overflow. Covered in lesson 2. Accumulate in a wider type, or in an unsigned type when wrap is the intent, and check ranges before the operation (CERT INT32-C).
Shifts. A shift count on a type of width is defined only for ; anything else is undefined. Left-shifting a signed value into or past the sign bit is undefined, so in a 32-bit int is out (the result is not representable); right-shifting a negative value is implementation-defined (§6.5.7).
The C standard leaves several shift results undefined or implementation-defined, and compilers rely on that when optimising. Each case shows what the standard says and what the bits would look like on a 32-bit two’s-complement machine; the second column is not a promise.
The cases in the figure are all one-line register idioms that look right. 1 << 31 is the one people write most, and it is also the one where the compiler happens to produce the intended bits today, which is exactly why it survives review.
Out-of-bounds access. Reading or writing past an array is undefined (lesson 3), and on a microcontroller the usual result is silent corruption of the next variable rather than a fault. Bounds must be checked against the length that travels with the buffer.
Uninitialised reads. A local variable that is never assigned has an indeterminate value, and using it is undefined (CERT EXP33-C). On a fresh reset SRAM often contains zeros, so the bug appears only after a warm reset or a stack reuse. Initialise, and turn on -Wall -Wextra, which catches most of these.
Aliasing. Accessing an object through a pointer to an incompatible type is undefined (§6.5 ¶7, the “strict aliasing” rule). *(uint32_t *)float_ptr to inspect a float’s bits is the classic case; GCC at -O2 assumes the float and the uint32_t cannot overlap and may reorder the accesses. memcpy into a uint32_t, or a union, are the sanctioned routes, and -fno-strict-aliasing exists for code that cannot be fixed.
STEP 4
The register update that loses an interrupt
Now the other half of the title. REG |= BIT is not one operation. It compiles to a load, an OR and a store, and between the load and the store the world can change. Suppose main code sets bit 0 of a shared interrupt-enable register while an interrupt handler sets bit 3 of the same register:
↑ This step uses the figure at the top of the page.
Play the sequence. Main loads 0x0, computes 0x1, is interrupted; the handler’s own read-modify-write leaves 0x8 in the register; main resumes and stores its stale 0x1. Bit 3 is gone. Nothing was undefined behaviour here; both pieces of code were correct C in isolation. The hazard is a data race, and it is not a corner case: it will happen the first time the interrupt lands in that two-instruction window, which at thousands of interrupts per second is soon.
volatile does not help (lesson 5): it guarantees the load and store happen, not that nothing happens between them.
STEP 5
Four ways to make it safe
Atomic set/clear aliases. The RP2040 maps every peripheral register three more times: a write to the register’s address + 0x2000 sets the written bits, + 0x3000 clears them, + 0x1000 XORs them, each in a single bus write with no read (datasheet §2.1.2). *(volatile uint32_t *)(REG_ADDR + 0x2000) = BIT; cannot be interrupted halfway because there is no halfway.
Write-only set/reset registers. STM32’s GPIOx_BSRR (RM0090 §8.4.7) is the same idea for one peripheral: one store sets or resets the chosen pins. Many timers and interrupt controllers have separate SET/CLR registers for the same reason. Prefer them whenever the register map offers them.
Critical sections. Where the hardware offers nothing, disable interrupts around the sequence: on Cortex-M, save PRIMASK, __disable_irq(), do the read-modify-write, restore. Keep the section to a few instructions, because every microsecond inside it is latency added to every interrupt.
Language atomics. C11 <stdatomic.h> gives atomic_fetch_or and friends for variables shared with an ISR; on Cortex-M3 and above the compiler implements them with LDREX/STREX loops. They are the right tool for a shared counter or flag in RAM. They are not a substitute for the hardware alias when the object is a peripheral register, because exclusive accesses to peripheral space are generally not supported.
MYTHS AND FACTS
Common misconceptions
Undefined behaviour means the result is unpredictable
It means the program has no meaning, so the compiler may transform it in ways that remove the code you thought was checking for the problem.
It works, so it isn’t undefined
It works at this optimisation level, on this compiler version, with this data. Undefined behaviour is a property of the source, not of a test run.
volatile fixes races
It fixes the compiler’s assumptions about the object, not the window between a load and a store.
|= is one instruction
It is at least three, and an interrupt can land between any of them.
C11 atomics work on registers
They use exclusive-access instructions that peripheral buses typically do not support; use the hardware alias or a critical section for registers, atomics for RAM.
Disabling interrupts is always the safe choice
It is correct and portable, but it adds latency to every interrupt for the duration. Prefer a hardware mechanism when one exists.
Check yourself
Answer in your head, then open the card.
Which is undefined, which is implementation-defined: 1 << 31, (int)0x80000000u, -4 >> 1?
1 << 31: undefined (signed left shift producing an unrepresentable value). (int)0x80000000u: implementation-defined (out-of-range unsigned-to-signed conversion, §6.3.1.3 ¶3). -4 >> 1: implementation-defined (right shift of a negative value).
Why can if (x + 1 < x) never detect signed overflow, and what should replace it?
Overflow is undefined, so for a valid program the condition is always false and the compiler may remove it. Check before the operation: if (x < INT32_MAX) x++; or compute in a wider type.
Main code executes TIMER->IE |= 0x1; and an ISR executes TIMER->IE |= 0x8;. Describe the sequence that loses bit 3 and name two fixes.
Main loads IE (0), the ISR runs and stores 0x8, main resumes and stores 0x1: bit 3 is lost. Fix with an atomic set alias or a dedicated IE_SET register if the chip has one, or wrap the main-code update in a PRIMASK critical section.
A colleague declares a shared volatile uint32_t events; and increments it from both an ISR and the main loop. Is it safe?
No. volatile makes the load and store happen but the increment is still load-add-store. Use atomic_fetch_add from <stdatomic.h>, or disable interrupts around the increment.
Sources (6)
- ISO/IEC 9899:2011 (C11), committee draft N1570, §3.4.1–3.4.4 (definitions of implementation-defined, unspecified and undefined behavior), §6.5 ¶7 (effective type / aliasing), §6.5.7 (shifts), §6.7.9 and §6.3.2.1 ¶2 (indeterminate values), Annex J.2 (list of undefined behaviors) — §3.4.3 defines undefined behavior as behavior for which the standard imposes no requirements; Annex J.2 enumerates more than 190 cases
- John Regehr, “A Guide to Undefined Behavior in C and C++, Part 1” (2010) — the clearest explanation of why compilers are entitled to assume undefined behavior never happens, with real examples of deleted checks
- SEI CERT C Coding Standard, INT32-C (signed overflow), INT34-C (shift counts) and EXP33-C (uninitialized memory) — each rule shows a non-compliant example, its consequence, and a compliant rewrite
- Raspberry Pi Ltd, RP2040 Datasheet (build 2025-02-20), §2.1.2 “Atomic Register Access”, p. 18 — every peripheral register has aliases at +0x1000 (XOR), +0x2000 (bitmask set) and +0x3000 (bitmask clear) so that single bits change without a read-modify-write
- STMicroelectronics, RM0090 Reference manual, §8.4.7 “GPIO port bit set/reset register (GPIOx_BSRR)” — a write-only register that sets or resets individual pins in one store; the same idea as an atomic alias
- GCC manual, “Options That Control Optimization”: -fstrict-aliasing, -fwrapv, -fno-delete-null-pointer-checks, -fsanitize=undefined — switches that either change the compiler’s assumptions or instrument the program to detect undefined behavior at run time