Плата с склада, свежая прошивка, первое включение — и на экране порог 255, калибровка -1, а термостат уже бьётся о несуществующие границы. Классика первого запуска: EEPROM после заводского стирания читается как 0xFF во всех 1024 ячейках, и любой код, который верит прочитанному без проверки, получает мусор с уверенным видом. У меня однажды такой мусор перекалибровал датчик так, что показания уехали на десяток градусов: сутки я искал причину в датчике и проводах, а лежала она в четырёх байтах, которые никто никогда не записывал.
Инструменты проверок у нас уже есть: magic-байт и дефолты из урока про magic-номер, put и get структуры из урока про конфиг-структуру. Здесь собираю из них полный сценарий первого запуска: сырые байты свежей памяти, версия конфига как главный вопрос о том, что вообще лежит, миграция между версиями и осознанный сброс к дефолтам. Ниже — одна структура на четыре ветки чтения; она же — главная функция любого моего проекта с настройками, от ночника до термостата.
Сценарий стоит один раз прописать целиком, до кода: память свежая — дефолты; версия совпала — грузим; версия старше — переносим; версия новее — дефолты с сообщением в Serial. Четыре строки плана закрывают вопросы первого запуска навсегда: дальше вы только добавляете поля и поднимаете номер. Скептики, которые вписывают дефолты прямо в переменные и пропускают проверку, оплачивают отладку в поле — я оплатил и повторять не планирую.
Что лежит в памяти до первого сохранения
Свежая ячейка EEPROM хранит 0xFF: стирание ставит все восемь бит в единицы, а запись умеет лишь превращать единицы в нули. Поэтому 0xFF — признак нетронутой памяти, а 0x00 — признак записанного нуля, и это разные состояния. Читая 0xFF разными типами, получаем разный мусор: uint8_t даст 255, int16_t — -1, четыре байта 0xFFFFFFFF как float — NaN. Порог 255 всегда больше любой температуры, смещение -1 тихо кривит калибровку, NaN отравляет арифметику — каждое из значений без проверки ломает логику по-своему. Проверить свой мусор легко: прочитайте сырой байт и напечатайте в HEX — FF после заводского стирания ни с чем не спутается.
EEPROM.get копирует в структуру sizeof байт подряд, и на свежей памяти все поля окажутся 0xFF — библиотека честно отражает память, а не ломается. Вопрос не в том, как читать, а в том, как отличить «конфига нет» от «конфиг есть и валиден». С типами и их байтовыми лицами мы разбирались в уроке про int и float по байтам; здесь за ответ отвечает версия конфига. Практическое следствие: любые вычисления с прочитанными полями — только после проверки версии, иначе NaN отравит и среднее, и порог, и всё, что от них зависит.
Первым полем структуры кладу uint8_t ver. Свежая память даёт 255: такого номера не существует по определению, значит — дефолты и первое сохранение. Совпадает с TEK_VER — конфиг записан скетчем той же сборки, грузим как есть. Ниже — конфиг от старого скетча: перетаскиваем существующие поля, новым назначаем дефолты, версию поднимаем и сохраняем. Выше — конфиг от более нового скетча, чем этот: молча грузить нельзя, поля могли сместиться; безопаснее дефолты и громкое сообщение в Serial. Номер версии — обычный uint8_t: место копеечное, а смысл ветки чтения определяется одним сравнением.
Версия снимает и вопрос обновлений: добавили поле — подняли версию, и платы в поле при первом запуске нового скетча мигрируют сами, без визита с кнопкой. Сброс к дефолтам при этом не провал, а сценарий: кнопка заводского сброса из урока про заводской сброс вызывает ровно ту же ветку, что и ver = 255, — один код на оба случая. Если версий уже накопилось две-три, миграция делается цепочкой: пока ver меньше текущего, применяете шаги следующей версии и поднимаете номер — цикл while с переключателем шагов короче, чем лесенка if для каждой версии отдельно.
#include <EEPROM.h> // Конфиг: версия, порог, калибровка. Поля добавлять только в конец// и поднимать версию - порядок существующих полей менять нельзя. struct Konfig { uint8_t ver; // версия конфига: 255 - память свежая uint8_t porog; // порог, 0-100 uint16_t smeschenie; // калибровка}; const uint8_t TEK_VER = 2;const int ADRES = 0;Konfig tek; Konfig defoltnyj() { Konfig k; k.ver = TEK_VER; k.porog = 40; k.smeschenie = 12; return k;} void pechatBajtov() { Serial.print("syrye bajty:"); for (int i = 0; i < (int)sizeof(Konfig); i++) { Serial.print(" "); Serial.print(EEPROM.read(i), HEX); } Serial.println("");} void chitaj() { EEPROM.get(ADRES, tek); pechatBajtov(); if (tek.ver == 0xFF) { // 255 - памяти не касались Serial.println("pamyat chistaya: kladjom defolty"); tek = defoltnyj(); EEPROM.put(ADRES, tek); } else if (tek.ver < TEK_VER) { Serial.print("konfig versii "); Serial.print(tek.ver); Serial.println(": migriruem, novye polya - iz defoltov"); tek.porog = 40; // пример миграции поля tek.smeschenie = 12; tek.ver = TEK_VER; EEPROM.put(ADRES, tek); } else if (tek.ver > TEK_VER) { Serial.println("konfig ot bolee novogo skecha: defolty"); tek = defoltnyj(); } else { Serial.println("versiya sovpala: konfig zagruzhen"); } Serial.print("ver="); Serial.print(tek.ver); Serial.print(" porog="); Serial.print(tek.porog); Serial.print(" smeschenie="); Serial.println(tek.smeschenie);} void setup() { Serial.begin(9600); Serial.print("sizeof(Konfig)="); Serial.println(sizeof(Konfig)); chitaj();} void loop() {}Миграция вместо сброса
Главная грабля миграции — порядок полей. Структура хранится байтами подряд, и перестановка существующих полей читает старый конфиг по новым адресам: порог возьмёт чужое смещение, смещение — мусор из-под порога. Правило жёсткое: новые поля добавляются в конец, версия растёт на единицу, порядок и типы старых полей не меняются никогда. На AVR поля структур лежат подряд без выравнивания, но sizeof(Konfig) напечатайте в Serial один раз — эта цифра понадобится при разводке адресов соседей по памяти. Третья грабля тише остальных: структуру меняют, а адреса соседей по памяти не пересчитывают. Если конфиг лежит с адреса 0 и вырос с четырёх байтов до восьми, он съест соседние ячейки — счётчик перезаписей из первого урока начнёт жить внутри порога. Разводите адреса с запасом и после каждого роста структуры сверяйте карту памяти.
Вторая грабля — ver выше TEK_VER. Сценарий живой: шили новый скетч, потом откатились старым. Молчаливый сброс безопасен, но нечестен к пользователю — печатайте в Serial, что вернули дефолты и почему. Совсем аккуратно поверх версии ложится контрольная сумма: она ловит и недописанные слоты wear leveling из прошлого урока — связка версии с CRC разобрана в уроке про целостность настроек. Проверка на столе: поменяйте местами два поля и запустите скетч на памяти с валидным конфигом — печать сырых байтов покажет тот же сдвиг, который увидит плата.
| Что прочиталось | Диагноз | Что делает скетч |
|---|---|---|
| ver = 255 (0xFF) | Память свежая, конфига нет | Дефолты и первое сохранение |
| ver = 2 при TEK_VER = 2 | Конфиг этой версии | Грузим как есть |
| ver = 1 при TEK_VER = 2 | Конфиг старого скетча | Миграция: дефолты новым полям, версия вверх |
| ver = 3 при TEK_VER = 2 | Конфиг от более нового скетча | Дефолты и сообщение в Serial |
- Свежая EEPROM читается как 0xFF во всех ячейках: стирание ставит биты в единицы, 0xFF — признак нетронутой памяти
- 0xFF разными типами — разный мусор: 255, -1, NaN; проверка обязательна до первого использования полей
- Первое поле структуры — версия: 255 означает дефолты, совпадение — загрузку, ниже — миграцию, выше — сброс с сообщением
- Поля добавляются в конец и поднимают версию; перестановка полей читает старый конфиг по новым адресам
- Печать сырых байтов в HEX — первый инструмент отладки первого запуска и проверки sizeof
- 1. Что вернёт EEPROM.read(0) на плате, которую только что достали из коробки?
- 2. Конфиг-структура с первым полем ver. В памяти ver = 1, скетч ждёт TEK_VER = 2. Что делает честный скетч?
- 3. Почему нельзя молча грузить конфиг, у которого ver выше версии скетча?
- 4. Два поля переставили в середине структуры, версию не тронули. Что прочитает EEPROM.get?
- 5. Порог из свежей EEPROM (0xFF) присвоен uint8_t без проверки. Значение?
Отладка первого запуска начинается с сырых байтов: печатайте первые sizeof(Konfig) адресов в HEX до всякого get. Свежая память покажет столбик FF, после первого сохранения — версию и поля, после кнопки стирания — снова FF. Одна печать различает «память стёрли» и «конфиг потерялся по дороге», а заодно проверяет sizeof. Задавать конфиг по-человечески удобно с терминала — приём из урока про конфигуратор по Serial.
На этом жизненный цикл настроек замыкается: свежая память — дефолты — редактирование — износ — круг. Свод приёмов раздела, от первых байтов до круга и миграций, собран в итоговом уроке про настройки на годы — сверьте с его чек-листом свой проект, пока EEPROM ещё свежая.
Частые вопросы по теме урока
- Что вернёт EEPROM.read(0) на плате, которую только что достали из коробки?
- Заводское стирание ставит все биты ячеек в единицы — память читается как 0xFF, то есть 255. 255
- Конфиг-структура с первым полем ver. В памяти ver = 1, скетч ждёт TEK_VER = 2. Что делает честный скетч?
- Версия ниже текущей — это конфиг старого скетча: существующие поля валидны, новым назначают дефолты, версию поднимают и сохраняют. Мигрирует: переносит поля, новым назначает дефолты, версию поднимает
- Почему нельзя молча грузить конфиг, у которого ver выше версии скетча?
- Конфиг писан более новым скетчем: раскладка полей могла измениться. Безопаснее дефолты и сообщение в Serial, а не тихий мусор в настройках. Поля могли сместиться или поменять смысл
- Два поля переставили в середине структуры, версию не тронули. Что прочитает EEPROM.get?
- get копирует байты подряд по текущей раскладке. Съехали и поля, и сама версия внутри — проверить её честно уже не получится. Старый конфиг по новым адресам
- Порог из свежей EEPROM (0xFF) присвоен uint8_t без проверки. Значение?
- 0xFF как uint8_t — это 255: порог всегда больше любой температуры. Похожий мусор у int16_t — это -1, у float — NaN. 255