1. Today’s topic

Today we examine firmware design that avoids unpredictable memory-related crashes after weeks of continuous operation:

text
static allocation
fixed memory pools
object lifetime control
heap fragmentation
peak heap
largest free block
stack high-water mark
allocation failure policy

The 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:

text
a static queue of 16 events
a fixed phase_event_t structure
no malloc/free in input_task

DMA, 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:

c
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:

text
initialization;
creation of long-lived services;
rare configuration changes;
loading a certificate;
preparing an OTA session with result checking.

Poor locations:

text
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

c
#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:

c
#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:

text
8 × modem_command_t
payload up to 256 bytes

Pool diagnostics:

c
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:

text
phase_event_t
fault_event_t
CAN frame
a short command

A queue of pointers requires an ownership contract:

text
before successful xQueueSend:
  producer owns the object
after successful xQueueSend:
  consumer owns the object
on xQueueSend failure:
  producer returns the object to the pool

9.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:

text
stdio
TLS
MQTT
TCP/IP stack
JSON libraries
DNS
Wi-Fi

Therefore 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

c
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

text
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:

markdown
# 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:

text
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=0

6. Further reading

  • ESP-IDF Heap Memory Allocation and Heap Memory Debugging.
  • FreeRTOS static task/queue allocation.
  • CMSIS-RTOS2 Memory Pool.

Brief recap

text
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 fault

Exercise

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.