«Управление сложностью — сама суть программирования компьютеров.» — Брайан Керниган

Оглавление

Стадия финального тестирования

Мы находились на этапе финального тестирования проекта 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 тактов)

  • Отсутствие вылетов (при непрерывном прогоне в течение трёх с лишним месяцев)

Вывод: во встраиваемых системах предсказуемость важнее гибкости. Проектируйте управление памятью под конкретную рабочую нагрузку, а не для широкого спектра применений.