Memory Management in Embedded Firmware: Avoiding Heap Fragmentation and Stack Overflows
Memory errors are the most common cause of mysterious production failures in embedded firmware. A device works perfectly in testing for weeks, then starts crashing randomly after 72 hours of production operation. The cause is almost always heap fragmentation, a stack overflow, or a memory leak. These bugs are hard to reproduce and harder to diagnose without the right tools and mindset.
This guide gives you the framework to design firmware that never runs out of memory.
Static vs Dynamic Allocation: The Core Trade-Off
The safest embedded firmware allocates all memory statically at compile time. If it compiles and links with your memory budget, it will never run out of memory at runtime.
// Static allocation — deterministic, no fragmentation possible
static uint8_t uart_rx_buffer[1024];
static uint8_t mqtt_payload_buffer[512];
static sensor_reading_t reading_history[100];// Dynamic allocation — flexible, but risks fragmentation
uint8_t *buf = malloc(1024); // May fail after extended runtime
if (!buf) handle_oom_error();
For production embedded firmware, the rule is: prefer static allocation everywhere possible, use dynamic allocation only where flexibility is essential (e.g., variable-length MQTT messages, dynamic JSON parsing, plugin architectures).
Heap Fragmentation: How It Happens and How to Fix It
Heap fragmentation occurs when allocations and frees of different sizes leave the heap as a patchwork of small free blocks. Your firmware can have 10 KB of total free heap but be unable to allocate a single 5 KB block because no contiguous free region of that size exists.
// Fragmentation demonstration (bad pattern — do not do this)
void process_messages(void) {
for (;;) {
// Alternating small and large allocations
char *small = malloc(64); // Free block A: 64 bytes
char *large = malloc(2048); // Free block B: 2048 bytes
process(small, large);
free(large); // Free B — 2048-byte hole
free(small); // Free A — 64-byte hole (may not merge with B if not adjacent)
// After 1000 iterations: heap is swiss cheese
}
}
Fix 1: Memory Pool Allocator
A memory pool pre-allocates a fixed number of fixed-size blocks. Allocation is O(1) and fragmentation-free. The trade-off is that block size must be chosen at design time.
#define POOL_BLOCK_SIZE 256
#define POOL_BLOCK_COUNT 16typedef struct pool_block {
struct pool_block *next;
uint8_t data[POOL_BLOCK_SIZE];
} pool_block_t;
static pool_block_t pool_storage[POOL_BLOCK_COUNT];
static pool_block_t *pool_free_list = NULL;
void pool_init(void) {
for (int i = 0; i < POOL_BLOCK_COUNT - 1; i++) {
pool_storage[i].next = &pool_storage[i + 1];
}
pool_storage[POOL_BLOCK_COUNT - 1].next = NULL;
pool_free_list = &pool_storage[0];
}
void *pool_alloc(void) {
if (!pool_free_list) return NULL; // Pool exhausted
pool_block_t *block = pool_free_list;
pool_free_list = block->next;
return block->data;
}
void pool_free(void *ptr) {
pool_block_t *block = (pool_block_t *)((uint8_t *)ptr - offsetof(pool_block_t, data));
block->next = pool_free_list;
pool_free_list = block;
}
This pool allocates 256-byte blocks in O(1) with zero fragmentation. Use separate pools for different size classes (64-byte, 256-byte, 1024-byte) to cover different allocation patterns.
FreeRTOS Heap Implementations
FreeRTOS provides five heap implementations. Choose based on your requirements:
| Heap | Supports free() | Fragmentation-safe | Use case | |---|---|---|---| | heap_1 | No | Yes (static) | Safety-critical, no dynamic dealloc | | heap_2 | Yes | No | Simple, same-size reuse patterns | | heap_3 | Yes | System malloc | Wraps newlib malloc (external) | | heap_4 | Yes | Merges adjacent | General purpose — most common | | heap_5 | Yes | Merges adjacent | Multiple non-contiguous memory regions |
// Configure heap_4 in FreeRTOSConfig.h
#define configUSE_HEAP_4 1
#define configTOTAL_HEAP_SIZE (50 * 1024) // 50 KB// Monitor heap health in production
void log_heap_status(void) {
ESP_LOGI("MEM", "Free heap: %lu bytes, min ever: %lu bytes",
xPortGetFreeHeapSize(),
xPortGetMinimumEverFreeHeapSize());
}
Log xPortGetMinimumEverFreeHeapSize() periodically. If this watermark is below 5 KB, you are close to exhaustion under peak load.
Stack Sizing Strategy
Every FreeRTOS task needs a stack. Too small: stack overflow, corrupt neighboring memory, random crashes. Too large: wasted RAM. Size stacks correctly with these steps:
Step 1: Start tasks with 4096-byte stacks (safe default).
Step 2: During development, log the high watermark from each task:
void sensor_task(void *pvParameters) {
for (;;) {
// ... task work // Log every 60 seconds
static uint32_t last_log = 0;
if (xTaskGetTickCount() - last_log > pdMS_TO_TICKS(60000)) {
last_log = xTaskGetTickCount();
UBaseType_t watermark = uxTaskGetStackHighWaterMark(NULL);
ESP_LOGI("STACK", "sensor_task watermark: %u words remaining", watermark);
}
}
}
Step 3: Production stack size = (configured size - watermark) × 1.5, rounded up to 256-byte boundary.
If your task shows 512 words (2048 bytes) remaining from a 4096-byte stack, it used 2048 bytes peak. Production size = 2048 × 1.5 = 3072 bytes.
ESP32 SPIRAM (PSRAM) for Large Allocations
ESP32-WROVER modules include 8 MB of SPI-connected PSRAM. This is slower than internal SRAM (longer access latency, cache-assisted) but sufficient for large buffers: HTTP response bodies, image buffers, TLS session caches.
#include "esp_heap_caps.h"// Allocate in SPIRAM — slower but 8MB available
uint8_t *large_buffer = heap_caps_malloc(65536, MALLOC_CAP_SPIRAM);
if (!large_buffer) {
ESP_LOGE("MEM", "Failed to allocate 64 KB from SPIRAM");
return ESP_ERR_NO_MEM;
}
// Allocate in internal SRAM — faster, latency-sensitive code paths
uint8_t *fast_buffer = heap_caps_malloc(1024, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
// Check SPIRAM availability at boot
size_t spiram_size = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
ESP_LOGI("MEM", "SPIRAM available: %u bytes", spiram_size);
Rules for SPIRAM:
Memory Leak Detection
Memory leaks in embedded firmware are subtle — you may not notice them for days. Pattern: free heap decreases monotonically over days of operation until the device crashes.
// Wrapper with leak tracking for development builds
#ifdef DEBUG_MEMORY_LEAKS
#define MAX_ALLOC_RECORDS 256typedef struct {
void *ptr;
size_t size;
const char *file;
int line;
} alloc_record_t;
static alloc_record_t alloc_records[MAX_ALLOC_RECORDS];
static int alloc_count = 0;
void *debug_malloc_tracked(size_t size, const char *file, int line) {
void *ptr = malloc(size);
if (ptr && alloc_count < MAX_ALLOC_RECORDS) {
alloc_records[alloc_count++] = (alloc_record_t){ptr, size, file, line};
}
return ptr;
}
#define malloc(s) debug_malloc_tracked(s, __FILE__, __LINE__)
#endif
In production (release build), this is compiled away. In development, you can dump alloc_records over serial to see what is currently allocated and where it was allocated from.
For related guidance on structuring tasks that manage memory safely, see our [FreeRTOS task scheduling guide](/freertos-esp32-task-scheduling).
[Contact Code Caracal](/contact) — we build production firmware for clients across 15+ countries.