1. Тема дня
Сегодня разбираем, как спроектировать прошивку, которая не начинает случайно падать через недели непрерывной работы из-за памяти:
static allocation
фиксированные memory pools
контроль времени жизни объектов
фрагментация heap
peak heap
largest free block
stack high-water mark
allocation failure policyГлавная мысль: Для критического пути память должна быть выделена заранее. Во время работы система должна перемещать уже существующие объекты, а не постоянно создавать и уничтожать их.
2. Зачем это нужно в твоих проектах
В EC25/MQTT/OTA постоянно возникают объекты переменного размера: AT-команды, URC, MQTT payload, HTTP/OTA data, response events. Если выделять их через malloc/free в runtime, можно получить фрагментацию. Для phase_event_t размер известен заранее, поэтому heap не нужен:
статическая очередь из 16 событий
фиксированная структура phase_event_t
никаких malloc/free в input_taskDMA, UART, CAN/RS-485 требуют заранее выделенных буферов с правильными свойствами памяти.
3. Теория
9.3.1 3.1. Free heap не равен доступному большому блоку
Суммарно свободно может быть 80 KiB, но largest free block — 6 KiB. Тогда TLS allocation на 12 KiB не пройдёт. Нужно смотреть:
heap_caps_get_free_size(...);
heap_caps_get_largest_free_block(...);
heap_caps_get_minimum_free_size(...);9.3.2 3.2. Когда dynamic allocation допустима
Хорошие места:
инициализация;
создание долгоживущих сервисов;
редкая смена конфигурации;
загрузка сертификата;
подготовка OTA-сессии с проверкой результата.Плохие места:
ISR;
input_task каждые 2 мс;
каждый UART-пакет;
каждый CAN frame;
каждый phase_event;
watchdog loop;
критическая секция.9.3.3 3.3. Static allocation FreeRTOS
Статическая задача:
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);
}Статическая очередь:
#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
Используй pool для объектов одинакового размера:
8 × modem_command_t
payload до 256 байтДиагностика pool:
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 или queue of pointers
Queue by value безопасна для маленьких структур:
phase_event_t
fault_event_t
CAN frame
короткая командаQueue of pointers требует ownership contract:
до успешного xQueueSend:
владелец producer
после успешного xQueueSend:
владелец consumer
при ошибке xQueueSend:
producer возвращает объект в pool9.3.6 3.6. Scratch buffer
Для JSON, TLS certificate parsing, OTA manifest или CLI dump лучше иметь один scratch buffer у owner-task, а не malloc() в каждом компоненте.
9.3.7 3.7. Невидимые аллокации библиотек
Память могут выделять:
stdio
TLS
MQTT
TCP/IP stack
JSON libraries
DNS
Wi-FiПоэтому критичные собственные модули должны быть без heap, а системные библиотеки — измерены и ограничены budget.
9.3.8 3.8. Allocation failure — часть 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. Типичные ошибки
1. Смотреть только на free heap.
2. Выделять память на каждый UART-пакет.
3. Использовать heap из ISR.
4. Queue of pointers без ownership contract.
5. Возвращать объект в pool дважды.
6. Создавать задачи/очереди при каждом reconnect.
7. Считать PSRAM заменой внутренней RAM.
8. Большие массивы на stack.
9. Pool без диагностики.
10. Не тестировать low-memory path.5. Практическое задание на 30-60 минут
Создай 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.Переведи phase_queue на xQueueCreateStatic(), создай modem command pool на 8 объектов, добавь CLI memory:
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. Что почитать дальше
- ESP-IDF Heap Memory Allocation и Heap Memory Debugging.
- FreeRTOS static task/queue allocation.
- CMSIS-RTOS2 Memory Pool.
Короткий итог
boot
→ статически создать critical tasks/queues
→ создать fixed pools
→ зарезервировать DMA/scratch buffers
→ измерить baseline heap
→ запустить сервисы
→ контролировать peak/min/largest block
→ обрабатывать exhaustion как faultЗадание
Перед переносом на ESP-IDF проверьте INPUT_TASK_STACK_WORDS и xTaskCreateStaticPinnedToCore. Какие API units действуют и как проверить storage capacity?
Критерии самопроверки: Отделить argument units, array element count и total byte capacity. Не выводить underallocation или универсальный multiplier из имени.
Показать ответ автора
ESP-IDF v6.1 задаёт ulStackDepth в bytes, в отличие от vanilla FreeRTOS. Имя WORDS не меняет units. Проверить actual target/API, sizeof, StackType_t, array capacity и measured stack use. Source snippet не изменён; это авторское уточнение portability.
Задание
Исчерпайте modem object pool в controlled test. Определите allocation failure, queue-send failure и completion ownership без leaks и double returns.
Критерии самопроверки: Проверить usage/high-water/failure counters и оба failure paths. Non-null pointer не задаёт ownership.
Показать ответ автора
Producer владеет acquired object до successful queue transfer. При allocation failure действует bounded failure policy; при queue-send failure producer возвращает object. После successful transfer consumer владеет completion и возвращает object один раз.