UNIT 02 · LESSON 6 OF 6

Undefined Behavior and Safe Register Access

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?

INTERACTIVEA read-modify-write interrupted halfway
Steps of a read-modify-write on a register with an interrupt writing the same register between the read and the writemain: r0 = REGmain: r0 |= 1IRQ: REG |= 8main: REG = r0register REG03020100main’s copy r00000step 1 of 4
Steps of a read-modify-write on a register with an interrupt writing the same register between the read and the writemain: r0 = REGmain: r0 |= 1IRQ: REG |= 8main: REG = r0register REG03020100main’s copy r00000step 1 of 4

Try this

Steps are spread over a few seconds for reading; real hardware does this in a handful of clock cycles.
Step 1 of 4: read.

Main code sets bit 0 of a shared register with the usual three steps: read, OR, write. If an interrupt fires between the read and the write and sets bit 3 in the same register, main code’s write restores its stale copy and bit 3 is lost. Press play to step through it, with or without the interrupt.

What you will be able to do
  • Define undefined, unspecified and implementation-defined behaviour and give one example of each that matters in firmware.
  • Explain why a compiler may delete a check or a loop that could only matter if undefined behaviour occurred.
  • Identify the five undefined-behaviour cases most common in embedded C: signed overflow, bad shifts, out-of-bounds access, uninitialised reads and aliasing violations.
  • Trace a read-modify-write on a shared register through an interrupt and show where the update is lost.
  • Choose between an atomic set/clear alias, a write-only set/reset register, a critical section and a C11 atomic for a given shared access.
Before you start
  • Unsigned wrap versus signed overflow (lesson 2).
  • Shifts and masks (lesson 1); volatile and its limits (lesson 5).
Steps in this lesson
  1. Three kinds of “it depends”
  2. Why the compiler can delete your check
  3. The five that firmware hits
  4. The register update that loses an interrupt
  5. Four ways to make it safe
  6. Worked example: a shared enable register, done four ways
  7. Common misconceptions

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.

TermMeaningExample
Implementation-definedthe compiler must choose and document the behaviourthe size of int; whether char is signed; converting an integer to a pointer; right-shifting a negative value
Unspecifiedthe compiler may choose, need not document, may choose differently each timethe order in which f() + g() evaluates its operands
Undefinedthe standard imposes no requirements; the program has no meaningsigned 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 nn on a type of width ww is defined only for 0≤n<w0 \le n < w; anything else is undefined. Left-shifting a signed value into or past the sign bit is undefined, so 1≪311 \ll 31 in a 32-bit int is out (the result 2312^{31} is not representable); right-shifting a negative value is implementation-defined (§6.5.7).

INTERACTIVEWhich shifts are defined
A shift expression with its verdict under the C standard, the machine result and the fix1 << 31undefined behaviourstandard1 is a signed int; the result 2³¹ is not representable in int (C11 §6.5.7 ¶4).machinewould be 0x80000000, the int minimum, on most compilersfixwrite 1u << 31 (or UINT32_C(1) << 31)
A shift expression with its verdict under the C standard, the machine result and the fix1 << 31undefined behaviourstandard1 is a signed int; the result 2³¹ is notrepresentable in int (C11 §6.5.7 ¶4).machinewould be 0x80000000, the int minimum, on mostcompilersfixwrite 1u << 31 (or UINT32_C(1) << 31)
Expression
1 << 31: undefined behaviour. 1 is a signed int; the result 2³¹ is not representable in int (C11 §6.5.7 ¶4). Fix: write 1u << 31 (or UINT32_C(1) << 31).

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.

STEP 6

Worked example: a shared enable register, done four ways

Main code must set bit 0 of IRQ_EN at 0x4001_0040 while an ISR may set bit 3 at any time.

Wrong (lost update possible):

IRQ_EN |= (1u << 0);

RP2040 atomic alias (one store, no read):

*(volatile uint32_t *)(0x40010040u + 0x2000u) = (1u << 0);

Dedicated set register (if the map has IRQ_EN_SET at 0x44):

IRQ_EN_SET = (1u << 0);

Critical section (portable, costs interrupt latency):

uint32_t pm = __get_PRIMASK();
__disable_irq();
IRQ_EN |= (1u << 0);
__set_PRIMASK(pm);

All four leave bit 3 alone if the ISR sets it at any moment. The first three do it in one instruction; the last does it by making sure nothing else runs. The alias and the set register are also the fastest, which is why the datasheet built them: the RP2040 text says outright that they are “the best solution” for updating part of a register.

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)
  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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