Память Arduino и ESP32: куда уходит ОЗУ и как её экономить
Скетч скомпилировался, залился и работает — но через минуту плата виснет, шлёт в монитор порта кракозябры или уходит в бесконечную перезагрузку. Одна из самых частых причин такого поведения — нехватка оперативной памяти, и компилятор обычно предупреждает о ней заранее, строкой, которую легко пропустить. Разбираемся, что означает вывод про 2048 байт, куда уходит ОЗУ и как её вернуть.
Что говорит компилятор
После сборки Arduino IDE печатает две строки:
Sketch uses 8102 bytes (26%) of program storage space. Maximum is 30720 bytes.
Global variables use 1953 bytes (95%) of dynamic memory, leaving 95 bytes for local variables.
Maximum is 2048 bytes.
Low memory available, stability problems may occur.
Первая строка — про flash, где лежит сама программа. Вторая — про SRAM, оперативную память, и именно она обычно становится проблемой. Число 2048 в конце — не ошибка и не лимит Arduino IDE, а весь объём ОЗУ микроконтроллера ATmega328P, который стоит на UNO и Nano. Два килобайта. Не мегабайта.
Заодно объясняется и первая строка: у ATmega328P 32 КБ флеша, но доступны скетчу 30 720 байт — остальное занимает загрузчик, который принимает прошивку по USB.
Low memory available — предупреждение, а не ошибка: скетч соберётся и зальётся.
Плата просто начнёт вести себя непредсказуемо, причём не сразу, а когда программа
дойдёт до участка, которому не хватит места. Отлаживать такое тяжело — лучше не
доводить.
Три вида памяти
| Тип | Что хранит | Живёт после выключения |
|---|---|---|
| Flash | Код программы и константы | Да |
| SRAM | Переменные во время работы | Нет |
| EEPROM | Настройки, которые нужно сохранять | Да |
Сколько чего есть на распространённых платах:
| Плата | Flash | SRAM | EEPROM |
|---|---|---|---|
| Arduino UNO / Nano | 32 КБ | 2 КБ | 1 КБ |
| Arduino Mega 2560 | 256 КБ | 8 КБ | 4 КБ |
| Arduino Leonardo / Micro | 32 КБ | 2,5 КБ | 1 КБ |
| ESP32 | 4 МБ | 520 КБ | Эмулируется во flash |
Разница между UNO и ESP32 — в 260 раз. Именно поэтому советы «просто используй
String», безобидные для ESP32, ломают проект на Nano.
Куда уходят два килобайта
Строковые литералы. Каждая строка в кавычках по умолчанию копируется из flash в ОЗУ при старте и остаётся там навсегда. Двадцать сообщений отладки по 40 символов — это 800 байт, почти половина всей памяти UNO, ещё до первой полезной переменной.
Массивы и буферы. Массив int values[200] на AVR занимает 400 байт: тип int
здесь двухбайтовый.
Буферы библиотек. Библиотека дисплея может держать кадровый буфер, сетевая — буфер пакета. Подключили две-три библиотеки — и половины ОЗУ нет.
Класс String. Память он выделяет динамически, и если новая строка помещается в
уже выделенную ёмкость, повторного выделения не происходит — вопреки расхожему мнению,
String не перевыделяет буфер на каждом чихе. Проблема тоньше: на AVR буфер растёт
ровно до нужного размера, без запаса, поэтому s += "x" в цикле вызывает
realloc практически на каждой итерации и дробит кучу. Заканчивается это отказом
выделения памяти — свободные байты формально есть, но одним куском их уже не собрать.
Лечится заранее зарезервированным местом: s.reserve(64).
На ESP32 ядро устроено умнее: короткие строки живут прямо внутри объекта, не трогая
кучу, а буфер округляется до 16 байт, так что запас на рост есть. Там в некритичном
коде String вполне допустим — а вот на AVR от него лучше отказаться сразу.
Макрос F(): первое, что стоит сделать
Обёртка F() оставляет строку во flash и читает её оттуда по мере вывода. Правка
занимает секунду, а на типичном скетче с отладочными сообщениями освобождает
несколько сотен байт.
// Было: строка копируется в ОЗУ
Serial.println("Инициализация завершена");
// Стало: строка остаётся во flash
Serial.println(F("Инициализация завершена"));
Работает не со всяким API: принимающая функция должна иметь перегрузку под
__FlashStringHelper*. У Serial.print(), lcd.print() и большинства популярных
библиотек она есть, но если библиотека ждёт обычный const char*, обёртка не
скомпилируется — там придётся оставить литерал как есть или копировать строку из
флеша вручную. На ESP32 макрос тоже существует и ничего не ломает, хотя выигрыш там
некритичен.
PROGMEM для таблиц данных
Если во flash нужно убрать не строку, а массив констант — таблицу калибровки, ноты
мелодии, картинку для дисплея, — используется атрибут PROGMEM. Читать такие данные
обычным индексом нельзя, нужны специальные функции.
#include <avr/pgmspace.h>
const uint16_t noteTable[] PROGMEM = {262, 294, 330, 349, 392, 440, 494};
void setup() {
Serial.begin(9600);
for (uint8_t i = 0; i < 7; i++) {
uint16_t note = pgm_read_word(¬eTable[i]);
Serial.println(note);
}
}
void loop() {
}
Функция чтения зависит от размера элемента: pgm_read_byte() для однобайтовых,
pgm_read_word() для двухбайтовых, pgm_read_dword() для четырёхбайтовых.
Поведение PROGMEM за пределами AVR различается, и путать эти два случая опасно. На
ESP32 атрибут объявлен ради совместимости и фактически ничего не меняет:
константы и так лежат во флеше, а единое адресное пространство позволяет читать их
обычным индексом. На ESP8266 он, наоборот, работает по-настоящему — размещает
данные в секции флеша и требует тех же pgm_read_* для чтения, что и на AVR.
Замена String на char[]
Самая результативная правка в старых скетчах — отказ от String в пользу массивов
символов фиксированного размера.
// Было: динамическое выделение, фрагментация кучи
String message = "Температура: ";
message += temperature;
message += " C";
Serial.println(message);
// Стало: буфер известного размера, куча не трогается
char message[32];
snprintf(message, sizeof(message), "Температура: %d C", temperature);
Serial.println(message);
snprintf() не даст выйти за границу буфера — он обрежет строку по указанному
размеру. Это заметно безопаснее, чем sprintf(), которому размер не передают.
Для разбора строк вместо методов String используйте strtok(), strcmp() и
atoi() из стандартной библиотеки C.
Правильные типы данных
На AVR размеры типов отличаются от привычных по «большому» программированию:
| Тип | Размер на AVR | Диапазон |
|---|---|---|
bool | 1 байт | false / true |
uint8_t | 1 байт | 0…255 |
int8_t | 1 байт | −128…127 |
int, int16_t | 2 байта | −32 768…32 767 |
long, int32_t, float | 4 байта | — |
double | 4 байта | То же, что float |
Счётчик цикла до ста не нуждается в int — хватит uint8_t, и это вдвое меньше
памяти на каждой переменной. В массиве из двухсот элементов экономия составит уже
200 байт, десятую часть всей памяти UNO.
Отдельно стоит запомнить: на AVR double не даёт никакой дополнительной точности по
сравнению с float — компилятор делает их одним и тем же четырёхбайтовым типом. На
ESP32 double настоящий, восьмибайтовый.
Как измерить свободную память
Предупреждение компилятора считает только глобальные переменные. Локальные, стек и куча растут уже во время работы, и увидеть реальную картину можно только изнутри программы.
Для AVR-плат:
int freeMemory() {
extern int __heap_start, *__brkval;
int stackTop;
return (int) &stackTop - (__brkval == 0 ? (int) &__heap_start : (int) __brkval);
}
void loop() {
Serial.print(F("Свободно: "));
Serial.println(freeMemory());
delay(1000);
}
Для ESP32 всё проще, функции встроены:
void loop() {
Serial.printf("Свободно: %u, минимум за сеанс: %u, макс. блок: %u\n",
ESP.getFreeHeap(),
ESP.getMinFreeHeap(),
ESP.getMaxAllocHeap());
delay(1000);
}
Смотреть нужно на два числа. getMinFreeHeap() показывает, насколько близко
программа подходила к краю за всё время работы. А разрыв между getFreeHeap() и
getMaxAllocHeap() — это мера фрагментации: если свободно 80 КБ, а наибольший
доступный кусок всего 5 КБ, куча раздроблена и следующее крупное выделение упадёт.
Если свободная память медленно, но уверенно убывает от цикла к циклу — в программе
утечка. Ищите new без delete, malloc() без free() или растущий в цикле
String.
Стек, куча и почему 95% ещё не приговор
SRAM делится на три области. Внизу лежат глобальные переменные — их размер известен при компиляции, именно они попадают в отчёт. Сверху вниз растёт стек: локальные переменные, аргументы функций, адреса возврата. Снизу вверх растёт куча — всё, что выделено динамически.
Крах наступает, когда стек и куча встречаются: значения затирают друг друга, и программа начинает выполнять мусор. Внешне это выглядит как случайные зависания без всякой закономерности.
Поэтому 95% в отчёте компилятора — это не «осталось 5% запаса», а «на весь стек и кучу остаётся 95 байт». Глубокая вложенность функций или рекурсия съедят их мгновенно. Ориентируйтесь на не больше 75% занятой динамической памяти.
Память на ESP32
Полмегабайта ОЗУ снимают остроту проблемы, но не отменяют её: Wi-Fi-стек забирает около 50 КБ, TLS-соединение — ещё до 40 КБ на каждое, буфер дисплея на 320×240 в 16-битном цвете весит 150 КБ.
Когда встроенной памяти мало, помогает PSRAM — внешняя микросхема на платах с
маркировкой вроде N16R8. Включается в Tools → PSRAM → Enabled, после чего
крупные буферы можно размещать явно в ней:
uint8_t* buffer = (uint8_t*) ps_malloc(200000);
if (buffer == nullptr) {
Serial.println("PSRAM недоступна");
}
PSRAM заметно медленнее внутренней SRAM, поэтому туда выносят большие редко используемые буферы, а не рабочие переменные. Подробнее о том, у каких плат она есть, — в статье про выбор платы ESP32.
Чек-лист
- Обернуть строковые литералы в
F()— везде, где принимающий API это поддерживает. - Убрать
String, заменить наchar[]иsnprintf(), либо хотя бы зарезервировать ёмкость черезreserve(). - Перевести константные таблицы в
PROGMEM. - Заменить
intнаuint8_tтам, где хватает диапазона. - Уменьшить размеры буферов и массивов до реально нужных.
- Проверить свободную память в работе, а не только отчёт компилятора.
- Держать занятость динамической памяти ниже 75%.
Что дальше
- Разбор остальных сообщений компилятора — Ошибки Arduino IDE.
- Сколько памяти у разных плат ESP32 — Какую ESP32 выбрать.
- Настройка среды под платы Arduino — Настройка Arduino IDE для разных плат Arduino.