Hardware, IoT & Embedded · Embedded C

Embedded C Pitfalls: Undefined Behavior and Memory Traps

Write safer firmware by avoiding classic footguns.

Reading time: ~8–12 min
Level: All levels
Updated:

Embedded C is fast, portable, and unforgiving. A single off-by-one, a signed overflow, or a stale pointer can turn into “random” resets that only happen in the field. This guide walks through the most common Embedded C pitfalls— especially undefined behavior and memory traps—and shows concrete habits that make firmware safer without slowing you down.


Quickstart

If you only do a few things, do these. They’re the fastest way to remove the “mystery bug” class of failures: hard faults, silent corruption, and timing-dependent heisenbugs.

1) Make the compiler your first reviewer

Most undefined behavior and memory bugs start as warnings. Treat warnings as a broken build and you’ll prevent a surprising amount of production pain.

  • Enable aggressive warnings (-Wall -Wextra -Wshadow -Wconversion)
  • Fail the build on warnings (-Werror)
  • Compile for debug often (-Og -g3) and keep symbols
  • Run static analysis (clang-tidy / cppcheck) in CI

2) Guard your memory: stack, buffers, and lifetimes

Firmware rarely “crashes politely”. Add lightweight guards so corruption is caught early and reproducibly.

  • Add stack high-watermark / canary checking
  • Prefer bounded APIs (memcpy with explicit lengths, not strcpy)
  • Initialize everything: locals, structs, and DMA buffers
  • Audit pointer lifetimes: never return pointers to stack storage

3) Test tricky code on your PC with sanitizers

Even if your MCU can’t run sanitizers, your logic can. Reproduce UB and overflows on a host build in seconds.

  • Build a “host” target (same C files, different toolchain)
  • Run AddressSanitizer / UndefinedBehaviorSanitizer on unit tests
  • Replay fuzzed inputs for parsers/protocol code
  • Keep the MCU build and host build aligned (same configs)

4) Add one “rule of reality” review per release

Before shipping, do a short review focused only on footguns: integer math, arrays, concurrency, and memory layout.

  • Search for suspicious patterns: sizeof(ptr), casts, volatile, memcpy into structs
  • Review ISR-shared state: atomicity, reentrancy, and ordering
  • Confirm stack and heap budgets with a linker map
  • Run a “brownout” / reset test if you depend on NVM writes
A simple mental model

In embedded, most “weird behavior” is either memory corruption or undefined behavior. The goal is to make both loud: fail fast in dev, not silently in the field.

Overview

C gives you direct control over memory and hardware—but the language also leaves room for behaviors that are not defined by the standard. On microcontrollers, those edge cases show up as hard faults, corrupted peripherals, or timing glitches that disappear when you add a printf.

What you’ll learn in this post

  • What undefined / unspecified / implementation-defined behavior means in practice
  • Why memory bugs look random (and how to make them deterministic)
  • The most common Embedded C pitfalls: integers, pointers, alignment, and concurrency
  • A workflow: compiler flags → static analysis → runtime guards → host tests → release checks

This isn’t a “write MISRA-compliant code everywhere” manifesto. It’s a pragmatic guide: keep code fast and small, but remove the common footguns that waste entire evenings.

Core concepts

1) Undefined vs unspecified vs implementation-defined

The C standard uses different categories for “the compiler can do different things here”: understanding the difference helps you decide when a warning is harmless vs dangerous.

Term What it means Typical embedded symptom
Undefined behavior (UB) The standard imposes no requirements; the compiler may assume it never happens Optimized builds “break”, rare resets, corrupted state
Unspecified behavior Several outcomes are allowed; you can’t rely on which one Different results across compilers/flags
Implementation-defined Compiler/architecture defines it (documented); still not portable Porting issues: signedness, shift behavior, type sizes
Why UB is worse than “a crash”

UB lets the optimizer transform your code under the assumption that UB never happens. That means the failure might not be near the bug—and adding a log line can “fix” it by changing timing or layout.

2) Embedded memory is not “one flat RAM”

Microcontrollers often have multiple memory regions with different rules: flash vs SRAM, DMA-accessible RAM, cacheable vs non-cacheable, tightly-coupled memory, and memory-mapped peripherals. Bugs happen when code assumes desktop-like behavior.

Key terms you’ll use daily

  • Stack: automatic storage; fast; limited; easy to overflow
  • Heap: dynamic allocation; flexible; fragmentation risk
  • Static storage: globals and static; predictable lifetime
  • MMIO: memory-mapped I/O registers; requires volatile

Two mistakes that cause “randomness”

  • Stack overflow that corrupts a return address or saved registers
  • DMA writing into a buffer the CPU thinks is normal cached RAM

3) Integers, promotions, and overflow

In Embedded C, integer bugs are common because we mix bit operations, register fields, protocol parsing, and different-width types. Two rules help:

  • Signed overflow is UB. If you might overflow, use a wider type or explicit saturating logic.
  • Small integer types promote. uint8_t and int16_t often promote to int in expressions.

Practical guidance

  • Use size_t for sizes/indices; uint32_t/uint64_t for counters that grow
  • Cast once, at the boundary (e.g., when reading a register), not repeatedly inside expressions
  • Prefer named constants and masks to “magic shifts”
  • Keep math in an unsigned/wider domain, then clamp to the destination type

4) Concurrency: interrupts are threads with sharp edges

Many firmware bugs are race conditions between main code and ISRs (or between tasks in an RTOS). volatile helps with visibility to the compiler, but it does not make multi-byte updates atomic, and it does not create safe ordering by itself.

Simple rule of thumb

If a variable is written in an ISR and read in main, decide: is it atomic on your MCU, and do you need a critical section or message queue instead?

Step-by-step

Here’s a workflow that catches the majority of embedded C pitfalls early. You can adopt it incrementally: start with flags and guards, then add the stronger checks as your firmware grows.

Step 1 — Harden your build: warnings, LTO awareness, and debug settings

The compiler is your best bug detector. Make it strict, and keep a separate “host test” build so you can use sanitizers. The goal is not to enable every flag forever, but to have a default that makes risky code uncomfortable to write.

# Toolchain-hardening starter (adapt paths/MCU flags to your project)

# 1) MCU build (arm-none-eabi-gcc example)
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -ffunction-sections -fdata-sections \
  -Wall -Wextra -Werror -Wshadow -Wconversion -Wsign-conversion -Wformat=2 \
  -Wnull-dereference -Wdouble-promotion -Wundef \
  -fno-common -fno-strict-aliasing \
  -Og -g3 \
  -c src/main.c -o build/main.o

arm-none-eabi-gcc -Tlinker.ld -Wl,--gc-sections -Wl,-Map=build/firmware.map \
  build/main.o -o build/firmware.elf

arm-none-eabi-size build/firmware.elf

# 2) Host build for tests (clang + sanitizers)
# Useful for parsers/protocol/state machines that run on both MCU and PC.
clang -std=c11 -O1 -g \
  -fsanitize=address,undefined -fno-omit-frame-pointer \
  -Wall -Wextra -Werror \
  tests/test_protocol.c src/protocol.c -o build/test_protocol

./build/test_protocol
Don’t “silence warnings” with casts

A cast that makes the warning disappear can also hide a real truncation or sign bug. Prefer to fix the type at the source: change the variable type, widen the expression, or clamp explicitly before narrowing.

Step 2 — Know your memory budgets: stack, heap, and static

When memory is tight, “it works on my board” can be misleading. A different compile flag, a new ISR, or a larger packet can change stack usage and trigger corruption. Treat memory like a budget you measure, not a guess you hope is fine.

Stack protection you can add today

  • Place a canary pattern at the end of the stack region
  • Check the high-water mark periodically (or in the idle task)
  • Avoid large local arrays; prefer static buffers or caller-provided buffers
  • Be wary of deep recursion (often accidental via callbacks)

Heap rules (if you use it at all)

  • Allocate only at startup; avoid allocate/free in steady state
  • Use fixed-size pools for predictable fragmentation behavior
  • Fail loudly on allocation failure (don’t continue half-initialized)
  • Consider “no heap” for safety-critical code

Step 3 — Write bounds-first code for buffers and protocol parsing

Many embedded systems parse bytes: UART frames, SPI registers, BLE packets, custom protocols. Parsing is a hotspot for buffer overflows, alignment issues, and endianness mistakes. A good pattern is: check bounds, then parse. Don’t cast raw bytes into structs unless you control packing and alignment (and even then, be cautious).

Example: safe parsing with explicit bounds and endianness

This pattern avoids common Embedded C pitfalls: out-of-bounds reads, alignment assumptions, and silent narrowing. It uses helper functions that fail cleanly instead of “best-effort parsing”.

#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>

typedef struct {
  uint16_t msg_type;
  uint32_t value;
} msg_t;

static bool read_u16_be(const uint8_t *buf, size_t len, size_t *off, uint16_t *out) {
  if (*off + 2u > len) return false;
  uint16_t hi = (uint16_t)buf[*off + 0u];
  uint16_t lo = (uint16_t)buf[*off + 1u];
  *out = (uint16_t)((hi << 8) | lo);
  *off += 2u;
  return true;
}

static bool read_u32_be(const uint8_t *buf, size_t len, size_t *off, uint32_t *out) {
  if (*off + 4u > len) return false;
  uint32_t b0 = (uint32_t)buf[*off + 0u];
  uint32_t b1 = (uint32_t)buf[*off + 1u];
  uint32_t b2 = (uint32_t)buf[*off + 2u];
  uint32_t b3 = (uint32_t)buf[*off + 3u];
  *out = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
  *off += 4u;
  return true;
}

bool parse_msg(const uint8_t *buf, size_t len, msg_t *out) {
  if (buf == NULL || out == NULL) return false;

  size_t off = 0u;
  uint16_t type = 0u;
  uint32_t val  = 0u;

  if (!read_u16_be(buf, len, &off, &type)) return false;
  if (!read_u32_be(buf, len, &off, &val))  return false;

  out->msg_type = type;
  out->value = val;
  return true;
}
Why this works well in embedded

You get deterministic behavior: invalid inputs fail fast, and the code never “walks off” a buffer. This makes fuzzing and host tests extremely effective for protocol code.

Step 4 — Treat MMIO registers differently than RAM

Peripheral registers are not normal variables. Reads can have side effects (e.g., clearing an interrupt flag), and writes may require specific ordering. Use volatile for MMIO access, but avoid scattering raw register math across the codebase. Wrap it with small, readable helpers.

MMIO checklist

  • Use volatile for register definitions (MMIO), not for normal shared state by default
  • Prefer read-modify-write helpers with masks to avoid bitfield mistakes
  • Be careful with “write 1 to clear” semantics (W1C) and reserved bits
  • Document ordering requirements (e.g., write CTRL then write DATA)

Step 5 — Automate memory and quality checks in CI

You don’t need heavy process, but you do need consistency. Two cheap checks go a long way: (1) fail builds on warnings and (2) fail builds when memory budgets are exceeded. Linker map files are perfect for this because they reflect real layout after linking.

Example: parse a linker map and enforce RAM/flash budgets

This script is intentionally simple: it scans the output of arm-none-eabi-size (or your toolchain’s size) and fails if the build exceeds a budget. Plug it into CI as a gate.

#!/usr/bin/env python3
import re
import subprocess
import sys

# Adjust budgets to your target (bytes)
FLASH_BUDGET = 512 * 1024   # 512 KiB
RAM_BUDGET   = 128 * 1024   # 128 KiB

ELF = sys.argv[1] if len(sys.argv) > 1 else "build/firmware.elf"

def run_size(elf_path: str) -> str:
  # GNU size output typically: text data bss dec hex filename
  out = subprocess.check_output(["arm-none-eabi-size", elf_path], text=True)
  return out.strip()

def parse_size(output: str):
  lines = output.splitlines()
  if len(lines) < 2:
    raise ValueError("Unexpected size output")
  cols = re.split(r"\s+", lines[1].strip())
  if len(cols) < 4:
    raise ValueError("Unexpected size columns")
  text = int(cols[0])
  data = int(cols[1])
  bss  = int(cols[2])
  flash = text + data
  ram   = data + bss
  return flash, ram, text, data, bss

def main():
  try:
    out = run_size(ELF)
    flash, ram, text, data, bss = parse_size(out)
  except Exception as e:
    print(f"[budget] ERROR: {e}")
    return 2

  print(f"[budget] ELF: {ELF}")
  print(f"[budget] FLASH (text+data): {flash} bytes (budget {FLASH_BUDGET})")
  print(f"[budget] RAM   (data+bss):   {ram} bytes (budget {RAM_BUDGET})")

  ok = True
  if flash > FLASH_BUDGET:
    print("[budget] FAIL: flash budget exceeded")
    ok = False
  if ram > RAM_BUDGET:
    print("[budget] FAIL: ram budget exceeded")
    ok = False

  return 0 if ok else 1

if __name__ == "__main__":
  raise SystemExit(main())

If your toolchain doesn’t provide arm-none-eabi-size, use your platform’s equivalent or parse the map file directly. The key idea is the same: make memory regressions impossible to “accidentally ship”.

Common mistakes

These are the Embedded C pitfalls that show up again and again in code reviews and field failures. Each item includes a concrete fix you can apply today.

Mistake 1 — Relying on signed overflow or “wraparound”

In C, signed overflow is undefined behavior. Optimized builds can transform your code as if overflow never happens.

  • Fix: use wider unsigned types for counters; clamp before narrowing; add explicit saturation.
  • Fix: enable -Wconversion and investigate narrowing warnings.

Mistake 2 — sizeof on the wrong thing

sizeof(ptr) is the size of the pointer, not the array. This causes partial copies and silent truncation bugs.

  • Fix: pass len explicitly; use sizeof(array) only in the same scope as the array.
  • Fix: prefer helper macros/functions that take arrays by reference where possible.

Mistake 3 — Casting bytes to structs (alignment + packing trap)

A uint8_t* buffer may not be aligned for a struct. On many MCUs, unaligned access hard faults.

  • Fix: parse field-by-field (bounds-first) or use memcpy into aligned storage.
  • Fix: handle endianness explicitly; don’t assume it matches your protocol.

Mistake 4 — Misusing volatile as a “threading primitive”

volatile prevents certain compiler optimizations, but it does not make updates atomic or create safe ordering.

  • Fix: protect shared state with critical sections, atomics (where supported), or message passing.
  • Fix: keep ISR-shared variables small and design a clear ownership model.

Mistake 5 — Uninitialized locals and padding bytes

Uninitialized stack data can leak into logic and protocol packets. Struct padding can contain garbage too.

  • Fix: initialize structs with {0} or dedicated init functions.
  • Fix: clear buffers that go over the wire (especially before CRC/signing).

Mistake 6 — Stack blow-ups from “small” changes

A new local buffer in a deep call chain, or a larger printf, can push you over the limit.

  • Fix: track stack high-water mark; avoid large locals; use static/pooled buffers.
  • Fix: keep formatting minimal; prefer fixed-size logging and ring buffers.
A debugging shortcut

If a bug disappears with extra logging, suspect UB, races, or memory corruption. Reduce optimization (-Og), enable strict warnings, and reproduce on a host build with sanitizers.

FAQ

What’s the simplest definition of undefined behavior in embedded C?

Undefined behavior means the C standard doesn’t define what happens, and the compiler is free to assume it never occurs. In embedded systems, that often looks like code that works in debug but breaks in release, or failures far away from the bug.

Does volatile make code safe across interrupts or RTOS tasks?

No. Volatile affects optimization, not correctness. It can help ensure a variable is re-read from memory, which is necessary for MMIO registers and some ISR-shared flags, but it does not make multi-byte updates atomic and it does not replace critical sections, atomics, or message passing.

Should I avoid dynamic allocation (malloc) in firmware?

For many systems, yes—or at least restrict it. If you must allocate, allocate at startup and avoid allocate/free during steady state. Fragmentation and failure handling are where embedded systems tend to break.

How do I prevent stack overflow on a microcontroller?

Measure and guard. Add a stack canary/high-water mark, keep large buffers out of locals, and watch deep call chains (especially with callbacks and logging). If you run an RTOS, measure per-task stacks and tune with real workloads.

When should I disable strict aliasing?

If your codebase (or third-party code) relies on type-punning that violates aliasing rules, the safest option is to fix the code (use memcpy or well-defined access patterns). If that’s not realistic immediately, -fno-strict-aliasing is a pragmatic mitigation to reduce optimizer-induced miscompilations.

What’s the fastest way to catch memory bugs without fancy tools on the MCU?

Add lightweight checks: stack canaries, guard bytes around critical buffers, assert invariants, and log reset reasons. Combine that with a host test build using sanitizers for code that can run on a PC (parsers, state machines, math).

Cheatsheet

Use this as a pre-merge checklist and a “why is this firmware weird?” triage list.

Embedded C pitfalls checklist (scan-fast)

Do

  • Turn on warnings and make them fatal (-Wall -Wextra -Werror)
  • Use size_t for sizes/indices; clamp before narrowing
  • Bounds-first parsing and explicit endianness conversions
  • Initialize structs and buffers; clear padding-sensitive outbound data
  • Guard stack usage (canary/high-water mark) and track memory budgets
  • Use host builds with sanitizers for testable logic
  • Wrap MMIO register access with small helpers; document semantics

Avoid

  • Signed overflow, shifting into sign bits, or relying on “wraparound”
  • Using volatile as a concurrency solution
  • Casting byte buffers to structs (alignment/packing hazards)
  • Unbounded string functions (strcpy, sprintf)
  • Returning pointers to local (stack) variables
  • Ignoring static analysis because “it compiles”
  • Alloc/free in a tight loop on an embedded heap

Triage: when you see resets or hard faults

  • Check stack usage: high-water mark, recent large locals, recursion/callback chains
  • Suspect OOB writes: buffers, ring buffers, DMA descriptors, ISR queues
  • Look for UB patterns: signed overflow, invalid shifts, aliasing, uninitialized reads
  • Verify MMIO semantics: W1C bits, reserved bits, ordering requirements
  • Compare debug vs release: optimization level, LTO, different inlining decisions

Wrap-up

The fastest way to write safer firmware is not “more testing” in the abstract—it’s removing the bug classes that create non-determinism: undefined behavior, silent truncation, and memory corruption. Start with strict builds, then add a small set of runtime guards and host sanitizer tests. You’ll spend less time chasing ghosts and more time shipping features.

Your next actions (15 minutes)
  • Add strict warnings + -Werror to your firmware build
  • Pick one high-risk module (protocol parsing or drivers) and add bounds-first checks
  • Create a host test target and run UBSan/ASan on unit tests
  • Record memory budgets (flash/RAM/stack) and enforce them in CI

If you want to go deeper, the “Related posts” section below pairs nicely with this topic: communication protocols, OTA updates, and clean wiring all benefit from the same “make failures loud” mindset.

Quiz

Quick self-check (demo). This quiz is auto-generated for hardware / iot / embedded.

1) Which statement best describes undefined behavior (UB) in Embedded C?
2) What is volatile primarily for in embedded systems?
3) Why is casting a byte buffer to a struct risky on many MCUs?
4) Which approach best prevents “random” failures caused by stack overflow?
5) What’s a practical way to catch UB and memory bugs when the MCU can’t run sanitizers?