Термостат для инкубатора я переносил с Uno на ESP32 в полной уверенности, что сюрпризов не будет: настройки — они и на новой плате настройки. Привычная карта из первых уроков про EEPROM переехала целиком: байт 0 — magic, байты 2-3 — целевая температура, байты 4-5 — гистерезис. На ESP32 это даже заработало. А потом я добавил в ту же память параметры сети, следом расписание кормлений — и карта адресов превратилась в минное поле: свободные байты кончились быстрее, чем я рассчитывал, и каждый новый параметр заставлял перечитывать весь расклад и двигать чужие поля.
Финальный тупик был смешнее. Я завёл ключ с именем temperature_target — восемнадцать символов, — записал его и стал читать обратно. Всегда возвращался дефолт. Три часа я гонял питание, датчик и шину, пока не нашёл в документации строку, объяснившую всё: имя ключа длиннее пятнадцати символов NVS не принимает, запись тихо не выполняется, а put возвращает ноль — ноль записанных байт. Никто не ругается, скетч не падает, просто молчит. С тех пор я строю хранение на ESP32 по его правилам, и они честно удобнее байтовых карт — об этом и урок.
Namespace: отдельная полка для каждой группы настроек
Хранилище ESP32 называется NVS — non-volatile storage: таблица пар ключ-значение внутри флеша, а не плоский массив байтов. Сверху над ним живёт библиотека Preferences, и разработчики ESP32 прямо называют её заменой EEPROM для Ардуино. Работа начинается с полки, то есть namespace: prefs.begin с аргументом device открывает пространство имён device, и все последующие put и get видят только его. Второй аргумент begin переводит полку в режим read-only — удобно для справочных данных, которые прошивка лишь читает. Закончив работу, зовёте prefs.end().
Правила у полок строгие, перечислю раз и навсегда. Имя namespace живёт по тем же правилам, что имя ключа: до пятнадцати символов. Разных полок на раздел — не больше 254. Сам namespace занимает в хранилище отдельную запись, как обычный ключ, — бесплатных полок не бывает. Зато внутри полки адреса не нужны вовсе: вы зовёте ключ по имени, а где он лежит физически — забота библиотеки. Мой расклад для прибора: полка device под рабочие настройки, полка net под сеть из первого урока про WiFi на ESP32, полка stats под счётчики. Три полки — и ни один новый параметр не двигает чужие байты.
Ключи типизированы, и это вторая приятная неожиданность после плоской EEPROM: putUShort кладёт беззнаковое короткое, putFloat — дробную калибровку, putBool — флаг, putString — строку, putBytes — сырой блоб, хоть целую структуру из урока про put и get. Чтение симметрично: get с обязательным вторым аргументом — значением по умолчанию для ключа, которого ещё нет. Это встроенный ответ на вопрос про чистую память — та роль, которую на Uno играл magic-байт.
#include <Preferences.h> Preferences prefs; void setup() { Serial.begin(115200); prefs.begin("device", false); // полка device, чтение и запись int tgt = prefs.getShort("tgt", 38); // второго аргумента нет на ключе - // считается дефолтом первого запуска Serial.print("tgt="); Serial.println(tgt); prefs.putUShort("cyc", 4021); // счётчик циклов: беззнаковое короткое prefs.putFloat("calib", 1.0625); // калибровочный коэффициент prefs.putBool("buzz", true); // флаг зуммера Serial.print("cyc="); Serial.println(prefs.getUShort("cyc", 0)); Serial.print("calib="); Serial.println(prefs.getFloat("calib", 0)); Serial.print("buzz="); Serial.println(prefs.getBool("buzz", false)); prefs.end();} void loop() { delay(1000);}В коде выше три вещи, которые я советую принять как привычку. Первая: каждое чтение с дефолтом, поэтому первый запуск прибора не требует отдельного кода инициализации. Вторая: запись возвращает число записанных байт, и мой случай с длинным ключом лечится одной строкой отладки — если put вернул ноль, печатаю имя ключа и значение. На этапе разработки, а не после месяца на объекте. Третья: put и get работают с готовым типом целиком — никакой раскладки на байты тут не нужно, библиотека делает её сама.
Размер записи стоит знать, чтобы не удивляться, куда уходит место. Внутри раздела любая пара занимает запись ровно 32 байта — такое выравнивание выбрано ради совместимости с шифрованием флеша. Целочисленные значения укладываются в одну запись, строки и блобы — по 32 байта на каждый кусок: строка в сорок символов съест две записи. Отсюда моё правило схемы ключей: имена короткие, я держу их до десяти символов — tgt, hyst, calib, cyc, — а весь смысловой префикс уезжает в имя полки. И никогда не переименовывайте ключи между версиями прошивки: старая прошивка на другом приборе прочитает пустоту и молча подставит дефолт.
Когда параметров много и меняются они вместе, есть соблазн упаковать всё в один блоб: меньше записей, одно имя. Компромисс такой — обновление одного поля перезаписывает блоб целиком, поэтому для настроек, которые крутят кнопками, я оставляю отдельные ключи, а блоб берегу для редких пакетов вроде расписания кормлений. Версию схемы данных при этом я всегда кладу отдельным коротким ключом ver в ту же полку: при чтении сначала смотрю версию, потом решаю, как толковать остальные поля.
Лимиты: 20 КБ по умолчанию и износ, о котором можно не думать
Теперь потолок. В стандартной таблице разделов ESP32 с флешем на 4 МБ под nvs отведено 0x5000 — это 20480 байт, те самые двадцать килобайт из заголовка. Делим на 32 байта записи — выходит порядка шестисот пар в лучшем случае, на практике меньше: полки, строки и блобы съедают свою долю. Для прибора с двумя десятками настроек запас столетний. А вот показания раз в секунду или журнал на тысячи записей в NVS не влезут — потоковым данным место на карте, как в уроке про логирование на SD. И цифра 0x5000 — дефолт, а не закон: таблицу разделов можно переверстать и отдать nvs хоть 64 КБ, отняв их у файловой системы или OTA-слотов.
Износ — тема, из-за которой мы в этом разделе вообще считали записи, — в NVS решён внутри библиотеки. Новые записи дописываются в свободные места раздела, устаревшие помечаются недействительными, при заполнении идёт уплотнение: физически каждая запись попадает в разные места флеша, и ресурс размазывается по всему разделу. Кольца из ячеек из урока про ресурс и ручная экономия из урока про update здесь не нужны. Но правило писать по событию я всё равно соблюдаю: каждая запись — это стирание страниц флеша, а у страниц собственный конечный ресурс.
Последний навык — посмотреть, что вообще лежит в полке. У Preferences собственного перебора ключей нет, зато он есть у низкоуровневого API: функция nvs_entry_find идёт по записям раздела и отдаёт имя и тип каждого ключа. Я держу такую функцию в отладочной прошивке: перед выпуском прибора смотрю, что накопилось в памяти за месяцы тестов, и вычищаю ключи, оставшиеся от старых версий. Та же гигиена, что и печать всей байтовой карты на Uno, только список ключей живой и с типами.
| Задача | На Uno через EEPROM | На ESP32 через NVS |
|---|---|---|
| Один байт настройки | ячейка по адресу N | putUChar в своей полке |
| Целое int | два байта через /256 и %256 | putShort одной строкой |
| Дробное float | четыре байта через union | putFloat одной строкой |
| Структура целиком | EEPROM.put по адресу | putBytes блобом или пачка ключей |
| Защита от мусора | magic и контрольная сумма вручную | тип ключа плюс дефолт при чтении |
| Износ | update, гистерезис, кольца ячеек | выравнивание износа внутри NVS |
- NVS на ESP32 — хранилище пар ключ-значение, Preferences — официальная замена EEPROM-библиотеки
- prefs.begin открывает namespace — полку до 15 символов; разных полок на разделе не больше 254
- Ключи типизированы: putUShort, putFloat, putBool, putString; get всегда с дефолтом для чистой памяти
- Запись возвращает число записанных байт: ноль — ключ не принят, чаще всего имя длиннее 15 символов
- Раздел nvs по умолчанию 20 КБ (0x5000), запись 32 байта: сотни настроек влезают, потоковые данные — нет
- Износ NVS размазывает сама: кольца и ручной update не нужны, но писать по событию всё равно правильно
- 1. Какой максимальной длины может быть имя ключа или namespace в NVS?
- 2. putUShort вернул 0. Что произошло?
- 3. Сколько места внутри раздела занимает одна целочисленная пара ключ-значение?
- 4. Почему на ESP32 не нужны кольцевые счётчики износа, как на Uno?
- 5. Как перебрать все ключи полки, если у Preferences своего перебора нет?
На Uno байтовая карта EEPROM остаётся честным решением — там просто нет ничего другого, и двадцать настроек в один килобайт укладываются без борьбы. Но как только проект переезжает на ESP32, я переношу хранение на Preferences: ключи вместо адресов, дефолты вместо magic-байтов, износ размазывает сама библиотека. Пара коротких ключей заменяет всю карту памяти, которую я когда-то рисовал на бумажке и вешал над столом.
Одна оговорка для батарейных приборов: NVS пишет во флеш, а флеш во время записи тянет ток заметно бодрее EEPROM. Если прибор спит годами и просыпается на секунду, сначала посмотрите на RTC-память из урока про данные между снами — восемь килобайт без единой записи во флеш. А следующий урок вернёт нас на Uno: три заготовки кода, которые я таскаю из проекта в проект уже четвёртый год.
Частые вопросы по теме урока
- Какой максимальной длины может быть имя ключа или namespace в NVS?
- Имя длиннее пятнадцати символов NVS не принимает: put вернёт ноль записанных байт, get молча отдаст дефолт. Namespace живёт по тем же правилам, что и ключи. 15 символов
- putUShort вернул 0. Что произошло?
- Возвращаемое значение — количество записанных байт. Ноль значит, что записи не было: длинное имя, закрытая полка или переполненный раздел. Пара не записалась: например, имя ключа длиннее 15 символов
- Сколько места внутри раздела занимает одна целочисленная пара ключ-значение?
- Выравнивание записи под шифрование флеша — 32 байта. Строки и блобы занимают по несколько таких записей: 32 байта на каждый кусок. 32 байта — запись целиком
- Почему на ESP32 не нужны кольцевые счётчики износа, как на Uno?
- Записи дописываются в свободные места, старые помечаются недействительными, заполнение уплотняется. Ресурс размазывается по всему разделу без ваших усилий. Библиотека сама размазывает записи по разделу и уплотняет хранилище
- Как перебрать все ключи полки, если у Preferences своего перебора нет?
- nvs_entry_find идёт по записям раздела и отдаёт имя и тип каждого ключа. Я держу такой аудит в отладочной прошивке перед выпуском прибора. Низкоуровневой функцией nvs_entry_find из API ESP-IDF