Static Memory Allocation in Embedded C: Patterns, Pitfalls, and Why malloc() Is Banned in Safety-Critical Code

Dynamic allocation is banned in safety-critical embedded code because it trades determinism for flexibility — a bad bargain when worst-case timing and decade-long uptime are certification requirements. This post covers the concrete failure modes of malloc(), the pitfalls that static allocation quietly reintroduces, the patterns that actually hold up in the field, and how Rust, C++, and Ada/SPARK change the calculus.

Introduction

Open the MISRA C:2012 guidelines and you'll find Rule 21.3: malloc, calloc, realloc, and free shall not be used. The same prohibition echoes through the JPL coding standard, the JSF AV C++ rules, and the practical interpretation of IEC 61508, ISO 26262, and DO-178C. This is not conservatism for its own sake. A heap is a stateful, history-dependent subsystem hiding inside your firmware, and on a microcontroller (MCU) with 64 KB of RAM and a watchdog that fires in 50 ms, that hidden state is a liability you cannot afford.

The reflexive answer is "use static allocation instead." That's correct, but incomplete. Static allocation removes the heap's failure modes and replaces them with a different, quieter set. Understanding both halves is what separates firmware that survives a 10-year deployment from firmware that drifts into an unrecoverable fault three months after the warranty expires.

Why Dynamic Allocation Is Banned

The objection to malloc() in safety-critical and hard-real-time contexts rests on four properties of heap allocators:

  • Non-deterministic timing. Allocation cost depends on the current free-list topology. A call that takes 200 cycles today may take 4000 tomorrow. Worst-Case Execution Time (WCET) analysis — mandatory for hard-real-time scheduling — becomes intractable when allocation latency is unbounded.
  • Fragmentation. Repeated allocation and freeing of mixed-size blocks splinters the heap. Eventually a request fails despite sufficient total free memory. On a system that never reboots, fragmentation is a slow-motion guaranteed failure.
  • Failure handling. malloc() returning NULL mid-flight in an engine controller or infusion pump is a question with no good answer. There is no caller higher up that can meaningfully recover.
  • Certifiability. Tools that prove the absence of runtime errors cannot reason about an unbounded, dynamically shaped heap. Banning the heap makes the memory map a static, analyzable artifact.

The crisp version: dynamic allocation moves a class of errors from compile time and link time (where they're cheap) to runtime in the field (where they're catastrophic).

Static Allocation Is Not a Free Pass

Removing the heap does not remove memory bugs — it relocates them. The dangers that static allocation reintroduces:

  • Over-provisioning. Every buffer must be sized for the worst case, permanently. A 4 KB receive buffer used at 5% average occupancy still costs 4 KB of RAM for the life of the product. Static sizing trades runtime risk for a fixed RAM tax.
  • No bounds enforcement. A static array offers exactly zero overflow protection. strcpy() into a static char[64] corrupts adjacent .bss just as eagerly as it would the heap — and now the corruption is in global state shared across the whole program.
  • Global mutable state. Static buffers are typically file- or module-scope globals. They create implicit coupling, break reentrancy, and turn into data races the moment an interrupt service routine (ISR) or RTOS task touches them concurrently.
  • Stack depth. Large automatic arrays and recursion push the stack toward overflow. Without static stack-depth analysis, a deeply nested call path under a rare input can silently smash whatever sits below the stack.
  • Disguised dynamism. A hand-rolled "memory pool" that allocates variable-size blocks reintroduces fragmentation under a different name. The discipline is in what you allocate, not merely in avoiding the word malloc.

Bad: a static buffer with no bounds discipline

static char log_line[80];

void format_log(const char *src) {
    strcpy(log_line, src);   /* overruns log_line into adjacent .bss on long src */
}

This is static allocation and it is still a memory-safety defect. The fixed size lulls the author into ignoring the length of src.

Good: bounded, always terminated, compile-time sized

static char log_line[80];

void format_log(const char *src) {
    /* Truncate to capacity; size is a compile-time constant, so no magic numbers */
    size_t n = strnlen(src, sizeof log_line - 1u);
    memcpy(log_line, src, n);
    log_line[n] = '\0';
}

The buffer is the same; the difference is that the write is provably bounded by sizeof log_line.

Patterns That Actually Hold Up

The workhorse pattern is the fixed-block pool allocator: equal-sized blocks, allocated once at init, handed out and returned in O(1). Equal sizing is the key — it makes fragmentation structurally impossible.

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

#define BLK_SIZE   64u
#define BLK_COUNT  16u

/* Backing storage lives in .bss — reserved at link time, zero runtime cost */
static uint8_t  pool_mem[BLK_COUNT][BLK_SIZE];
static uint16_t free_list[BLK_COUNT];   /* a stack of free block indices */
static size_t   free_top;

void pool_init(void) {
    for (size_t i = 0; i < BLK_COUNT; ++i) free_list[i] = (uint16_t)i;
    free_top = BLK_COUNT;
}

/* O(1), deterministic, never fragments */
void *pool_alloc(void) {
    if (free_top == 0u) return NULL;          /* exhaustion is explicit and testable */
    return pool_mem[free_list[--free_top]];
}

void pool_free(void *p) {
    size_t idx = ((uint8_t (*)[BLK_SIZE])p) - pool_mem;  /* recover block index */
    free_list[free_top++] = (uint16_t)idx;
}

Why this is acceptable where malloc() is not: allocation latency is constant and known, the total footprint (BLK_SIZE * BLK_COUNT) is fixed at link time, fragmentation cannot occur, and exhaustion is a deterministic NULL you can unit-test rather than a probabilistic field failure. Companion patterns — statically sized ring buffers for ISR-to-task hand-off, arena/region allocators reset per processing cycle, and static_assert(sizeof pool_mem <= AVAILABLE_RAM, ...) to fail the build rather than the device — round out the toolkit.

Beyond C: What Other Languages Offer

C gives you no help enforcing any of this; the discipline lives entirely in reviews, MISRA checkers, and static analyzers bolted on after the fact. Other languages move parts of that discipline into the type system.

Language Mechanism Embedded relevance Caveat
C++ (embedded subset) RAII, constexpr, std::array, templates, ETL Compile-time sizing, scope-bound resources, no manual cleanup Must disable exceptions/RTTI; templates can bloat flash
Rust (#![no_std]) Ownership + borrow checker, heapless, const generics Use-after-free and data races become compile errors unsafe still needed at the HW boundary
Ada / SPARK Strong typing, contracts, Ravenscar profile, formal proof Decades of avionics/rail certification; provable absence of runtime errors Smaller talent pool, steeper toolchain

Rust is the most disruptive of the three because it enforces the exact properties C engineers spend review cycles policing — no aliasing of mutable state, no use-after-free, no out-of-bounds access — at compile time, with no runtime cost.

Rust: capacity in the type, overflow as a Result

#![no_std]
use heapless::Vec;              // fixed-capacity collection, no heap

// Capacity (64) is part of the type — exhaustion cannot be ignored
let mut samples: Vec<u16, 64> = Vec::new();
if samples.push(adc_read()).is_err() {
    // buffer full: a handled condition, never silent UB
}

The C pool above achieves determinism through discipline; the Rust version achieves it through the type system, so the compiler — not a reviewer — rejects the unsafe variant.

Conclusion

The rules are durable: in safety-critical or hard-real-time code, ban malloc() and friends, allocate everything at init, prefer equal-sized pools and statically bounded buffers, and treat fixed sizing not as a limitation but as the property that makes the system analyzable. Just as importantly, don't mistake "static" for "safe" — bounds discipline, reentrancy, and stack-depth analysis still demand attention, because the buffer that can't be leaked can still be overrun.

When this approach fits: anything with WCET requirements, certification obligations, long unattended uptime, or single-digit-KB RAM budgets. When it doesn't: allocation-heavy workloads on resource-rich Linux-class edge devices, where a vetted allocator and an OS memory manager are reasonable — the heap ban is a property of the constraint envelope, not of embedded systems universally.

Does C still have a place? Yes — but increasingly as the incumbent rather than the default. C's value was never that it prevents these bugs; it's that it gets out of the way and runs everywhere, with a mature toolchain on every MCU in existence. Rust delivers the same zero-cost, no-runtime model while making whole bug classes unrepresentable, and with qualified toolchains (e.g., Ferrocene for ISO 26262) the certification gap is closing. For a greenfield safety-critical project today, Rust is a serious default and C is the pragmatic choice dictated by toolchain support, existing codebases, and team expertise. The honest verdict: C is not obsolete, but "we use C because it's safe" was always the wrong reason — and that reason no longer survives contact with the alternatives.

Return to Post List