Динамическая память микроконтроллеров: четыре эксперимента с ошибками и их исправлением
Практический урок для ATmega328P / ATmega328PB в Arduino IDE: изучаем malloc(), free(), утечки памяти и повреждение управляющей переменной из-за записи за границы буфера.
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 | После переполнения |
|---|---|---|---|
| 9 | 00 | 00 | FF |
| 10 | 00 | 39 | FF |
| 11 | 00 | 30 | FF |
| 12 | 00 | 00 | FF |
| 13 | 00 | 00 | FF |
| 14 | 00 | 00 | FF |
Интерактивная карта памяти
После переполнения 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 байт уже выходит за границы физически выделенного блока. Она может повредить соседний блок, внутренние метаданные выделения памяти, стек либо проявиться совсем иначе. Нельзя рассчитывать, что рядом окажется нужная переменная или что результат всегда будет одинаковым. Именно поэтому в уроке используется контролируемое расположение полей внутри общего блока.