Динамическая память микроконтроллеров: четыре эксперимента с ошибками и их исправлением

Практический урок для ATmega328P / ATmega328PB в Arduino IDE: изучаем malloc(), free(), утечки памяти и повреждение управляющей переменной из-за записи за границы буфера.

Подключение: светодиод к D13 через токоограничивающий резистор (или встроенный на плате), UART для Serial Monitor. На ATmega328PB нужен установленный Arduino core с поддержкой этой модели и корректно выбранным Serial-портом платы.

1. Что такое динамическая память

У ATmega328P/328PB всего 2048 байт SRAM. Глобальные и статические переменные, стек и динамическая память (heap) используют эту RAM. malloc(N) запрашивает участок размером N байт, возвращает указатель либо NULL, если выделение не удалось. free(ptr) освобождает ранее выделенный блок.

2. Для чего нужна программа и что мы будем проверять

Мы загрузим в ATmega328P или ATmega328PB одну учебную программу, которая работает в четырёх режимах. Выбор осуществляется строкой #define TEST_MODE 4: достаточно заменить цифру и заново прошить микроконтроллер. Это позволяет сравнить неправильные и правильные способы работы с оперативной памятью в одинаковых условиях.

В первых трёх режимах микроконтроллер обрабатывает тестовую строку Atmega / AVR MEMORY TEST, выводит номер цикла и строку в Serial Monitor со скоростью 9600 бод, а также мигает светодиодом на выводе D13. Отличается только способ организации памяти. Если выделить очередной блок не удаётся, программа сообщает об ошибке и переходит к частому миганию.

Четвёртый режим выполняется один раз: программа записывает контрольное число 12345, создаёт логический буфер размером 10 байт и намеренно записывает в него 20 байт. Затем считывает число повторно и показывает его изменение. Для воспроизводимости оба участка находятся в одной выделенной области malloc(30), то есть программа не выходит за границы физически выделенного блока.

РежимЧто делаемЧто наблюдаем
1 — ошибкаmalloc(64) на каждом цикле, без free()В какой-то момент malloc() вернёт NULL
2 — исправлениеmalloc(64) и free()Утечка устранена
3 — предпочтительноСтатический массив char[64]Фиксированный расход SRAM, без heap
4 — ошибка записиЛогический буфер 10 байт, записываем 20Контрольное число 12345 меняется на 4294967295

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

3. Полная программа для ATmega328P / ATmega328PB

Меняйте одну строку #define TEST_MODE 4 на число 1, 2, 3 или 4. В режимах 1–3 светодиод мигает раз в 600 мс. При обнаружении ошибки, а также после завершения режима 4, переходит на быстрое мигание. Откройте Serial Monitor со скоростью 9600 бод.

#include <Arduino.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

// 1 - утечка памяти
// 2 - malloc() + free()
// 3 - статический буфер
// 4 - повреждение контрольного числа
#define TEST_MODE 4

#define LED_PIN 13
#define BUFFER_SIZE 64
#define SMALL_BUFFER_SIZE 10
#define WRITE_SIZE 20
#define REGION_SIZE 30

unsigned long cycleCount = 0;
const char testText[] = "Atmega / AVR MEMORY TEST";

void processData(char *buffer)
{
    strcpy(buffer, testText); // допустимо в режимах 1-3: 64 байта
}

void errorBlink()
{
    while (1)
    {
        digitalWrite(LED_PIN, HIGH);
        delay(100);
        digitalWrite(LED_PIN, LOW);
        delay(100);
    }
}

void normalBlink()
{
    digitalWrite(LED_PIN, HIGH);
    delay(300);
    digitalWrite(LED_PIN, LOW);
    delay(300);
}

void printBytes(const uint8_t *memory, uint8_t count)
{
    for (uint8_t i = 0; i < count; i++)
    {
        Serial.print(F("Byte "));
        Serial.print(i);
        Serial.print(F(" = 0x"));
        if (memory[i] < 16) Serial.print('0');
        Serial.println(memory[i], HEX);
    }
}

void setup()
{
    pinMode(LED_PIN, OUTPUT);
    Serial.begin(9600);
    delay(1000);

    Serial.println(F("=== AVR MEMORY TEST ==="));
    Serial.print(F("MODE = "));
    Serial.println(TEST_MODE);
}

void loop()
{
#if TEST_MODE == 4
    // ВАЖНО: физически выделены 30 байт.
    // Первые 10 - логический буфер, байты 10..13 - число.
    // Запись 20 байт НЕ выходит за границу malloc(30),
    // но преднамеренно уничтожает соседнее поле.
    uint8_t *memory = (uint8_t *)malloc(REGION_SIZE);
    if (memory == NULL)
    {
        Serial.println(F("ERROR: MALLOC FAILED!"));
        errorBlink();
    }

    uint8_t *buffer = memory;
    uint8_t *control = memory + SMALL_BUFFER_SIZE;
    memset(memory, 0, REGION_SIZE);

    // 12345 = 0x00003039; на AVR байты: 39 30 00 00
    uint32_t controlValue = 12345UL;
    memcpy(control, &controlValue, sizeof(controlValue));

    uint32_t result = 0;
    memcpy(&result, control, sizeof(result));

    Serial.println(F("--- BEFORE ---"));
    Serial.print(F("Control = "));
    Serial.println(result);
    Serial.println(F("Logical buffer: 10 bytes"));
    Serial.println(F("Write attempt : 20 bytes"));
    printBytes(memory, 20);

    // Намеренно нарушаем границу логического буфера.
    for (uint8_t i = 0; i < WRITE_SIZE; i++)
    {
        buffer[i] = 0xFF;
    }

    memcpy(&result, control, sizeof(result));
    Serial.println(F("--- AFTER ---"));
    Serial.print(F("Control BEFORE = "));
    Serial.println(controlValue);
    Serial.print(F("Control AFTER  = "));
    Serial.println(result);
    printBytes(memory, 20);

    if (result != controlValue)
        Serial.println(F("ERROR: CONTROL VALUE CORRUPTED!"));
    else
        Serial.println(F("CONTROL VALUE OK"));

    free(memory);
    Serial.println(F("TEST FINISHED"));
    errorBlink();

#else
    char *buffer = NULL;

#if TEST_MODE == 3
    static char staticBuffer[BUFFER_SIZE];
    buffer = staticBuffer;
#else
    buffer = (char *)malloc(BUFFER_SIZE);
    if (buffer == NULL)
    {
        Serial.println(F("ERROR: MEMORY FULL!"));
        errorBlink();
    }
#endif

    processData(buffer);
    cycleCount++;
    Serial.print(F("Cycle: "));
    Serial.print(cycleCount);
    Serial.print(F("  Data: "));
    Serial.println(buffer);
    normalBlink();

#if TEST_MODE == 1
    // Ошибка: free(buffer) отсутствует, блок теряется.
#elif TEST_MODE == 2
    free(buffer);
    buffer = NULL;
#elif TEST_MODE == 3
    // Статическую память освобождать не нужно.
#endif
#endif
}

4. Разбор четвёртого режима: как портится число

Сначала записываем в контрольную область число 12345. Затем разрешаем буферу использовать только первые 10 байт, но намеренно записываем в него 20 байт со значением 0xFF. Четыре байта контрольного числа стоят сразу за логическим буфером, поэтому они будут перезаписаны.

Точность эксперимента: фактически выделяется общий блок malloc(30); в нём условно выделены 10 байт под буфер и 4 байта под число. Запись 20 байт остаётся в пределах выделенных 30 байт, но нарушает логическую границу буфера. Это воспроизводимая демонстрация порчи соседних данных, а не настоящий выход за конец malloc(10), поведение которого непредсказуемо.

Почему байты 10 и 11 не равны 0x00?

После memset(memory, 0, 30) действительно всё равно нулю. Однако сразу затем выполняется memcpy(control, &controlValue, 4). Указатель control = memory + 10, поэтому число записывается в байты 10–13. Число 12345 = 0x00003039. AVR использует порядок Little-Endian — младший байт располагается первым.

ИндексДо записи контрольного числаПосле записи 12345После переполнения
90000FF
100039FF
110030FF
120000FF
130000FF
140000FF

Интерактивная карта памяти

Синий — логический буфер (0–9); зелёный — четыре байта контрольного числа (10–13); красный — повреждённое число.

После переполнения 0x00003039 превращается в 0xFFFFFFFF. При чтении как uint32_t получаем 4294967295. Если бы в этих четырёх байтах хранились частота генератора, заданный ток или таймер реле, алгоритм мог бы начать работать с заведомо неправильным параметром.

5. Результаты экспериментов в Serial Monitor

Режим 1 — утечка

=== AVR MEMORY TEST ===
MODE = 1
Cycle: 1  Data: Atmega / AVR MEMORY TEST
Cycle: 2  Data: Atmega / AVR MEMORY TEST
...
ERROR: MEMORY FULL!

Точное количество циклов до отказа зависит от версии Arduino core и расхода SRAM. Причина — потеря указателя без вызова free().

Режим 2 — malloc и free

=== AVR MEMORY TEST ===
MODE = 2
Cycle: 1  Data: Atmega / AVR MEMORY TEST
Cycle: 2  Data: Atmega / AVR MEMORY TEST
...
Cycle: 1000  Data: Atmega / AVR MEMORY TEST

Память каждого цикла возвращается распределителю. Но при переменных размерах блоков всё ещё возможна фрагментация heap.

Режим 3 — статическая память

Вывод как в режиме 2. Вместо запросов к heap применяется массив, существующий всё время работы программы. Это лучший вариант для фиксированного буфера.

Режим 4 — порча числа

=== AVR MEMORY TEST ===
MODE = 4
--- BEFORE ---
Control = 12345
Logical buffer: 10 bytes
Write attempt : 20 bytes
...
Byte 10 = 0x39
Byte 11 = 0x30
Byte 12 = 0x00
Byte 13 = 0x00
...
--- AFTER ---
Control BEFORE = 12345
Control AFTER  = 4294967295
...
Byte 10 = 0xFF
Byte 11 = 0xFF
Byte 12 = 0xFF
Byte 13 = 0xFF
...
ERROR: CONTROL VALUE CORRUPTED!
TEST FINISHED

6. Исправляем ошибку четвёртого режима

Исправление №1. Если логическому буферу разрешено 10 байт, записывайте не больше 10. В цикле замените i < WRITE_SIZE на i < SMALL_BUFFER_SIZE:

for (uint8_t i = 0; i < SMALL_BUFFER_SIZE; i++)
{
    buffer[i] = 0xFF;
}

В этом случае байты 10–13 остаются 39 30 00 00, а контрольное число — 12345.

Исправление №2. Если необходимо записывать все 20 байт, измените расположение данных: выделите 20 байт под буфер и только затем расположите четырёхбайтовое контрольное число. Для учебного примера достаточно такого фрагмента:

const size_t dataSize = 20;
const size_t numberSize = sizeof(uint32_t);
uint8_t *memory = (uint8_t *)malloc(dataSize + numberSize);
if (memory != NULL) {
    uint8_t *buffer = memory;
    uint8_t *control = memory + dataSize;
    uint32_t number = 12345UL;
    memcpy(control, &number, sizeof(number));
    memset(buffer, 0xFF, dataSize);
    memcpy(&number, control, sizeof(number));
    Serial.println(number); // 12345
    free(memory);
}

А если malloc действительно выделяет только 10 байт?

При реальном malloc(10) запись 20 байт уже выходит за границы физически выделенного блока. Она может повредить соседний блок, внутренние метаданные выделения памяти, стек либо проявиться совсем иначе. Нельзя рассчитывать, что рядом окажется нужная переменная или что результат всегда будет одинаковым. Именно поэтому в уроке используется контролируемое расположение полей внутри общего блока.

Итог: изначально число было 12345; запись большего количества данных, чем разрешено логическому буферу, уничтожила его байты. Ошибка исправляется проверкой длины записи, правильным расчётом размеров и, когда возможно, использованием статических массивов.