Static Memory Allocation in Embedded C: Patterns, Pitfalls, and Why malloc() Is Banned in Safety-Critical Code
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()returningNULLmid-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 astatic char[64]corrupts adjacent.bssjust 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.