
«Управление сложностью — сама суть программирования компьютеров.» — Брайан Керниган
Оглавление
Глава 14: Обработка строк и эффективность использования кэша
Глава 15: Графы и их обход с эффективным использованием кэша
Глава 19: Управление памятью в прошивках
Стадия финального тестирования
Мы находились на этапе финального тестирования проекта IoT‑датчика — устройства для умного дома с 128 КБ ОЗУ, которое отслеживает температуру, влажность и качество воздуха. Прошивка прошла все функциональные тесты. Юнит‑тесты: зелёные. Интеграционные тесты: зелёные. Энергопотребление: в пределах спецификации.
Последним требованием был тест непрерывной эксплуатации в течение 72 часов. Мы подготовили в лаборатории двенадцать устройств, настроив их на отправку данных датчиков каждую секунду, и оставили их работать.
Спустя три дня я пришёл в лабораторию, ожидая собрать логи тестирования и завершить проект.
Но вместо этого выяснилось, что все двенадцать устройств аварийно выключились.
В консоли последовательного порта на каждом устройстве была одна и та же ошибка:
[72:14:23] malloc failed: out of memory [72:14:23] Fragmentation: 45% [72:14:23] System halted
У меня cкрутило желудок. Проект планировалось выпустить через две недели. Я в точности знал, что произошло, и понимал, что решать проблему будет очень трудоёмко.
Решение из учебника
Когда я несколько месяцев назад проектировал прошивку устройств, то использовал разумные, как мне казалось, практики. Устройство должно было выполнять следующие действия:
Заниматься сетевыми коммуникациями (стек TCP/IP)
Каждую секунду обрабатывать данные датчиков
Сохранять конфигурацию
Выполнять беспроводные (OTA, Over‑The‑Air) обновления
Я воспользовался malloc/free из newlib, как и советуют нам учебники:
void process_sensor_data(void) { // Распределение буфера для показаний датчиков sensor_data_t *data = malloc(sizeof(sensor_data_t)); // Чтение показаний датчиков read_temperature(data); read_humidity(data); // Обработка и отправка send_to_cloud(data); // Освобождение буфера free(data); }
Просто и чисто, всё по учебнику.
И за 72 часа непрерывной работы этот код убил все двенадцать устройств.
Вскрытие
Я извлёк трассировку памяти из аварийного дампа:
[72:14:23] Сбой malloc: не хватает памяти [72:14:23] Доступно: 8 КБ [72:14:23] Запрошено: 16 КБ [72:14:23] Фрагментация: 45% [72:14:23] Всего распределений: 259200 (72 часов × 3600 секунд/ч) [72:14:23] Средний размер распределений: 156 байт [72:14:23] Система остановлена
Фрагментация 45%. Из 128 КБ ОЗУ только 8 КБ были доступны в виде непрерывных блоков. Под сетевой буфер прошивке требовалось 16 КБ, но она не могла их найти. Проблема заключалась не в утечке памяти — мы освобождали всё корректно — а во фрагментации.
После 259 200 распределений и освобождений в течение 72 часов куча походила на швейцарский сыр:
Исходное состояние (свободно 128 КБ): [ ] Спустя 72 часа: [занято][свободно][занято][свободно][занято][свободно][занято][свободно]... 4 КБ 2 КБ 8 КБ 1 КБ Всего свободно: 58 КБ Наибольший непрерывный блок: 8 КБ Невозможно распределить 16 КБ!
До запланированной даты выпуска у меня было две недели. За это время я должен был придумать решение, не требующее переписывания кода всей прошивки.
Перепроектирование
За две недели я не мог переписать всю прошивку, но мог исправить управление памятью.
Важное наблюдение: наши распределения имели предсказуемые паттерны.
Я проанализировал аварийный дамп и выяснил следующее:
Данные датчиков: 156 байт, распределяемые каждую секунду
Сетевые пакеты: 1024 байт, распределяемые каждые 5 секунд
Конфигурация: 2048 байт, распределяемые при запуске
Временные буферы: 256 байт, распределяемые во время обработки
Предсказуемые размеры, предсказуемый срок жизни.
Мне не нужен был общий распределитель, достаточно было специализированных распределителей для каждого сценария.
Стратегия 1: пулы памяти фиксированного размера
Для данных датчиков (156 байт, распределяемые каждую секунду), я создал пул фиксированного размера:
#define SENSOR_POOL_SIZE 256 // Округляется вверх до степени двойки #define SENSOR_POOL_COUNT 10 // Максимум десять одновременных показаний typedef struct free_block { struct free_block *next; } free_block_t; typedef struct { uint8_t memory[SENSOR_POOL_SIZE * SENSOR_POOL_COUNT]; free_block_t *free_list; } sensor_pool_t; static sensor_pool_t g_sensor_pool; void sensor_pool_init(void) { g_sensor_pool.free_list = NULL; // Связываем все блоки в список для освобождения for (int i = 0; i < SENSOR_POOL_COUNT; i++) { free_block_t *block = (free_block_t *)&g_sensor_pool.memory[i * SENSOR_POOL_SIZE]; block->next = g_sensor_pool.free_list; g_sensor_pool.free_list = block; } } void *sensor_alloc(void) { if (!g_sensor_pool.free_list) { return NULL; // Пул исчерпан } void *ptr = g_sensor_pool.free_list; g_sensor_pool.free_list = g_sensor_pool.free_list->next; return ptr; } void sensor_free(void *ptr) { free_block_t *block = (free_block_t *)ptr; block->next = g_sensor_pool.free_list; g_sensor_pool.free_list = block; }
Всё просто. Распределение и освобождение за O(1). Фрагментация отсутствует.
Я выполнил бенчмарк:
Тест: 10000 показаний датчиков (блоки по 256 байт) malloc/free: Такты: 2,4 миллиона (по 240 тактов на операцию) Фрагментация: 18% Время: 2,0 мс Пул фиксированного размера (список для освобождения): Такты: 120 тысяч (12 тактов на операцию) Фрагментация: 0% Время: 0,10 мс Ускорение: 20×
В 20 раз быстрее и нулевая фрагментация. Так я решил проблему данных датчиков.
Стратегия 2: статическое распределение для сетевых буферов
Для общения по TCP/IP сетевому стеку требовались буферы. Изначально код распределял их динамически:
// ПЛОХО: Динамическое распределение typedef struct { char *tx_buffer; char *rx_buffer; // ... } uart_context_t; void uart_init(uart_context_t *ctx) { ctx->tx_buffer = malloc(1024); // Фрагментация! ctx->rx_buffer = malloc(1024); } // ХОРОШО: Статическое распределение typedef struct { char tx_buffer[1024]; char rx_buffer[1024]; // ... } uart_context_t; static uart_context_t g_uart_ctx; // Статическое, без malloc void uart_init(void) { // Буферы уже распределены, ничего делать не нужно }
Преимущества:
Нулевая фрагментация: куча не используется
Нулевой оверхед: никаких метаданных
Предсказуемость: всё известно на этапе компиляции
Скорость: не требуется время на распределение
Минус: ОЗУ используется, даже если не нужна, но для прошивки это обычно приемлемо.
Стратегия 3: распределение на основе стека
Для временных буферов я использовал стек:
// ПЛОХО: Распределение кучи для временного буфера void process_data(void) { char *temp = malloc(512); // ... использование temp free(temp); } // ХОРОШО: Распределение со стеком void process_data(void) { char temp[512]; // В стеке // ... использование temp // Освобождение выполняется автоматически, когда функция выполняет возврат }
Преимущества:
Наибольшая скорость: достаточно управлять указателем стека
Отсутствие фрагментации: стек увеличивается/уменьшается, больше ничего не затрагивая
Автоматическая очистка: освобождение не требуется
Ограничение: размер стека ограничен (обычно 4–16 КБ). Не следует распределять в стек большие буферы.
Стратегия 4: области памяти
Можно разбить память на области под различные цели:
// Структура памяти (суммарно 128 КБ) #define REGION_STATIC_START 0x20000000 #define REGION_STATIC_SIZE (64 * 1024) // 64 КБ под статические данные #define REGION_POOL_START (REGION_STATIC_START + REGION_STATIC_SIZE) #define REGION_POOL_SIZE (32 * 1024) // 32 КБ под пулы #define REGION_STACK_START (REGION_POOL_START + REGION_POOL_SIZE) #define REGION_STACK_SIZE (16 * 1024) // 16 КБ под стек #define REGION_DMA_START (REGION_STACK_START + REGION_STACK_SIZE) #define REGION_DMA_SIZE (16 * 1024) // 16 КБ под буферы DMA typedef struct { uint8_t static_data[REGION_STATIC_SIZE]; uint8_t pool_memory[REGION_POOL_SIZE]; uint8_t stack[REGION_STACK_SIZE]; uint8_t dma_buffers[REGION_DMA_SIZE]; } memory_layout_t; __attribute__((section(".ram"))) static memory_layout_t g_memory;
Почему это помогает:
Чёткие границы: размер каждой области постоянен
Отсутствие помех: DMA не повреждает стек
Простота отладки: мы знаем, что каждая область заполнена
Удобство для кэша: связанные друг с другом данные находятся в одной области
Стратегия 5: Slab‑распределитель
Для объектов одного типа можно использовать slab‑распределитель:
#define MAX_CONNECTIONS 32 typedef struct { int socket_fd; char rx_buffer[2048]; char tx_buffer[2048]; // ... другие поля } connection_t; typedef struct { connection_t connections[MAX_CONNECTIONS]; uint32_t free_bitmap; // 1 бит на соединение } connection_pool_t; static connection_pool_t g_conn_pool; connection_t *conn_alloc(void) { // Находим первый свободный бит int idx = __builtin_ffs(g_conn_pool.free_bitmap) - 1; if (idx < 0) { return NULL; // Пул исчерпан } // Отмечаем как используемое g_conn_pool.free_bitmap &= ~(1U << idx); // Возвращаем соединение return &g_conn_pool.connections[idx]; } void conn_free(connection_t *conn) { int idx = conn - g_conn_pool.connections; g_conn_pool.free_bitmap |= (1U << idx); }
Преимущества:
Распределение за O(1): просто находим первый установленный бит
Удобство для кэша: все соединения непрерывны
Типобезопасность: распределить можно только connection_t
Низкий оверхед: по 1 биту на объект
Бенчмарк
Тест: распределение 1000 соединений malloc/free: Такты: 240 тысяч Фрагментация: 12% Время: 0,20 мс Slab-распределитель (битовая карта): Такты: 18 тысяч Фрагментация: 0% Время: 0,015 мс Ускорение: 13,3×
Пример из реального мира: управление кучей FreeRTOS
В FreeRTOS есть несколько реализаций куч:
heap_1.c: простой Bump Allocator
static uint8_t heap[configTOTAL_HEAP_SIZE]; static size_t next_free_byte = 0; void *pvPortMalloc(size_t size) { void *ptr = NULL; if (next_free_byte + size < configTOTAL_HEAP_SIZE) { ptr = &heap[next_free_byte]; next_free_byte += size; } return ptr; } void vPortFree(void *ptr) { // No-op: невозможно освободить отдельные блоки }
Сценарий использования: системы, которые не могут освобождать память (распределение выполняется только при запуске).
heap_4.c: First‑Fit with Coalescing
typedef struct A_BLOCK_LINK { struct A_BLOCK_LINK *pxNextFreeBlock; size_t xBlockSize; } BlockLink_t; static BlockLink_t xStart; static BlockLink_t *pxEnd = NULL; void *pvPortMalloc(size_t xWantedSize) { BlockLink_t *pxBlock, *pxPreviousBlock, *pxNewBlockLink; // Находим первый достаточно большой блок pxPreviousBlock = &xStart; pxBlock = xStart.pxNextFreeBlock; while ((pxBlock->xBlockSize < xWantedSize) && (pxBlock->pxNextFreeBlock != NULL)) { pxPreviousBlock = pxBlock; pxBlock = pxBlock->pxNextFreeBlock; } if (pxBlock != pxEnd) { // Разбиваем блок, если он достаточно большой // ... return (void *)(((uint8_t *)pxPreviousBlock->pxNextFreeBlock) + xHeapStructSize); } return NULL; }
Сценарий использования: распределение общего назначения с допустимой степенью фрагментации.
heap_5.c: несколько областей
typedef struct HeapRegion { uint8_t *pucStartAddress; size_t xSizeInBytes; } HeapRegion_t; void vPortDefineHeapRegions(const HeapRegion_t * const pxHeapRegions) { // Инициализация нескольких несплошных областей памяти // ... }
Сценарий использования: системы с несколькими областями ОЗУ (внутренняя SRAM + внешняя DRAM).
Соединяем всё вместе: оптимизированная память прошивки
Вот готовая оптимизированная прошивка, объединяющая в себе все описанные выше методики:
// 1. Области памяти #define STATIC_REGION_SIZE (64 * 1024) #define POOL_REGION_SIZE (32 * 1024) #define STACK_REGION_SIZE (16 * 1024) #define DMA_REGION_SIZE (16 * 1024) // 2. Пулы фиксированного размера typedef struct { fast_pool_t small_pool; // Блоки по 32 байта fast_pool_t medium_pool; // Блоки по 256 байт fast_pool_t large_pool; // Блоки по 4096 байт } pool_manager_t; static pool_manager_t g_pools; // 3. Статическое распределение для объектов с долгим сроком жизни typedef struct { char tx_buffer[1024]; char rx_buffer[1024]; // ... } uart_context_t; static uart_context_t g_uart; // 4. Slab-распределитель для соединений typedef struct { connection_t connections[MAX_CONNECTIONS]; uint32_t free_bitmap; } connection_pool_t; static connection_pool_t g_conn_pool; // Инициализация памяти void memory_init(void) { // Инициализация пулов pool_init(&g_pools.small_pool, 32, 128); pool_init(&g_pools.medium_pool, 256, 32); pool_init(&g_pools.large_pool, 4096, 8); // Инициализация пула соединений g_conn_pool.free_bitmap = 0xFFFFFFFF; // All free // Статические объекты уже инициализированы } // Функция умного распределения void *mem_alloc(size_t size) { if (size <= 32) { return pool_alloc(&g_pools.small_pool); } else if (size <= 256) { return pool_alloc(&g_pools.medium_pool); } else if (size <= 4096) { return pool_alloc(&g_pools.large_pool); } else { return NULL; // Слишком большой } } void mem_free(void *ptr, size_t size) { if (size <= 32) { pool_free(&g_pools.small_pool, ptr); } else if (size <= 256) { pool_free(&g_pools.medium_pool, ptr); } else if (size <= 4096) { pool_free(&g_pools.large_pool, ptr); } }
Окончательный бенчмарк
Тест: прогон прошивки IoT в течение 24 часов malloc/free: Пиковое использование памяти: 118 КБ Фрагментация: 45% Наибольший свободный блок: 8 КБ Вылеты: 3 (out of memory) Время распределения: 200 тактов (в среднем) Оптимизированная (пулы + статическое распределение + slab): Пиковое использование памяти: 96 КБ Фрагментация: 0% Наибольший свободный блок: 32 КБ Вылеты: 0 Время распределения: 12 тактов (в среднем) Улучшения: Используемая память: на 18,6% меньше Фрагментация: устранена на 100% Скорость распределения: в 16,7 выше Надёжность: вылеты отсутствуют
Повторное тестирование
Реализовав модернизированную архитектуру пулов памяти, я перепрошил все двенадцать устройств и заново начал 72-часовой тест.
На этот раз я непрерывно отслеживал использование памяти. Спустя 15 минут паттерн уже был понятен: использование памяти стабилизировалось на 96 КБ, а уровень фрагментации остался нулевым.
Спустя 72 часа все двенадцать устройств по‑прежнему работали. И спустя неделю они тоже работали. В конечном итоге, мы продлили тест на три месяца: никаких проблем с памятью и фрагментацией не возникло.
Чему я научился
Перепроектирование прошивки научило меня тому, что malloc/free — неподходящий инструмент для встраиваемых систем.
Вот, что мне помогло:
1. Пулы фиксированного размера позволяют устранить фрагментацию
Предварительное распределение блоков фиксированного размера (256 байт для датчиков, 1024 байта для сетевых пакетов) обеспечивает распределение за O(1) с нулевой фрагментацией. Пул датчиков оказался в 20 раз быстрее, чем malloc.
2. Статическое распределение для объектов с долгим сроком жизни
Сетевые буферы, данные конфигураций и другие постоянные объекты должны распределяться статически. Нулевой оверхед и нулевая фрагментация, а структура памяти известна уже на этапе компиляции.
3. Распределение на основе стека для временных буферов
Буферы с коротким сроком жизни (например, временные буферы обработки) должны использовать стек. Самое быстрое распределение, достаточно лишь изменять указатель стека; автоматическая очистка при возврате из функции.
4. Slab‑распределители для однородных объектов
Для пулов соединений и буферов пакетов удобно slab‑распределение. Применение битовой карты для отслеживания свободной/занятой памяти обеспечивает распределение за O(1) всего с 1 битом оверхеда на объект. В 13,3 раза быстрее, чем malloc.
Окончательные показатели
Исходная прошивка (malloc/free): Пиковое использование памяти: 118 КБ Фрагментация: 45% Вылет: спустя 72 часа Время распределения: 240 тактов (в среднем) Оптимизированная прошивка (пулы + статическое распределение + slab): Пиковое использование памяти: 96 КБ Фрагментация: 0% Вылеты: никогда (тестирование в течение трёх с лишним месяцев) Время распределения: 12 тактов (в среднем) Улучшения: Пиковое использование памяти: снижение на 18,6% Фрагментация: полное устранение Скорость распределения: в 20 раз больше Надёжность: полное отсутствие вылетов
Вывод: прошивкам требуется прогнозируемое, детерминированное управление памятью. Следует избегать malloc/free и использовать пулы, статическое распределение и slab‑распределители.
И всегда нужно проводить длительное тестирование, как минимум 72 часа, прежде чем принимать решение о готовности проекта. Важнее всего баги, возникающие спустя несколько дней непрерывной работы.
Подведём итог
Основные наблюдения:
malloc/free вызывают фрагментацию в длительно работающей прошивке
Пулы фиксированного размера: в 20 раз быстрее, полное отсутствие фрагментации
Статическое распределение: лучше всего подходит для объектов с длительным сроком жизни
Распределение на основе стека: лучше всего подходит для временных буферов
Slab‑распределители: в 13,3 раза быстрее для однородных объектов
Прошивка датчика IoT:
На 18,6% меньше занятой памяти (118 КБ → 96 КБ)
Отсутствие фрагментации (45% → 0%)
Распределение в 20 раз быстрее (240 тактов → 12 тактов)
Отсутствие вылетов (при непрерывном прогоне в течение трёх с лишним месяцев)
Вывод: во встраиваемых системах предсказуемость важнее гибкости. Проектируйте управление памятью под конкретную рабочую нагрузку, а не для широкого спектра применений.
