C Undefined Behavior Catalog: What Actually Happens on ARM Cortex-M
Signed integer overflow, strict aliasing violations, use-after-free — on Cortex-M with GCC 12, here is what the compiler actually generates. Not theoretical.

Undefined behavior in C on ARM Cortex-M is not a language lawyer exercise — with GCC 12.2 (arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2), specific UB patterns produce wrong code that passes unit tests. Catalog from our STM32H743 and nRF52840 bring-up.
Test harness
- MCU: STM32H743ZI, nRF52840
- Compiler: GCC 12.2.1,
-std=c11 -O2 -mcpu=cortex-m4 -mthumb - Disassembly:
arm-none-eabi-objdump -d - Optional:
-fsanitize=undefinedon host shim builds (not on-target)
UB #1 — Signed integer overflow
int32_t acc = INT_MAX;
acc += user_delta; /* UB if signed overflow */
On Cortex-M4 GCC 12 -O2: compiler assumes overflow never happens; elides defensive checks you "added later":
if (acc < 0) handle_error(); /* may be optimized away */
Fix: -fwrapv (performance cost), or cast to int64_t before add, or use unsigned with explicit range checks.
Observed: IMU fusion filter — rare overflow on temperature compensation path; optimized out clamp; drone pitch glitch 1/800 flights.
UB #2 — Strict aliasing violation
uint32_t read_u32(const uint8_t *p) {
return *(uint32_t *)p; /* UB if unaligned on M4 — also aliasing */
}
Generated code: may use ldr word load on ARM — HardFault on unaligned pointer on M4 (unaligned access configurable on M7).
Even aligned: type punning through incompatible pointer violates strict aliasing; compiler may reorder loads/stores across uint8_t buffer writes.
Fix: memcpy(&out, p, 4); — optimizes to register load on -O2 when aligned. Or __attribute__((may_alias)) / -fno-strict-aliasing (whole-file sledgehammer).
UB #3 — Use-after-free (interrupt context)
void isr(void) {
free(rx_buffer); /* buffer still referenced by main */
}
Not theoretical: DMA RX complete ISR fired while main held pointer; GCC reused register — silent corruption.
Fix: static pools, flag handoff, no free in ISR. Rust ownership catches at compile time — see Rust ownership patterns.
UB #4 — Shift by width or negative
uint32_t x = 1u << shift; /* UB if shift >= 32 */
GCC 12: may generate single lsls — unpredictable when shift==32 (often 0, not architecture guarantee in C abstract machine).
Fix: if (shift >= 32) ... before shift.
UB #5 — Null pointer dereference
C allows free(NULL); does not allow *p when p is null even if "you know" it won't run:
if (p) use(*p);
else use(*p); /* unreachable — compiler may assume p non-null in branch */
LTO across files broke "assert then deref" pattern when assert compiled out in -DNDEBUG release.
Fix: if (!p) return; then deref; no else path.
UB #6 — Data race (volatile misuse)
volatile int flag;
/* main and ISR both increment without atomic */
flag++;
Not C11 atomic — UB. Generates non-atomic load-add-store; lost updates on Cortex-M.
Fix: stdatomic.h or critical section disable IRQ for non-atomic peripheral regs use volatile only for hardware registers, not cross-thread flags.
Reading the assembly (example)
Signed overflow clamp "after" add — -O2:
adds r0, r1, r2
/* no branch to error — compiler deleted check */
Force audit: -Wstrict-overflow -fstrict-overflow (default at -O2).
Compiler flags we enforce
CFLAGS += -Wall -Wextra -Werror=implicit-function-declaration
CFLAGS += -fno-strict-aliasing # legacy codebase only — new code uses memcpy
CFLAGS += -U_FORTIFY_SOURCE # careful on embedded libc
New modules: no -fno-strict-aliasing; review aliasing.
UB #7 — Uninitialized read after partial I/O
struct pkt hdr;
ssize_t n = read(fd, &hdr, sizeof hdr);
if (n > 0 && hdr.magic == 0xDEAD) ... /* UB if n < sizeof hdr */
Reading fields beyond bytes written is UB even if you "know" magic is first. Zero buffer first or validate n == sizeof hdr.
GCC 12 with -O2 may assume magic always valid if prior code path wrote full struct — partial read paths break silently.
UB #8 — Packed struct alignment
struct __attribute__((packed)) { uint32_t a; uint8_t b; } s;
uint32_t *p = &s.a; /* UB if &s.a misaligned on M0+ */
Use memcpy to aligned local. -Waddress-of-packed-member helps; treat as error in CI.
Static analysis toolchain
We run cppcheck --enable=all on HAL commits and -fanalyzer on GCC 12 for new drivers — caught one UAF before flash. Not a substitute for reading assembly on hot paths; complements it.
Sanitizers on host-side protocol parsers that share structs with firmware — find UB before ARM deploy.
We archive objdump snippets for each UB class in docs/embedded/ub-gallery/ — onboarding read for anyone touching motor control or DMA drivers.
When GCC release notes mention optimizer changes, re-run ub-gallery tests on -O2 before toolchain bump — 12.1 → 12.2 shifted one strict-aliasing assumption in our HAL.
-Wundefined on host builds flags obvious UB patterns early; not available for all cross-compile targets but worth nightly CI on protocol stack.
Comparison to Rust no_std
Hardware DMA ownership — rust no_std DMA eliminates some ISR lifetime UB at cost of compile time.
What I'd do next
Run CBMC or clang static analyzer on safety-critical modules (motor control) — schedule 2 days per quarter.
Document peripheral register access patterns in HAL — only place volatile belongs.
Migrate hot paths to -std=c17 and _Static_assert on struct layouts shared with DMA — catch alignment UB at compile time.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

