1. Today’s topic
Today we examine firmware design that avoids unpredictable memory-related crashes after weeks of continuous operation:
static allocation
fixed memory pools
object lifetime control
heap fragmentation
peak heap
largest free block
stack high-water mark
allocation failure policyThe central idea: allocate memory for the critical path in advance. During operation, the system should move existing objects rather than constantly create and destroy them.
2. Why this matters in your projects
EC25/MQTT/OTA constantly create variable-size objects: AT commands, URC, MQTT payload, HTTP/OTA data, and response events. Allocating them with malloc/free at runtime can cause fragmentation. The size of phase_event_t is known in advance, so heap allocation is unnecessary:
a static queue of 16 events
a fixed phase_event_t structure
no malloc/free in input_taskDMA, UART, and CAN/RS-485 require preallocated buffers with the appropriate memory properties.
3. Theory
9.3.1 3.1. Free heap is not one available large block
Total free memory may be 80 KiB while the largest free block is 6 KiB. A TLS allocation of 12 KiB will then fail. Inspect:
heap_caps_get_free_size(...);
heap_caps_get_largest_free_block(...);
heap_caps_get_minimum_free_size(...);9.3.2 3.2. When dynamic allocation is acceptable
Good locations:
initialization;
creation of long-lived services;
rare configuration changes;
loading a certificate;
preparing an OTA session with result checking.Poor locations:
ISR;
input_task every 2 ms;
every UART packet;
every CAN frame;
every phase_event;
watchdog loop;
a critical section.9.3.3 3.3. FreeRTOS static allocation
Static task:
ESP-IDF: static task stack depth in bytes
#define INPUT_TASK_STACK_WORDS 2048U
static StaticTask_t s_input_task_tcb;
static StackType_t s_input_task_stack[INPUT_TASK_STACK_WORDS];
static void input_task(void *arg)
{
for (;;) {
input_service_step();
vTaskDelay(pdMS_TO_TICKS(2));
}
}
static void input_task_create(void)
{
TaskHandle_t handle = xTaskCreateStaticPinnedToCore(
input_task,
"input",
INPUT_TASK_STACK_WORDS,
NULL,
12,
s_input_task_stack,
&s_input_task_tcb,
1);
assert(handle != NULL);
}Static queue:
#define PHASE_QUEUE_LENGTH 16U
static StaticQueue_t s_phase_queue_control;
static uint8_t s_phase_queue_storage[
PHASE_QUEUE_LENGTH * sizeof(phase_event_t)
];
static QueueHandle_t s_phase_queue;
static void phase_queue_create(void)
{
s_phase_queue = xQueueCreateStatic(
PHASE_QUEUE_LENGTH,
sizeof(phase_event_t),
s_phase_queue_storage,
&s_phase_queue_control);
assert(s_phase_queue != NULL);
}9.3.4 3.4. Fixed-size object pool
Use a pool for equally sized objects:
8 × modem_command_t
payload up to 256 bytesPool diagnostics:
typedef struct {
uint32_t current_used;
uint32_t max_used;
uint32_t alloc_ok;
uint32_t alloc_failed;
uint32_t invalid_free;
} memory_pool_diag_t;9.3.5 3.5. Queue by value or queue of pointers
Queue by value is safe for small structures:
phase_event_t
fault_event_t
CAN frame
a short commandA queue of pointers requires an ownership contract:
before successful xQueueSend:
producer owns the object
after successful xQueueSend:
consumer owns the object
on xQueueSend failure:
producer returns the object to the pool9.3.6 3.6. Scratch buffer
For JSON, TLS certificate parsing, an OTA manifest, or a CLI dump, prefer one scratch buffer owned by the owner-task rather than malloc() in every component.
9.3.7 3.7. Invisible library allocations
Memory may be allocated by:
stdio
TLS
MQTT
TCP/IP stack
JSON libraries
DNS
Wi-FiTherefore your critical modules should avoid heap allocation, while system libraries are measured and constrained by a budget.
9.3.8 3.8. Allocation failure is part of the state machine
uint8_t *buffer = heap_caps_malloc(size,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
if (buffer == NULL) {
diag.alloc_failed++;
fault_report(FAULT_SRC_MEMORY,
FAULT_MEMORY_ALLOCATION_FAILED,
(int32_t)size,
0,
0,
0);
return ESP_ERR_NO_MEM;
}4. Common mistakes
1. Looking only at free heap.
2. Allocating memory for every UART packet.
3. Using heap from ISR.
4. Queue of pointers without an ownership contract.
5. Returning an object to the pool twice.
6. Creating tasks/queues on every reconnect.
7. Treating PSRAM as a substitute for internal RAM.
8. Large arrays on the stack.
9. Pool without diagnostics.
10. Not testing the low-memory path.5. Practical assignment for 30-60 minutes
Create MEMORY_POLICY.md:
# Memory policy
1. ISR never allocates memory.
2. Input and phase fast paths use no heap.
3. Critical tasks and queues are statically allocated.
4. Large temporary buffers belong to one owner task.
5. Pointer queues have an explicit ownership contract.
6. Fixed-size messages use object pools.
7. Every allocation result is checked.
8. Free heap, largest block and minimum heap are monitored.
9. Pool exhaustion is a diagnostic fault.
10. Memory stress tests are part of HIL/CI.Move phase_queue to xQueueCreateStatic(), create a modem command pool of 8 objects, and add a memory CLI:
memory:
INTERNAL:
free=84216
minimum=51320
largest=62144
fragmentation=262permille
POOLS:
modem_cmd used=2/8 max=7 alloc_fail=0
phase_queue used=0/16 high=6 drops=06. Further reading
- ESP-IDF Heap Memory Allocation and Heap Memory Debugging.
- FreeRTOS static task/queue allocation.
- CMSIS-RTOS2 Memory Pool.
Brief recap
boot
→ create critical tasks/queues statically
→ create fixed pools
→ reserve DMA/scratch buffers
→ measure baseline heap
→ start services
→ monitor peak/min/largest block
→ handle exhaustion as a faultExercise
Review INPUT_TASK_STACK_WORDS and xTaskCreateStaticPinnedToCore before adapting the source to ESP-IDF. What API unit applies, and how do you verify storage capacity?
Self-check criteria: Separate argument units, array element count, and total byte capacity. Do not infer underallocation or a universal multiplier from a name.
Show the supplied answer
ESP-IDF v6.1 defines ulStackDepth in bytes, unlike vanilla FreeRTOS. The name WORDS does not change the unit. Check the actual target/API, sizeof, StackType_t, array capacity, and measured stack use. The source snippet stays unchanged; this is an editorial portability clarification.
Exercise
Exhaust a modem object pool in a controlled test. Define allocation failure, queue-send failure, and completion ownership without leaks or double returns.
Self-check criteria: Check usage/high-water/failure counters and both failure paths. A non-null pointer does not establish ownership.
Show the supplied answer
The producer owns an acquired object until successful queue transfer. On allocation failure follow the bounded failure policy; on queue-send failure the producer returns it. After successful transfer the consumer owns completion and returns the object once.