Два одинаковых термостата для рассады собрал за выходные: один себе на стеллаж, второй другу в теплицу. Себе настроил порог и гистерезис, проверил, отдал. Через день звонок: у друга термостат гоняет обогреватель без остановки, а на экране порог 1020. Причина вскрылась за минуту: у него на старте EEPROM.get прочитал чистую память, и поля заполнились числом 255 — порог стал 255 на четыре, гистерезис после умножения ушёл в минус. Моя проверка if (porog == 255) рассчитывала на «пустую ячейку» — и не сработала: порог хранился делённым на четыре, приём из урока про тюнинг кнопками, и 255 в этой ячейке означало честную тысячу двадцать — то самое значение, которое скетч и записал бы.
Различить «свежую плату» и «настроенную» по значениям настроек нельзя в принципе: любое число в разрешённом диапазоне может оказаться и выбором пользователя, и мусором чистой памяти. Яркость ночника из первого урока раздела писалась в ячейку 0 и могла быть любой от 0 до 255. Задаче нужен отдельный знак, который не путается с данными, — и такой знак существует уже лет тридцать во всех прошивках, где есть конфиг.
В этом уроке: маркер-байт 0x42, который ставится один раз за жизнь платы; version-байт рядом с ним, переживающий смену раскладки конфига; правило безопасных дефолтов и полная разборка грабли «маркер совпал, а настройки чужие». Вся проверка умещается в одну функцию loadConfig(), которая кочует у меня из проекта в проект почти без правок.
Маркер 0x42: одна ячейка решает всё
Свежая с завода EEPROM читается как 255 — это стёртое состояние ячейки, единицы во всех битах; нуля там нет, пока его не запишут. Как читается и пишется самый первый байт, мы уже знаем — теперь этот приём работает на нас: ячейка 0 хранит маркер, и если там не 0x42, плата считается ни разу не настраивавшейся. 0x42 — это 66, латинская буква B; константа выбрана просто потому, что легко запоминается и не совпадает с 255. Годится любая своя константа — кроме значений, которые ваш же скетч пишет в данных.
Порядок первого запуска: loadConfig() читает маркер, не совпал — заливает дефолты в рабочие ячейки, пишет версию конфига и последней ставит маркер. Маркер пишется последним не для красоты: если питание умрёт посреди заливки, на следующем старте проверка снова увидит «не совпал» и повторит всё сначала. Такая заливка происходит один раз за жизнь платы: два десятка байт против ресурса 100000 циклов на ячейку — экономить здесь нечего, числа ресурса в этом уроке наконец работают на нас.
Один маркер решает старт, но не решает апгрейды. Прошивка обновилась, в конфиге появился новый параметр, раскладка сдвинулась — а байты в EEPROM остались от старой. Рядом с маркером живёт version-байт: скетч знает свою версию раскладки, сравнивает с записанной и при несовпадении мигрирует — переносит старые поля на новые места, новому ставит дефолт, обновляет версию. Тот же приём, упакованный внутрь структуры, мы видели в уроке про put и get: там magic и version ехали первыми полями ящика.
Напоследок — почему именно маркер, а не, скажем, счётчик запусков: идея писать в EEPROM номер каждой загрузки выглядит заманчиво, но каждая загрузка становится записью. Термостат, просыпающийся по таймеру десять раз в сутки, выжрет 100000 циклов за двадцать семь лет — приемлемо; метеостанция с самоконтролем каждые десять минут — за считаные месяцы. Маркер же пишется один раз за жизнь платы и потому бесплатен: ресурс тратится только в тот единственный вечер, когда плата впервые встречается со своим скетчем.
#include <EEPROM.h> const byte MARKER = 0x42; // 66const byte CFG_VER = 2;const int ADDR_MARKER = 0;const int ADDR_VER = 1;const int ADDR_CFG = 10; // порог/4 и гистерезис/5 int porog;int gist; void saveDefaults() { porog = 500; gist = 20; EEPROM.update(ADDR_CFG, porog / 4); EEPROM.update(ADDR_CFG + 1, gist / 5); EEPROM.update(ADDR_VER, CFG_VER); EEPROM.update(ADDR_MARKER, MARKER); // маркер — последним tone(8, 1500, 100); delay(150); tone(8, 1500, 100); Serial.println("pervyi zapusk: defolty zapisany");} void loadConfig() { byte m = EEPROM.read(ADDR_MARKER); byte v = EEPROM.read(ADDR_VER); if (m != MARKER) { // чистая или чужая память saveDefaults(); return; } if (v != CFG_VER) { // наша память, чужая версия if (v == 1) { // миграция: в v1 не было гистерезиса porog = constrain(EEPROM.read(ADDR_CFG) * 4, 20, 1000); gist = 20; EEPROM.update(ADDR_CFG + 1, gist / 5); EEPROM.update(ADDR_VER, CFG_VER); Serial.println("migraciya v1 -> v2"); return; } saveDefaults(); // неизвестная версия — честный сброс return; } porog = constrain(EEPROM.read(ADDR_CFG) * 4, 20, 1000); gist = constrain(EEPROM.read(ADDR_CFG + 1) * 5, 0, 50);} void setup() { Serial.begin(9600); pinMode(8, OUTPUT); loadConfig(); Serial.print("porog="); Serial.print(porog); Serial.print(" gist="); Serial.println(gist);} void loop() { // рабочая логика прибора delay(100);}Смотрите на лестницу проверок: маркер, версия, данные. Маркер отсеял чистую плату, версия отсеивает чужую и устаревшую раскладку, и только после обеих скетч соглашается читать значения. Миграция в коде заняла пять строк: v1 не знал про гистерезис — порог переносится, гистерезис получает дефолт, версия обновляется. Неизвестная версия — не поле для догадок: честнее сбросить всё на дефолты, чем гадать, что означали байты чужого скетча.
Почему одной проверки маркера мало — отдельная история, она в заметке ниже. В двух словах: маркер может совпасть случайно. Лекарство посерьёзнее — контрольная сумма поверх конфига: складываем байты, пишем сумму рядом, при чтении сверяем; приём разобран в уроке про контрольную сумму и ложится на EEPROM один в один — не сошлась, значит конфиг битый, заливаем дефолты.
Про дефолты отдельно: безопасный дефолт — не ноль. Порог 0 заставит ночник гореть круглосуточно, гистерезис 0 заставит реле щёлкать каждые полсекунды. Середина диапазона и разумный зазор — вот что заливается при первом запуске: в моём шаблоне порог 500, гистерезис 20, звук включён — прибор с такими значениями уже полезен, даже если хозяин ни разу не откроет меню. И constrain при чтении остаётся даже для валидного конфига: ячейка могла деградировать от старости, и 250 в квантах лучше ужать до разрешённых границ, чем поверить ей слепо.
Дефолты, границы и соседи по памяти
Конфиг — не единственный жилец EEPROM. Рядом с ним в моих проектах живут счётчики: моточасы, число включений — двухбайтовую арифметику для них мы писали в уроке про счётчик включений. Раскладку адресов я веду в комментарии шапки скетча: маркер 0, версия 1, конфиг с 10, счётчики с 20. Пока карта письменная, добавление нового жильца не ломает старых; как только она становится «у меня в голове» — раскладка неизбежно разъезжается.
Отдельная оговорка для ESP32. Внутренней EEPROM там нет — она эмулируется сектором флеша, и библиотека требует ритуала: EEPROM.begin(4096) в setup резервирует окно памяти, а после каждой записи нужен EEPROM.commit(), иначе изменения доживут только до первой перезагрузки. Забытый commit — классика первых ESP32-проектов: на столе всё работает, в корпусе через сутки настройки откатываются. Маркер и версия работают там точно так же, просто заливка дефолтов заканчивается обязательным commit.
И заметьте: loadConfig() из урока выше не знает ничего про остальной прибор. Он принимает любую память и всегда возвращает валидный конфиг — на чистой плате, на перешитой со старой версии, на плате друга. Именно поэтому функция копируется между проектами почти без правок: меняются дефолты и раскладка, лестница проверок остаётся. После первого запуска конфиг обязан работать; если прибор после заливки дефолтов ведёт себя странно, причину ищите в рабочей логике, а не в памяти.
| Состояние памяти | Что в ячейках 0 и 1 | Решение loadConfig |
|---|---|---|
| Свежая с завода | 255 и 255 | Маркера нет — залить дефолты, записать версию и маркер; один раз за жизнь платы |
| Наша прошивка, конфиг v2 | 0x42 и 2 | Загрузить значения, ужать constrain-ом в границы |
| Наша прошивка, конфиг v1 | 0x42 и 1 | Миграция: порог перенести, гистерезису — дефолт, версию обновить |
| Чужой скетч, случайное совпадение | 0x42 и не 2 | Версия не наша — сброс на дефолты; контрольная сумма ловит и более хитрые совпадения |
Три урока сложились в полный конструктор настройки прибора: тюнинг порога кнопками, меню на LCD с десятком параметров в трёх кнопках, маркер и версия — правильный первый запуск и спокойные апгрейды прошивки. Всё это выросло из вещей начала раздела: первых настроек и одного байта на нулевом адресе. Дальше конструктор разбирается по приборам: где-то хватит одной ячейки с порогом, где-то весь ящик структур — размер здесь не аргумент, аргументы — частота записей и цена потерянной настройки. Возьмите loadConfig() из этого урока как шаблон — и звонки вида «у меня порог 1020» останутся чужой историей.
- Свежая EEPROM читается как 255, а не как ноль: различать «чисто» и «настроено» по значениям настроек нельзя в принципе
- Маркер 0x42 пишется последним, после дефолтов и версии: обрыв питания посреди первого запуска просто повторит заливку
- version-байт рядом с маркером переживает смену раскладки: знакомая версия мигрирует, неизвестная — честный сброс на дефолты
- От случайного совпадения маркера на пожившей плате спасают версия и контрольная сумма: яркость 66 — это и есть 0x42
- Дефолты — безопасные, не нулевые: порог в середине диапазона, гистерезис не ноль; на ESP32 к заливке добавляются EEPROM.begin и commit
- 1. Что читается из свежей с завода ячейки EEPROM?
- 2. Маркер 0x42 в ячейке 0 совпал, но version-байт равен 7, а скетч ждёт 2. Что делать?
- 3. Почему проверка val == 255 как признак первого запуска ненадёжна?
- 4. Сколько раз за жизнь платы обычно выполняется блок «залить дефолты»?
- 5. На ESP32 настройки пропадают при перезагрузке после EEPROM.put. Что забыли?
Частые вопросы по теме урока
- Что читается из свежей с завода ячейки EEPROM?
- Стёртое состояние ячейки — единицы во всех битах, то есть 255. Ноль в памяти возникает только после записи. 255
- Маркер 0x42 в ячейке 0 совпал, но version-байт равен 7, а скетч ждёт 2. Что делать?
- Совпавший маркер при чужой версии — признак случайного совпадения или старой раскладки. Версия здесь и существует, чтобы остановить доверие. Считать конфиг чужим: залить дефолты и поставить свою версию
- Почему проверка val == 255 как признак первого запуска ненадёжна?
- Значение данных и признак чистой памяти обязаны жить в разных ячейках; иначе пользователь однажды сохранит 255 — и скетч его не отличит от пустоты. 255 может быть легитимным сохранённым значением — например, порог 1020, делённый на 4
- Сколько раз за жизнь платы обычно выполняется блок «залить дефолты»?
- После заливки стоит маркер, и все следующие запуски идут по ветке загрузки конфига. Повтор возможен только при смене версии или битой памяти. Один раз — при самом первом запуске
- На ESP32 настройки пропадают при перезагрузке после EEPROM.put. Что забыли?
- На ESP32 EEPROM эмулируется во флеше: begin резервирует окно, commit переносит изменения из RAM в сектор. Без commit они живут до перезагрузки. EEPROM.commit() после записи