EEPROM, NVS и Preferences: что переживает выключение питания

Где Arduino и ESP32 хранят настройки и рекорды, как не прочитать мусор при первом запуске и не износить память

Переменная живёт, пока есть питание. Разбираем, куда Arduino и ESP32 складывают то, что должно пережить выключение, почему первый запуск читает мусор и сколько записей выдержит память.

Термометр на балконе показывает минимум за ночь. Утром выбило пробки, питание вернулось через минуту — и минимум снова равен текущей температуре. Программа не ошиблась: все её переменные лежали в оперативной памяти, а она без питания не хранит ничего. Чтобы число пережило выключение, его надо положить в энергонезависимую память, и у Arduino и ESP32 она устроена по-разному.

Ниже — где эта память находится, как с ней работать через EEPROM и Preferences, почему первый запуск читает мусор и сколько записей она выдерживает до износа.

Что стирается, а что остаётся

У микроконтроллера несколько видов памяти, и переживают они разное.

Где лежит значениеСброс кнопкойГлубокий сон ESP32Снятие питанияПредел записей
Обычная переменная (ОЗУ)нетнетнетнет предела
RTC_DATA_ATTR на ESP32нетданетнет предела
EEPROM ATmega328Pда—даоколо 100 000 на ячейку
NVS во флеше ESP32дададаоколо 100 000 стираний сектора

Строка про RTC_DATA_ATTR — частая ловушка. Такая переменная лежит в памяти часов реального времени, которая остаётся под питанием во время глубокого сна. Поэтому в примере глубокого сна счётчик wakeups растёт от пробуждения к пробуждению. Но это не хранилище: после снятия питания или программной перезагрузки она снова получает начальное значение.

Энергонезависимых мест два: у ATmega328P — отдельная EEPROM на 1024 байта, у ESP32 — раздел NVS во внешней флеш-памяти. Принцип у них один, а подробности различаются настолько, что их стоит разобрать по отдельности.

EEPROM на Arduino: килобайт побайтно

EEPROM в ATmega328P — это 1024 ячейки по байту с собственными адресами. Библиотека EEPROM.h даёт к ним пять функций: read и write для одного байта, update — запись, которая сначала сравнивает и не трогает ячейку, если там уже то же значение, и get/put для структур целиком. Запись одного байта занимает около 3,3 мс, и всё это время процессор ждёт.

Важная деталь: EEPROM.put внутри вызывает update для каждого байта. Если из десяти байт структуры изменились два, записаны будут только два. Это бесплатная экономия ресурса, и ей стоит пользоваться.

Вторая деталь — что лежит в EEPROM у новой платы. Стёртая ячейка содержит 0xFF. Если прочитать в float четыре байта 0xFF, получится NaN, а в int — минус единица. Программа, которая при первом запуске доверяет прочитанному, начнёт с мусора. Поэтому в термометре на Nano рядом с данными хранится признак:

const uint16_t MAGIC = 0xC0DE;   // признак «в EEPROM наши данные, а не мусор»

struct Records { uint16_t magic; float lo, hi; } rec;

void setup() {
  EEPROM.get(ADDR, rec);
  if (rec.magic != MAGIC) resetRecords();   // первый запуск: пишем начальные
}

Структура занимает 10 байт: два на признак и по четыре на каждый float. При первом запуске magic читается как 0xFFFF, не совпадает с 0xC0DE, и resetRecords() записывает начальные значения: минимум 1000, максимум −1000. Такие числа выбраны нарочно — первое же измерение побьёт оба рекорда.

Признак спасает и при смене формата. Если в новой версии прошивки в структуру добавится поле, старые данные прочитаются со сдвигом. Поменяйте MAGIC вместе со структурой, и новая прошивка честно начнёт с чистого листа, вместо того чтобы трактовать старые байты по-новому.

Arduino Nano: термометр, который помнит минимум и максимум после выключения

Поставьте датчику температуру 35 °C, дождитесь строки «записано в EEPROM», остановите симуляцию и запустите снова: первая строка покажет «из EEPROM: мин …, макс 35.0»

Открыть термометр

NVS и Preferences на ESP32

У ESP32 отдельной EEPROM нет. В стандартной таблице разделов Arduino для флеша на 4 МБ под NVS отведено 20 КБ по адресу 0x9000. NVS — это хранилище «ключ — значение»: у каждой записи есть пространство имён, имя и тип. Работать с ним удобнее всего через библиотеку Preferences, которая входит в ядро arduino-esp32.

Preferences prefs;

prefs.begin("device", true);             // true — только чтение
int bright  = prefs.getInt("bright", 50);  // 50 — если ключа ещё нет
float thr   = prefs.getFloat("thr", 25.5);
String name = prefs.getString("name", "прибор");
prefs.end();

Это фрагмент из примера настроек прибора. Второй аргумент каждого get решает ту же задачу, что MAGIC на Arduino: если ключа нет, возвращается значение по умолчанию, а не мусор. На первом запуске пространства device ещё не существует, begin в режиме чтения вернёт false, и все get отдадут значения по умолчанию — ровно то, что нужно.

Имя пространства и имя ключа — не длиннее 15 знаков. Пространства разделяют ключи: ваш count в пространстве boot не столкнётся с count чужой библиотеки.

Устроена NVS не так, как EEPROM. Флеш нельзя переписать по байту: стереть можно только сектор в 4 КБ целиком. Поэтому NVS пишет журналом: новое значение ключа дописывается в свободное место страницы, а старое помечается как недействительное. Когда страница заполняется, живые записи переносятся на чистую, а старая стирается. Записи расходятся по всему разделу, и износ распределяется сам. Бонус такой схемы — устойчивость к пропаже питания посреди записи: запись либо завершена, либо её нет. У EEPROM структура из десяти байт пишется около 33 мс побайтно, и выключение в этот момент оставит половину старых данных и половину новых.

Библиотека EEPROM на ESP32 тоже есть, но это эмуляция поверх NVS: EEPROM.begin(размер) читает образ в ОЗУ, а на флеш он попадает только после EEPROM.commit(). Забытый commit() — частая причина жалоб «на ESP32 EEPROM не сохраняет».

Сколько записей выдержит память

Обе памяти рассчитаны примерно на 100 000 циклов: EEPROM — на ячейку, флеш — на стирание сектора. Много это или мало, зависит от того, как часто писать.

Для EEPROM всё считается прямо. Если писать одну и ту же ячейку каждые 2 секунды, как измеряет термометр из примера: 100 000 × 2 с = 200 000 с, это около 55 часов. Раз в минуту — 100 000 минут, около 69 суток. Запись только при новом рекорде случается несколько раз в день, и ячейки хватит на десятилетия.

Для NVS нужно учесть журнал. Каждое целое число занимает запись в 32 байта, на странице их помещается 126, страниц в разделе пять, одна держится свободной для переноса. Одна страница стирается примерно раз в 4 × 126 ≈ 500 записей, и 100 000 стираний дают порядка 50 миллионов записей. Оценка грубая, но порядок верный.

Как пишемEEPROM, одна ячейкаNVS, один ключ-число
в loop() без задержкиминутычасы
раз в секундуоколо 28 часовпорядка полутора лет
раз в минутуоколо 69 сутоксрок службы прибора
только при изменениигодысрок службы прибора

putInt() в loop() без задержки выполняет сотни и тысячи записей в секунду, и 50 миллионов кончаются за часы. Об этом предупреждает комментарий в примере настроек: писать настройки в loop() значит убить NVS за сутки. Строки занимают по записи на каждые 32 байта текста и расходуют ресурс быстрее чисел.

Правило одно: писать тогда, когда значение изменилось, и не чаще, чем нужно. В рекордах NVS к этому добавлен запас:

int raw = analogRead(POT);
if (raw > best + 20) {          // запас, чтобы шум не гонял запись
  best = raw;
  prefs.putInt("max", best);
}

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

Типичные ошибки

  1. Нет значения по умолчанию или признака. Первый запуск читает 0xFF или пустоту и работает с NaN, нулевой яркостью, порогом −1.
  2. Запись в каждом проходе loop(). Прибор работает неделю и начинает терять настройки. Искать причину в коде бесполезно: код правильный, память изношена.
  3. begin(..., true) и попытка записи. В режиме только чтения put не пишет, а возвращает ноль. Если результат не проверяют, ошибка молчаливая.
  4. Длинное имя. Пространство thermometer_settings длиннее 15 знаков, и в зависимости от версии библиотеки имя либо обрежется, либо begin() вернёт ошибку. Держите имена короткими и проверяйте результат begin().
  5. Смена структуры без смены признака. Новая прошивка читает старые байты по новой раскладке.
  6. RTC_DATA_ATTR вместо хранилища. Переживает сон, но не выключение.

Что проверить в симуляторе

С версии 1.8.14 EEPROM Arduino в PinPort сохраняется между запусками той же платы, пока страница открыта: остановили симуляцию, запустили снова — рекорды термометра на месте. Сценарий примера это и проверяет: ставит датчику 35 °C и ждёт в мониторе порта строку «макс 35.0». Кнопка на D2 сбрасывает рекорды, и после сброса признак MAGIC записывается заново вместе с начальными значениями.

У ESP32 то же самое с NVS и файловой системой SPIFFS: с версии 1.8.15 разделы данных флеш-памяти переживают «Стоп» и новый «Запустить» той же платы, пока страница открыта. Счётчик запусков показывает это буквально: каждый запуск печатает номер на единицу больше. Внутри одного прогона NVS переживает и ESP.restart(), и сторожевой таймер, и глубокий сон — модель перезапускается, а флеш остаётся прежним. Данные возвращаются только в раздел с тем же именем и адресом: сменили схему разделов — старые данные не лягут, как не легли бы и на плате. Тот же опыт с настройками прибора: яркость при каждом старте прибавляет 10 и сохраняется, после 100 возвращается к нулю.

Итог

Оперативная память без питания не хранит ничего, RTC_DATA_ATTR переживает только сон. Настоящие хранилища — EEPROM на Arduino и NVS на ESP32, и у обоих два правила: при первом чтении не доверять пустой памяти (признак или значение по умолчанию) и писать только то, что изменилось. Первое правило защищает от мусора, второе — от износа, который проявится через неделю работы и который в коде не найти.

Попробуйте в симуляторе

NVS: счётчик запусков

Запустите, нажмите «Стоп» и запустите снова: номер запуска вырастет на единицу — NVS пережила выключение. Потом добавьте в loop() delay(3000) и ESP.restart(): номер будет расти и при каждой перезагрузке

Запустить в симуляторе

NVS: сохранение рекордов

Уберите запас +20 из условия и покрутите потенциометр медленно: строк «сохранён» станет в разы больше — каждая из них стоила бы записи во флеш

Рекорды в NVS

Глубокий сон по таймеру

Уберите RTC_DATA_ATTR у переменной wakeups: после каждого пробуждения номер станет равен единице, потому что обычная память сон не переживает

Глубокий сон

Конфиг в JSON и в памяти

Добавьте в чтение новое поле mode со значением по умолчанию 1 и выведите его в порт: в старой сохранённой строке поля нет, но прошивка получит единицу, а не ноль

Конфиг в JSON

← Все статьи