Вторая история из той же серии, но тихая: добавил в термостат ночное снижение — четвёртое поле в конфиг. Старые платы с новым скетчем поднялись, прочитали свои настройки и заработали по чужой раскладке: новое поле читалось со сдвинутого адреса, где лежал режим работы, а режим получил остаток от порога. Никаких аварий, плата моргает светодиодом и греет воду — просто каждый параметр стоит не на своём месте, и ищется это неделями. Повторять такой поиск у меня желания нет, поэтому раскладка блока теперь подписана версией, а старт умеет переносить блок со старой карты на новую.
Служебных байта три, и каждый отвечает за свой вопрос. Маркер в нулевом адресе: «эту память вообще конфигурировали?» — барьер между чистой EEPROM и настроенной. Версия рядом: «по какой карте писались поля?» — барьер между старой раскладкой и новой. Дальше поля, дальше сумма из предыдущего урока. Конвейер проверки при старте проходит этажи по порядку, и каждый этаж принимает своё решение: залить дефолты, мигрировать или читать как есть.
Про первый запуск у нас уже был отдельный разговор — в уроке про магическое число маркер 0x42 и заливка дефолтов разобраны по шагам. Здесь я добавляю два хода: маркер переезжает в нулевой адрес, рядом добавляется байт версии, и обновление прошивки сводится к миграции блока, а не к честному сбросу. Разница огромная: пользователь не должен выставлять температуру заново только потому, что я добавил в скетч поле. Сброс как способ «починить» обновление я видел чаще, чем хотелось бы: он работает, но это работа лопатой там, где нужен ключ.
Маркер в нулевом адресе
Нулевой адрес выбран не для красоты: он читается первым при любой раскладке и не сдвигается никогда — поля могут переезжать сколько угодно, маркер остаётся на месте. Число маркера обязано отличаться и от пустой памяти, и от зачищенной: свежая EEPROM читается как 255, а после прогона штатного примера eeprom_clear во всём килобайте лежат нули — тот скетч пишет ноль в каждую из 1024 ячеек, и это не восстановление, а запись нулей. Беру 0xA5, то есть 165: в двоичном виде 10100101, единицы и нули перемешаны, и случайно такое значение не встретится ни в массиве из 255, ни в массиве из нулей.
Проверка маркера — первый этаж конвейера: не совпало — блоку нет веры, заливаем дефолты, поля и версию, маркер пишется самым последним. Совпало — смотрим версию. Маркеры в середине памяти я встречал в старых проектах — скажем, в ячейке 100, как когда-то делал сам: работает ровно до первого конфликта, когда другая прошивка кладёт в ту же ячейку своё значение. Нулевой адрес плюс версия — самая короткая страховка от чужого блока в вашем килобайте; полная картина того, что вообще живёт в 1024 байтах, собрана в уроке про карту памяти.
Номер версии живёт по одному правилу: только вверх и никогда по второму кругу. Раскладка версии 2 — это всегда одна и та же карта адресов, в какой бы сборке скетча она ни была; попытка переиспользовать номер для другой раскладки ловится на старте сюрпризом вида «версия знакомая, а поля чужие», и выгребается это долго. Карту раскладок держите в шапке скетча комментариями: строка на версию — адреса полей, дата и повод; через год вы будете читать собственный код без археологии.
Версия и миграция
Раскладка версии 1: маркер, версия, три поля. Раскладка версии 2: маркер, версия, те же три поля по тем же адресам и два новых в конце. Заметьте ход: новые поля дописываются в хвост, а не вставляются в середину — тогда адреса старых не сдвигаются, и миграция превращается в дописывание. Скетч при старте читает версию 1, дописывает поля-дефолты v2 в хвост, поднимает байт версии до 2 — и пользователь не заметил ничего, кроме новой функции. Вставка поля в середину тоже законна, но тогда миграция обязана переносить каждое старое поле на новый адрес руками, и за эту свободу платит таблица переноса в скетче.
Миграция пишет блок по той же дисциплине, что и первый запуск: поля, потом версия, маркер уже на месте и не трогается. Обрыв питания посреди миграции оставит блок со старой версией и, возможно, одним дописанным полем — при следующем старте миграция пройдёт заново и допишет остальное. Идемпотентность — единственное жёсткое требование к миграции: сколько раз ни запускай, результат один. Если версия оказалась выше известной прошивке, блок писала более свежая программа, и откатывать вниз я его не разрешаю себе по умолчанию: старая прошивка не знает новых полей, и лучший исход — честный сброс с печатью причины, худший — записать поверх блока v2 мусор v1.
После миграции обязательна проверка диапазонов — не украшение. Дефолт нового поля обязан быть безопасным, не нулевым: ночной сдвиг в ноль означает «ночью уставка падает до нуля», и это не то, что заказывал пользователь. Читаем блок функцией из урока про put и get со структурой, каждое поле сверяем с разумными границами и только потом пускаем в работу. Сумма целостности из прошлого урока проходит по всем полям уже после миграции: сначала структура, потом арифметика.
#include <EEPROM.h> // Раскладка v1: 0 маркер, 1 версия, 2 уставка, 3 гистерезис, 4 режим.// Раскладка v2: то же + 5 ночной сдвиг, 6 порог сухого хода.// Новые поля дописаны в хвост: адреса старых не сдвинулись. const int MARKER = 165; // 0xA5 = 10100101const int VERSIYA = 2; void zalivka(String pochemu) { EEPROM.update(2, 210); EEPROM.update(3, 2); EEPROM.update(4, 1); EEPROM.update(5, 30); EEPROM.update(6, 45); EEPROM.update(1, VERSIYA); // версия перед маркером EEPROM.update(0, MARKER); // маркер — самым последним Serial.print("zality defolty v2: "); Serial.println(pochemu);} void migraciya() { // v1 знал три поля; адреса 2-4 верны, дописываем хвост v2 EEPROM.update(5, 30); EEPROM.update(6, 45); EEPROM.update(1, VERSIYA); Serial.println("v1 -> v2: starye polya sohraneny, dopisany dva novyh");} void setup() { Serial.begin(9600); if (EEPROM.read(0) != MARKER) { if (EEPROM.read(0) == 0) { zalivka("nuli: pamet chistili eeprom_clear"); } else { zalivka("chistaya pamyat iz 255"); } return; } int v = EEPROM.read(1); if (v == VERSIYA) { Serial.println("versiya 2: chitaem kak est"); return; } if (v == 1) { migraciya(); return; } Serial.print("versiya "); Serial.print(v); Serial.println(" neizvestna: chestnyj sbros k defoltam"); zalivka("blok ot bolee novoj proshivki");} void loop() {}| Что увидели при старте | Что это значит | Что делает скетч |
|---|---|---|
| Маркер 255 | Плата новая, память чистая | Заливка дефолтов, версия перед маркером, маркер последним |
| Маркер 0 | Кто-то прогнал eeprom_clear: нули вместо 255 | Та же заливка — маркер обязан отличаться и от нуля |
| Версия 1 | Блок старой прошивки, адреса знакомы | Миграция: дописать поля v2, поднять версию |
| Версия 9 | Блок от более свежей прошивки, чем у платы | Честный сброс: откатывать миграцию вниз скетч не умеет |
Три платы в симуляторе
Движок книги EEPROM этого урока не эмулирует — говорю как есть, — поэтому блок живёт в переменных, а три сценария старта разбираются подряд в setup: чистая плата из 255, плата с нулями после eeprom_clear и плата версии 1, которую надо тащить на версию 2. Кнопка на D2 листает сценарии дальше: стёртый маркер, незнакомая версия 9, снова блок версии 1. LED на D12 горит всякий раз, когда блок приведён к рабочему состоянию — заливкой, миграцией или чтением как есть. Каждая строка решения печатается с причиной — привычка из протокольной работы: лог без причины превращает отладку в гадание.
Смотрите в монитор на строку решения: один и тот же конвейер приводит разные блоки к разным исходам, и каждая ветка заканчивается печатной строкой с причиной. На настоящей плате вместо присваиваний в массиве — EEPROM.read по адресам раскладки: скетч из code-блока выше переносится без правок, конвейер совпадает один в один. Попробуйте и обратный ход: в сценарии кнопки поставьте версии 9 значение 1 — конвейер честно мигрирует блок, потому что ему всё равно, откуда взялся старый: от прошлого обновления или от вашей руки. Для отладки миграций это дешёвый стенд.
- Маркер в нулевом адресе читается первым и не сдвигается никогда, что бы ни происходило с полями
- 0xA5 (165, двоичный 10100101) отличается и от чистой памяти 255, и от нулей после eeprom_clear
- Байт версии отвечает на вопрос «по какой карте писались поля»; незнакомая версия — честный сброс, а не угадывание
- Новые поля дописываются в хвост: адреса старых не сдвигаются, миграция сводится к дописыванию дефолтов и подъёму версии
- Порядок заливки: поля, версия, маркер последним — обрыв посреди первого запуска повторит заливку, а не оставит полумаркер
- 1. Почему маркер живёт именно в нулевом адресе?
- 2. Плата прогнала штатный пример eeprom_clear. Что теперь читается во всём килобайте?
- 3. В блоке версии 1 три поля, версия 2 добавляет два новых. Как я раскладываю поля версии 2?
- 4. Скетч прочитал версию 9: блок писала более свежая прошивка, чем стоит сейчас. Что делаю?
- 5. Обрыв питания случился посреди миграции v1 в v2. Что произойдёт при следующем старте?
Домашнее задание: найдите в своём проекте конфиг без версии и представьте, что завтра добавляете в него поле. Куда допишете? В хвост, версия поднимется на единицу, а конвейер при старте сделает остальное. Дальше по разделу остался последний вопрос — как отдавать настройки назад, к заводским, причём так, чтобы случайное касание кнопки ничего не стирало: фабричный сброс с пятью секундами удержания. А если ваш конфиг живёт на ESP32, та же идея носит другое имя — разделы и ключи в NVS, смотрите урок про Preferences.
Частые вопросы по теме урока
- Почему маркер живёт именно в нулевом адресе?
- Поля переезжают от версии к версии, маркер обязан стоять на месте — нулевой адрес единственный с такой гарантией. Скорость тут ни при чём. Он читается первым и никогда не сдвигается, что бы ни происходило с полями
- Плата прогнала штатный пример eeprom_clear. Что теперь читается во всём килобайте?
- eeprom_clear пишет ноль в каждую из 1024 ячеек: полный килобайт циклов записи и около 3,4 с работы. Маркер обязан отличаться и от этого нуля, и от чистых 255. Нули во всех ячейках
- В блоке версии 1 три поля, версия 2 добавляет два новых. Как я раскладываю поля версии 2?
- Дописывание в хвост делает миграцию дешёвой: старые адреса верны, дописать осталось два дефолта. Вставка в середину потребовала бы ручного переноса каждого поля. Новые поля — в хвост, адреса старых не трогаются
- Скетч прочитал версию 9: блок писала более свежая прошивка, чем стоит сейчас. Что делаю?
- Откат миграции вниз скетч не умеет: он не знает новых полей. Честный сброс дороже в смысле потерянных настроек, дешевле в смысле последствий. Честный сброс к дефолтам с печатью причины
- Обрыв питания случился посреди миграции v1 в v2. Что произойдёт при следующем старте?
- Версия поднимается последней, поэтому оборванная миграция выглядит как версия 1 — и повторится целиком. Идемпотентность — обязательное свойство миграции. Миграция пройдёт заново и допишет недописанное