Инструкции

Память Arduino и ESP32: куда уходит ОЗУ и как её экономить

12 мин чтения·Август 2026

Скетч скомпилировался, залился и работает — но через минуту плата виснет, шлёт в монитор порта кракозябры или уходит в бесконечную перезагрузку. Одна из самых частых причин такого поведения — нехватка оперативной памяти, и компилятор обычно предупреждает о ней заранее, строкой, которую легко пропустить. Разбираемся, что означает вывод про 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Настройки, которые нужно сохранятьДа

Сколько чего есть на распространённых платах:

ПлатаFlashSRAMEEPROM
Arduino UNO / Nano32 КБ2 КБ1 КБ
Arduino Mega 2560256 КБ8 КБ4 КБ
Arduino Leonardo / Micro32 КБ2,5 КБ1 КБ
ESP324 МБ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(&noteTable[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Диапазон
bool1 байтfalse / true
uint8_t1 байт0…255
int8_t1 байт−128…127
int, int16_t2 байта−32 768…32 767
long, int32_t, float4 байта
double4 байтаТо же, что 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.

Чек-лист

  1. Обернуть строковые литералы в F() — везде, где принимающий API это поддерживает.
  2. Убрать String, заменить на char[] и snprintf(), либо хотя бы зарезервировать ёмкость через reserve().
  3. Перевести константные таблицы в PROGMEM.
  4. Заменить int на uint8_t там, где хватает диапазона.
  5. Уменьшить размеры буферов и массивов до реально нужных.
  6. Проверить свободную память в работе, а не только отчёт компилятора.
  7. Держать занятость динамической памяти ниже 75%.

Что дальше

Связанные статьи