Кормушка для кота с расписанием: ESP32, часы реального времени, шнек от степпера. Порции и порог граммов я хранил по привычке с Uno — через EEPROM.h, и всё работало ровно до того вечера, когда мне понадобился ещё один ключ: буфер стал больше, половина значений уехала, кот остался без ужина. Вскрытие показало скучную правду: на ESP32 привычного EEPROM нет вовсе, а EEPROM.h — эмуляция с буфером в оперативной памяти и одним блобом во флеше. С тех пор вся память настроек на моих платах живёт в Preferences, и урок — о том, как я этот инструмент устроил для себя: namespace под задачу, ключи с типом в имени, заводские дефолты в одном месте.
Физику NVS и семантику begin/end — журнал записей, уплотнение страниц, износ секторов — я разбирал в разделе про EEPROM, в уроке про Preferences оттуда же; там же лежит словарь миграции с EEPROM.h, повторять его не буду. Здесь — этаж выше: как спроектировать набор ключей, чтобы через полгода в собственном коде не заблудиться, какие ловушки таит типизация и где Preferences уместен, а где избыточен. Кода немного, решений много.
Ключи, типы и тихие дефолты
Preferences — хранилище ключ-значение. begin("имя") открывает namespace — группу ключей с именем до пятнадцати символов; putUChar, putShort, putFloat, putString кладут значения, а зеркальные get-вызовы принимают дефолт прямо в аргументе: ключа нет — вернётся ваша заводская цифра, без обвязки из проверок. Главная деталь, о которой tutorials пишут вскользь: с каждым ключом хранится метка типа. getType("porog") вернёт числовой тег, isKey("porog") подскажет, есть ли ключ вовсе, — и чтение чужим типом проходит молча: getFloat по ключу, записанному putShort, честно вернёт ваш дефолт, а не сохранённое значение.
Моя грабля из кормушки: порог писал putShort, через месяц читал getFloat — получил дефолт и неделю подозревал питание. Диагноз скучный: тип не совпал, функция молча отдала запасную цифру. С тех пор тип живёт прямо в имени ключа: porog_i16, svet_u8, imya_str — и чтение всегда тем же вызовом, что и запись. Вторая привычка: putXX возвращает размер записанного — ноль означает отказ: namespace открыт только для чтения, ключ длиннее пятнадцати символов или раздел забит. Ноль легко не заметить, а это единственный намёк, что запись прошла мимо.
| Что храним | Где и как | Почему так |
|---|---|---|
| заводские калибровки, серийный номер | namespace cal, открыт в read-only | пишутся один раз при прошивке; случайная запись возвращает ноль, а не портит значения |
| пользовательские настройки | namespace cfg, ключи с типом в имени | запись по событию: кнопка, команда из сети, смена режима |
| счётчики и статистика | namespace stats, putUInt | приращения копят износ — держу отдельно, чтобы не мешали настройкам |
| переменные цикла сна | RTC_DATA_ATTR в RAM | не флеш: переживает сон, умирает с питанием, износа ноль |
#include <Preferences.h> Preferences cfg; // пользовательские настройки: пишем по событиюPreferences cal; // калибровки: read-only, пишутся только при прошивкеPreferences stats; // счётчики: приращение раз в режим работы int porog = 0;int svet = 0;String imya = ""; void loadDefaults() { // заводские цифры живут в одном месте porog = 25; // граммов на порцию svet = 80; // яркость подсветки, 0-255 imya = "node1";} void setup() { Serial.begin(115200); cfg.begin("cfg", false); // читать и писать cal.begin("cal", true); // только чтение: put здесь вернёт ноль stats.begin("stats", false); bool first = !cfg.isKey("ver"); // ключа нет — первый запуск if (first) { loadDefaults(); cfg.putUChar("ver", 1); cfg.putShort("porog_i16", porog); cfg.putUChar("svet_u8", svet); cfg.putString("imya_str", imya); Serial.println("pervyj zapusk: zalil zavodskie"); } else { porog = cfg.getShort("porog_i16", 25); // дефолт подстрахует svet = cfg.getUChar("svet_u8", 80); imya = cfg.getString("imya_str", "node1"); } Serial.print("porog="); Serial.println(porog); Serial.print("svet="); Serial.println(svet); Serial.print("imya="); Serial.println(imya); Serial.print("tip porog_i16 = "); Serial.println(cfg.getType("porog_i16")); // числовой тег типа из NVS} void loop() { // записи — по событию: кнопка, команда из сети, смена режима. Не в loop().}Разделение на namespace — не эстетика, а страховка. clear() стирает только открытый namespace: фабричный сброс пользовательских настроек не задевает калибровки и счётчики — я именно так и сбрасываю кормушку, долгим нажатием кнопки, и номер платы при этом цел. Счётчики в отдельном stats живут своей жизнью: у батарейных узлов счётчик снов удобнее держать в RAM, в RTC_DATA_ATTR, — это бесплатно и без износа, но живёт только пока есть питание; расклад у меня в уроке про RTC-память и счётчик снов. Всё, что обязано пережить вилку из розетки, — в cfg и stats.
Второй сдерживающий фактор — ресурс флеша: каждое приращение счётчика — физическая запись в сектор, и арифметика износа из урока про ресурс EEPROM переносится сюда без поправок; встроенное уплотнение NVS размазывает износ по разделу, но не отменяет его. У кормушки записи случаются единицами в день — запас на десятилетия. У батарейного узла с циклами сна я считаю записи в энергобюджете вместе с миллиамперами — приём описан в уроке про батарейное питание: память и расход батарейки — строки одной таблицы, а не два отдельных мира.
Структуры настроек переезжают через putBytes и getBytes: указатель, размер, ключ — и чемодан байтов лежит в одном ключе. Только помните: это сырые байты, поэтому при изменении состава полей подписывайте версию прямо внутри структуры, иначе старая прочтётся в новую как чемодан без замка. А калибровки в read-only namespace я наполняю отдельным скетчем: он открывает тот же namespace уже с правом записи, разово кладёт коэффициенты и закрывает. Рабочий скетч никогда не получает права писать в cal — случайно испортить калибровку становится физически невозможно, и отдельный плюс: журналом прошивок служит та самая версия в ключе.
Чего я в Preferences не делаю
Не пишу в loop «на всякий случай» и не храню то, что обязано умирать с питанием: безопасный стартовый уровень реле задан кодом, а не сохранённым значением — иначе после сбоя плата проснётся с чужим состоянием выходов. Не кладу в ключи большие массивы: NVS — дом мелких значений и коротких строк, где каждая запись — отдельный журнал; логи и таблицы — во флеш-файловую систему, это другой механизм с другими граблями. И не храню в Preferences то, что должно быть приватным, без нужды: SSID и пароль WiFi там лежать умеют, но для моих самоделок проще строка в скетче — на прошитом устройстве их и так достать несложно.
EEPROM.h на ESP32 я оставил ровно в одном месте: перенос старого скетча с Uno, где адреса зашиты в логику так глубоко, что переписывание страшнее эмуляции. Там буфер с блобом и явный commit() — приемлемая плата за малую кровь. Для любого нового кода путь один: Preferences с ключами, которые читаются через полгода как текст, и типом, который видно в имени. А после каждой перезагрузки конфиг читается заново — и если рестарты стали частыми, виноват не хранилище: причины перезагрузок я раскладывал в отдельном уроке про аптайм.
Итог урока короткий: на ESP32 энергонезависимая память — это словарь, а не ячейки с адресами. Namespace — ящик, ключ — этикетка, тип — наклейка, без которой чтение молча подсовывает дефолт. Записи — по событию, заводские цифры — в одном месте, счётчики — отдельно, чтобы сброс и износ не задевали соседей. Моя кормушка после переезда на Preferences пережила уже три прошивки и один фабричный сброс, и ни одна порция с тех пор не пропала.
Домашнее задание: найдите в своём скетче одну переменную, которую вы мечтаете пережить вилку из розетки. Заведите namespace, пропишите ключ с типом в имени, сделайте первый запуск с дефолтами через isKey и проверьте возврат put — он должен быть больше нуля. А если в проекте есть Serial-команды вроде set и get из симулятора, разбор чтения чисел из монитора ждёт в уроке про Serial.parseInt — там же про таймауты чтения, на которые наткнётесь сразу.
- 1. Значение записали putShort("porog", 40), а читают getFloat("porog", 0). Что вернёт чтение?
- 2. Namespace открыт в read-only (begin с true). Что вернёт putUChar?
- 3. Какой длины может быть имя ключа или namespace?
- 4. Чем на ESP32 оказывается EEPROM.h при честном взгляде?
- 5. Где держать счётчик циклов сна у батарейного узла?
- На ESP32 энергонезависимая память — NVS: словарь ключ-значение во флеше, Preferences — рабочая обёртка над ним
- С каждым ключом хранится метка типа: чтение чужим типом молча возвращает дефолт — тип живёт в имени ключа
- Возврат put — размер записанного: ноль означает read-only, длинный ключ или забитый раздел
- Namespace делят по смыслу: cal read-only, cfg пользователь, stats счётчики — clear() стирает только свой ящик
- EEPROM.h на ESP32 — буфер в RAM и блоб по commit(); для нового кода — Preferences и записи по событию
Частые вопросы по теме урока
- Значение записали putShort("porog", 40), а читают getFloat("porog", 0). Что вернёт чтение?
- С каждым ключом хранится метка типа, и чтение чужим типом молча возвращает дефолт. Диагностика — getType по имени ключа, профилактика — тип в имени ключа. 0 — тип ключа не совпал, вернётся дефолт
- Namespace открыт в read-only (begin с true). Что вернёт putUChar?
- Возврат put — размер записанного; ноль означает отказ: read-only, длинный ключ или нет места. Проверка нуля — единственный способ заметить запись мимо хранилища. Ноль — запись не прошла
- Какой длины может быть имя ключа или namespace?
- Пятнадцать символов — предел NVS. Более длинные имена молча не сохраняются, поэтому у меня ключи короткие и с типом в имени: porog_i16, svet_u8, imya_str. До 15 символов, более длинные не работают
- Чем на ESP32 оказывается EEPROM.h при честном взгляде?
- Физической EEPROM-области на ESP32 нет: библиотека держит буфер в оперативной памяти и сносит его в NVS по явному commit(). Забыли commit — потеряли всё. Эмуляцией: буфер в RAM и один блоб в NVS по commit()
- Где держать счётчик циклов сна у батарейного узла?
- Запись в флеш на каждый цикл сна съедает ресурс секторов за пару лет. RTC-память переживает сон и рестарт без единой записи во флеш, но умирает с питанием. В RTC_DATA_ATTR — RAM без износа, живёт между снами