Embedded C
Dynamic Memory: malloc, calloc, realloc, free
Dynamic allocation is the default tool on a desktop and the tool many embedded/safety-critical codebases restrict or ban outright — not from tradition, but because a heap on a device with a few kilobytes of RAM and no OS to reclaim memory fails in ways a desktop application never has to think about. Understanding exactly what each function does, and exactly how a heap fails, is what makes "we don't use malloc here" a design decision instead of a superstition.
- What it is
- Runtime-managed memory requested from the heap with malloc/calloc/realloc and returned with free.
- Mental model
- The heap is a shared parking lot with no attendant — you have to remember which spot you parked in, never park somewhere you already left, and never assume a spot is still yours after you've given up the ticket.
- Key numbers
- 4 classic bug categories (leak, double-free, use-after-free, overflow) account for the overwhelming majority of real-world heap bugs.
- Reach for it when
- Almost never after initialization on a hard-real-time or safety-critical embedded target — reach for a static array or a fixed-size memory pool instead, and reserve this page for understanding why.
malloc
malloc(size) requests size bytes of
uninitialized memory from the heap, returning a
void* to it — or NULL if the request
can't be satisfied. On a device with a heap measured in kilobytes,
failure is a realistic outcome that has to be handled, not a
theoretical edge case a desktop program can shrug off.
uint8_t *buf = malloc(64);
if (buf == NULL) {
handle_allocation_failure(); // this branch is not optional here
return;
}
calloc
calloc(count, size) allocates count * size
bytes and zero-initializes them — and, unlike doing
the multiplication yourself, does it with an internal overflow check.
malloc(count * size) can silently overflow the
multiplication on a platform with a narrow size_t, handing
back a successful allocation that's far smaller than intended;
calloc is specified to detect that and fail instead.
struct sensor_reading *readings = calloc(num_sensors, sizeof(*readings));
// every field of every element starts at zero — no manual memset needed
realloc
realloc(ptr, new_size) resizes a previous allocation —
possibly by moving it to a new address entirely, in which case it
copies the old contents and the original pointer becomes invalid. The
classic mistake overwrites the only reference to the original block
with realloc's return value before checking it succeeded:
/* WRONG — leaks the original block on failure */
ptr = realloc(ptr, bigger_size); // if this returns NULL, the old
// block's address is gone forever
/* correct */
void *tmp = realloc(ptr, bigger_size);
if (tmp == NULL) {
handle_allocation_failure(); // ptr is still valid here — free it if done
} else {
ptr = tmp;
}
Heap fragmentation
A heap doesn't fail only when it's genuinely full — it fails when enough contiguous space doesn't exist, even though the total free memory would be plenty. This is fragmentation, and it's the specific reason a long-running device can start failing allocations after weeks of uptime despite never leaking a single byte.
The four classic bugs
- Memory leak
- Losing every pointer to an allocated block without calling
free()— the memory is unreachable but the allocator still considers it in use. On a device with no process model to reclaim memory when it exits, a leak means the device slowly runs out until it's reset. - Double free
- Calling
free()on the same pointer twice — corrupts the allocator's internal bookkeeping, often not crashing at the point of the mistake but at some unrelated later allocation. - Use-after-free
- Dereferencing a pointer after its memory has been freed — the memory may already belong to a different, unrelated allocation, so the bug's symptoms depend entirely on what else happens to run next.
- Heap buffer overflow
- Writing past the end of an allocated block corrupts whatever comes after it in the heap — often another allocation's data or the allocator's own metadata, producing a crash far from the actual bug.
Why embedded code avoids malloc
- Non-deterministic timing — searching for a suitable free block can take variable time depending on heap state, unacceptable when code has to meet a hard real-time deadline (see RTOS).
- Fragmentation over a long lifetime — exactly the failure mode diagrammed above — a device running for months can fail an allocation despite having enough total free memory.
- No OS safety net — a desktop process's leaked memory is reclaimed the moment it exits; an embedded system usually has no concept of a process exiting at all, so a leak simply accumulates until the device fails.
- Coding-standard restrictions — MISRA-C and similar safety-critical coding standards restrict or ban dynamic allocation after initialization specifically for the reasons above.
Alternatives: static allocation and memory pools
The simplest alternative is a compile-time-sized array — its memory
footprint is fixed and known before the program ever runs. When
something genuinely needs to be allocated and released at runtime, a
memory pool (fixed-size block allocator) is the
standard embedded answer: pre-allocate N fixed-size
blocks once at startup, and "allocate"/"free" become O(1) operations
that just mark a pool slot used or free — no search, no
fragmentation, because every block is the same size. A pool's free
list is, not coincidentally, usually implemented as the exact singly
linked list covered in Linked
Lists, threaded through the unused blocks themselves.
Real-world applications
- RTOS task stacks — a task's stack is typically a compile-time array or drawn from a static pool, never a raw
malloc()per task. - Sensor driver buffers — a fixed receive buffer allocated once at driver initialization and reused for the device's entire operating lifetime.
- Network packet pools — a networking stack on a constrained device typically draws packet buffers from a pre-sized pool rather than calling
malloc()per packet.
Gotchas
- Not checking
malloc's return value forNULLis common in tutorials and genuinely dangerous on a device with a heap measured in kilobytes. - Calling
free()on a pointer that didn't come frommalloc/calloc/realloc(a stack address, an offset pointer) is undefined behavior, not a no-op. realloc's return-value-overwrite pattern (ptr = realloc(ptr, n)) silently leaks the original block on failure — always assign to a temporary first.