Камера дозревания сыра в моей кладовке четвёртый год держит влажность и температуру, и настройки за эти годы разрослись: порог увлажнителя, интервал опроса, яркость экрана, ночной режим, поправка датчика. Жили они структурой Config — ящиком, который уезжает в EEPROM одним вызовом put и возвращается одним get; механику я разобрал ещё в уроке про put и get. Жить было хорошо ровно до дня, когда понадобилось поле «пауза проветривания».
Я вставил его в структуру рядом с интервалом — по смыслу же родные. Magic-байт на месте, компилятор молчит, sizeof вырос на единицу. А через два дня камера показывала полную ерунду: экран погас, а пауза проветривания растянулась со считанных минут до двух часов. Мой новый байт читал чужую яркость, яркость — чужой резерв, и весь ящик поехал на один байт вправо. Числа выглядели правдоподобно, поэтому подвох я искал три вечера: проверял датчик, потом насос, потом самого себя.
Виноват оказался не EEPROM и не put — виновата моя привычка класть новое поле туда, где удобно глазу. В ящике настроек у каждого байта есть адрес-наследство: байты старой прошивки лежат ровно там же, где лежали, а читать их новый код обязан по старым правилам или честно признать смену раскладки. Этот урок — про то, как проектировать ящик, чтобы он переживал пополнение: поля, версия, резерв и арифметика sizeof.
Проектируем ящик: поля, типы, служебные байты
Начинаю с типов, потому что именно они определяют sizeof. Правило простое: значение влезает в 0–255 — берём byte; нужны минусы или числа больше 255 — int; дроби — float, а лучше пересчёт в целые с масштабом, как в уроке про побайтовую запись. Порог влажности 0–100 — byte. Интервал опроса в секундах до 65535 просится в int, но честно скажу: для моих узлов хватает byte в минутах, а экономия двух байтов на Uno, где всего 1024, — приятный бонус, а не подвиг.
Дальше порядок полей. Служебные байты — magic и version — едут первыми: их читают до всего остального, и от них зависит, читать ли поля вообще. Magic отличает свежую плату от настроенной ещё с первого урока про настройки — здесь он делает ту же работу, но уже внутри ящика, без отдельного адреса вне раскладки. Потом данные, потом резерв. Переставлять поля между версиями я не рекомендую вовсе: каждое перемещение — это сдвиг раскладки, а сдвиг — моя история с погасшим экраном. Диапазоны всех типов с числами — в уроке про переменные и типы.
Резерв — три байта с именем reserv — занимает место в раскладке с первого дня, но ничего не хранит. Он существует ради единственной цели: когда через год понадобится новое поле, вы не двигаете раскладку, а заявляете резервный байт как поле. Структура честно остаётся в том же sizeof, настройки пользователей читаются без конвертации, а version сообщает только о добавленном поле. Три байта я выбрал не с потолка: за год в камере появилось два новых поля, и один резерв так и остался нетронутым на следующий раз.
#include <EEPROM.h> // Карта EEPROM: 0-8 - ящик Config (sizeof = 9), соседи - после addrBox + sizeofconst int addrBox = 0; struct Config { byte magic; // 170: здесь есть настройки byte version; // раскладка v1 int porog; // порог влажности, 0-100 byte intervalMin; // интервал опроса, минуты byte bright; // яркость экрана, 0-255 byte reserv[3]; // под будущие поля}; Config box; void saveConfig() { EEPROM.put(addrBox, box); // внутри - побайтовый update} void loadConfig() { EEPROM.get(addrBox, box); if (box.magic != 170 || box.version != 1) { box.magic = 170; // пустая память или чужая раскладка - дефолты box.version = 1; box.porog = 40; box.intervalMin = 10; box.bright = 120; for (int i = 0; i < 3; i++) { box.reserv[i] = 0; // резерв всегда чистый при записи } saveConfig(); }} void setup() { Serial.begin(9600); loadConfig(); Serial.print("sizeof="); Serial.println(sizeof(Config)); // 9 на Uno Serial.print("porog="); Serial.print(box.porog); Serial.print(" interval="); Serial.println(box.intervalMin);} void loop() { delay(100);}Смотрите на арифметику sizeof: magic один, version один, int порог — два, интервал один, яркость один, резерв три. Девять байт. AVR кладёт поля структуры вплотную, без выравнивающих дырок, поэтому sizeof на Uno — честная сумма размеров полей. Ровно столько ячеек ящик занимает с нулевого адреса, и на столько же ячеек надо сдвигать всё, что живёт в EEPROM рядом — счётчики, калибровки, журнал. Привычка из урока про ресурс: соседей адресуйте от addrBox плюс sizeof(Config), а не от пальцевых констант.
EEPROM.put пишет все девять байт, но внутри у него update-семантика: ячейки с тем же значением пропускаются. Перезаписать ящик целиком ради изменившегося порога — норма: реально перезапишутся один-два байта. При пересохранении раз в час это меньше сотни циклов в год — ресурс в 100000 циклов на ячейку тут не кончается никогда. Страх износа, который мы считали в разговоре про ресурс, к целому ящику неприменим: байты, которые не изменились, бесплатны.
Отдельно про печать sizeof: Serial.print(sizeof(Config)) в setup — дешёвый способ следить за ящиком. Если sizeof вырос внезапно, вы добавили поле не туда или подменили тип; на AVR выравнивание почти не случается, а вот моя вставка в середину случалась дважды. Строка печати ловит это за секунду — до того, как пользователи начнут присылать скриншоты погасших экранов.
И про бюджет целиком: на Uno под EEPROM отведён килобайт, и ящик в нём — не единственный жилец. Моё правило: ящик держу до 32 байт — этого хватает на пятнадцать-двадцать полей, а остальная память остаётся счётчикам, калибровкам и ротационным блокам. Когда конфиг перерастает сотню байт, EEPROM выжимается быстрее не записями, а именно площадью: каждый пересмотр раскладки едет по всей карте соседей, и дешевле переводить большой конфиг на карту памяти или внешнюю 24C256, чем перетасовывать килобайт.
| Правка в структуре | sizeof | Что с настройками старой прошивки |
|---|---|---|
| Исходный ящик: magic, version, порог (int), интервал, яркость, reserv[3] | 9 | Читается как есть |
| Новое поле взяли из резерва — конец ящика | 9 | Сдвига нет: байт уже был в раскладке, новому полю ставят дефолт |
| Поле вставили в середину | 10 | Все поля после места вставки уехали на байт — читается мусор |
| Интервалу расширили byte до int | 10 | Сдвиг после интервала — нужен бамп версии и конвертация |
Миграция версии: резерв против вставки
Теперь главный выбор, который вы делаете при каждом пополнении ящика. Дорога первая — из резерва: забираем reserv[0] под byte ventPause, резерв ужимаем до двух, version ставим 2. sizeof не изменился, раскладка не сдвинулась, и миграция сводится к одной проверке: если пришла версия 1, новому полю ставим дефолт. Настройки пользователя не тронуты, записать надо только байт версии и новое поле — put пропустит остальные байты сам.
Дорога вторая — вставка в середину, та самая, на которой я съехал с дороги с сыром. sizeof вырос, каждая ячейка после места вставки уехала, и у пользователя яркость читается как резерв. Версия здесь спасает только вместе с кодом: старую раскладку надо конвертировать полем за полем или честно сбрасывать на дефолты. Практика сообщества тут совпадает с моей: если при старте обнаружена старая, но валидная версия, новые поля получают дефолты, а старые читаются без сдвига; конвертация оправдана, только когда старые данные однозначно распознаются. Мнение с границей: для ящика до десятка полей я всегда тяну новые поля из резерва, потому что миграция не нужна в принципе; а вот резерв на двадцать байт «на всё про всё» я не держу — это уже способ ничего не планировать.
#include <EEPROM.h> const int addrBox = 0; struct ConfigV2 { byte magic; byte version; // теперь 2 int porog; byte intervalMin; byte bright; byte ventPause; // бывший reserv[0], дефолт 15 минут byte reserv[2];}; ConfigV2 box; void loadConfig() { EEPROM.get(addrBox, box); if (box.magic != 170) { box.magic = 170; // пустая плата - дефолты целиком box.version = 2; box.porog = 40; box.intervalMin = 10; box.bright = 120; box.ventPause = 15; box.reserv[0] = 0; box.reserv[1] = 0; EEPROM.put(addrBox, box); return; } if (box.version == 1) { // старая раскладка: поля не сдвинулись, box.version = 2; // резерва v1 читается как ventPause if (box.ventPause == 0) { box.ventPause = 15; // у v1 резерв был нулевой - ставим дефолт } EEPROM.put(addrBox, box); // реально перезапишутся два байта }} void setup() { Serial.begin(9600); loadConfig(); Serial.print("ver="); Serial.print(box.version); Serial.print(" ventPause="); Serial.println(box.ventPause);} void loop() { delay(100);}Резерв и связки: что дальше с ящиком
Обслуживание резерва простое. При каждом сохранении заполняйте его нулями — одна строка перед put. При чтении игнорируйте — пока он резерв. В день, когда он станет полем, у вас уже есть байт в раскладке, бампнутая версия, дефолт на ноль и ноль действий с самим EEPROM. Именно это я называю страховкой: вы платите три байта из тысячи и снимаете с себя целый класс миграций.
И связки, ради которых ящик стоит затевать. Тот же struct с тем же sizeof и version едет между платами по UART в уроке про передачу структур — настройки с камеры я переношу на новую плату одним сериал-дампом. А защиты у ящика пока нет: magic ловит только пустую память, и один перевёрнутый бит посреди полей пройдёт незамеченным, как прошёл через magic у моей камеры. Как закрыть ящик контрольной суммой — следующий урок, и для настроек он важнее всех предыдущих.
- Ящик настроек — одна struct: magic, version, поля данных, резерв; put и get переносят его одним вызовом
- На AVR поля кладутся вплотную: magic + version + int + byte + byte + reserv[3] — sizeof 9 байт
- Новое поле — только в конец или из резерва; вставка в середину сдвигает раскладку и требует миграции
- Резерв заполняйте нулями при каждом сохранении и проверяйте при чтении: там бывает чужое старьё
- Старая валидная версия — новым полям дефолты, старые читаются без сдвига; конвертация — только при однозначной раскладке
- 1. Куда класть новое поле в структуре настроек, чтобы старые данные читались без сдвига?
- 2. Структура: magic, version, int порог, byte интервал, byte яркость, reserv[3]. Что вернёт sizeof на Uno?
- 3. Новое поле объявили из резерва, а у половины устройств в этом байте лежит 137. Что вы пропустили?
- 4. Что делает EEPROM.put, если в ящике изменилось одно поле?
- 5. Прибор считает версию 2, а в EEPROM лежит валидный ящик версии 1. Как поступить?
Частые вопросы по теме урока
- Куда класть новое поле в структуре настроек, чтобы старые данные читались без сдвига?
- Сдвигается всё, что стоит после вставки. В конце или в резерве сдвига нет: байт уже входил в sizeof, поля до него лежат на своих местах. В конец или в заранее оставленный резервный байт
- Структура: magic, version, int порог, byte интервал, byte яркость, reserv[3]. Что вернёт sizeof на Uno?
- Один плюс один плюс два плюс один плюс один плюс три — девять. AVR кладёт поля вплотную, без выравнивающих байтов. 9
- Новое поле объявили из резерва, а у половины устройств в этом байте лежит 137. Что вы пропустили?
- В резерве бывает старьё от прошлых раскладок. Чистый резерв при записи и дефолт на ноль при чтении закрывают оба сценария. Обнуление резерва при каждом сохранении и проверку значения при заявлении поля
- Что делает EEPROM.put, если в ящике изменилось одно поле?
- Put разбирает структуру на байты и пишет через update: совпавшие ячейки пропускаются. Перезапись ящика ради одного поля почти бесплатна. Пишет только изменившиеся байты — внутри update-семантика
- Прибор считает версию 2, а в EEPROM лежит валидный ящик версии 1. Как поступить?
- Поле из резерва не сдвигает раскладку: старые данные валидны, новому полю нужен дефолт. Вставка в середину потребовала бы конвертации или сброса. Старые поля читаются без сдвига, новое поле из резерва получает дефолт