Плата вернулась с объекта через год: стояла в щитке насосной под чужим скетчем счётчика воды. Я прошил свой термостат, включил — и увидел в мониторе порог 190 и режим 3, которых не задавал никто. Причина прозаична: EEPROM не очищается при перепрошивке — там, где жил чужой счётчик, остались его байты, и мой скетч принял их за свои настройки. На свежей плате такого не бывает, а на плате с биографией — регулярно, и каждую такую плату я теперь прогоняю через слово-печать.
Стандартная защита — маркер-байт: в разделе уже два разбора, однобайтовый 0x42 с дефолтами и 0xA5 с версией структуры. Оба отлично ловят пустую память со всеми 255, но против живого мусора от прошлого скетча одиночный байт слаб: байт-маркер — это 1 шанс из 256, что случайный байт совпадёт с ним, а мусор в живой памяти не случайный. Прошлый скетч писал по этим же адресам свои значения, и одно из них имеет все шансы оказаться вашим маркером.
Моё решение для плат с историей — слово из двух байт: 0x55 и 0xAA, пара 0xAA55. Совпадение двух байт сразу — 1 шанс из 65536; для сравнения, пара 0x55AA держит подпись загрузочного сектора IBM PC с 1981 года — конвенция, пережившая четыре десятка лет компьютерной техники. Дальше: где стоит слово, в каком порядке пишется, как прибор говорит вердикт вспышками и чего слово не делает ни при каких условиях.
Печать на конверте: слово в конце блока
Блок конфига я раскладываю так: поля с адреса 10, слово-печать в самом конце — на 18-м и 19-м адресах, младший байт 0x55 первым. Печать пишется последней, после всех полей: обрыв питания посреди заливки дефолтов оставит блок без печати, и на следующем старте проверка снова увидит «не совпало» — заливка повторится целиком. Правило то же, что у нуль-терминатора строки в уроке про SSID: последний байт превращает недописанное в готовое.
Вердикты загрузки прибор показывает вспышками — прибор без экрана обязан говорить. Одна длинная — слово сошлось, конфиг загружен. Двойная — память свежая, 255 и 255: первый запуск, заливаю дефолты и ставлю печать. Тройная — слово не сошлось: чужой мусор или битая ячейка, заводской сброс и новая печать. Три состояния, три ритма — язык, который читается через комнату. Ручной сброс кнопкой с удержанием живёт в отдельном уроке: здесь сброс не по воле человека, а по вердикту проверки.
И честная граница применимости. На Uno с загрузчиком optiboot EEPROM переживает перепрошивку — потому «старые байты» там реальны. На ATtiny85 всё наоборот: чип по умолчанию стирает EEPROM при заливке прошивки, сохранение лечится фьюзом EESAVE. Там сценарий «плата с чужим прошлым» встречается реже, зато настройки пропадают при каждом обновлении прошивки. Сначала узнайте поведение своей платы, потом выбирайте глубину защиты.
#include <EEPROM.h> // Карта блока: 10-17 конфиг, 18-19 слово (0x55 младшим, 0xAA старшим).const int A_CFG = 10;const int A_WORD = 18;const byte LO = 0x55;const byte HI = 0xAA;const byte PIN_LED = 12; int porog; // порог в квантах: единицы АЦП / 4byte rezhim; void blinky(byte n, int ms) { for (byte i = 0; i < n; i++) { digitalWrite(PIN_LED, HIGH); delay(ms); digitalWrite(PIN_LED, LOW); delay(120); }} void zaliDefaults() { porog = 125; // 500 единиц АЦП rezhim = 1; EEPROM.update(A_CFG, porog); EEPROM.update(A_CFG + 1, rezhim); EEPROM.update(A_WORD, LO); // печать - последней EEPROM.update(A_WORD + 1, HI); Serial.println("defolty zapisany, pechat postavlena");} void loadCfg() { byte lo = EEPROM.read(A_WORD); byte hi = EEPROM.read(A_WORD + 1); if (lo == 255 && hi == 255) { // свежая память zaliDefaults(); blinky(2, 150); Serial.println("pervyi zapusk"); return; } if (lo != LO || hi != HI) { // чужое или битое Serial.print("chuzhie dannye: slovo "); Serial.print(lo); Serial.print("/"); Serial.println(hi); zaliDefaults(); // заводской сброс blinky(3, 150); return; } porog = EEPROM.read(A_CFG); rezhim = EEPROM.read(A_CFG + 1); if (porog > 250) { porog = 125; } // страховка от деградации ячейки blinky(1, 400); Serial.print("konfig zagruzhen: porog="); Serial.print(porog * 4); Serial.print(" rezhim="); Serial.println(rezhim);} void setup() { Serial.begin(9600); pinMode(PIN_LED, OUTPUT); loadCfg(); // до любого обращения к полям} void loop() { delay(100);}Лестница в loadCfg короткая и жёсткая: пара 255 и 255 — свежая память; пара не совпала — чужое или битое; совпала — грузим поля и всё равно страхуем значения границами: порог больше 250 квантов после деградации ячейки прижимается к дефолту. Никакой ветки «а вдруг повезёт» нет: слово — единственный вход к полям конфига. Порог хранится делённым на четыре — та же упаковка, что в тюнинге кнопками, и то же правило при чтении: значение в границах или дефолт.
Заводской сброс по несоответствию — не паника, а штатный сценарий. Байты чужого скетча честно приняты за мусор, поверх залиты дефолты, поставлена печать — прибор продолжает жизнь с заводскими настройками, и хозяин узнаёт об этом по трём вспышкам при включении. Потеря правки оператора при таком сбросе стоит меньше, чем работа по чужим порогам: неделя с рассадой при 190 вместо 500 единиц дороже одного повторного прохода по меню.
Печать обязана жить столько же, сколько конфиг: она лежит в ячейках, которые больше ничего не пишет, и ставится через update. Если слово сотрётся от старости ячейки, ваш же валидный конфиг получит тройное мигание и сброс — отсюда же правило карты: на адреса печати не наступает никто, раскладка зон фиксируется до прошивки, а за разъезды зон отвечает карта адресов.
Переключите сценарий на «чужой скетч» и смотрите в монитор: печать читается как 55 и 194. Свежий глаз видит 55 и думает, что это 0x55, — это 0x37, пятьдесят пять в десятичной системе; ровно на таких парах ловятся первые глаза, и в рабочих скетчах я печатаю байты в HEX. Вердикт — тройное мигание, поверх заливаются дефолты. Подвигайте ручку A0 и повторите: байт в ячейке конфига меняется, вердикт — нет. Чтобы обмануть слово, чужой мусор должен угадать два байта подряд — 1 шанс из 65536; повторите опыт десяток раз — совпадения не увидите, и это лучший аргумент в пользу двух байт вместо одного.
Сценарий «свой конфиг» даёт одну длинную вспышку и загрузку — та лестница, по которой плата ходит каждый день. На живой плате всё то же делает скетч из code-блока: loadCfg стоит в setup до любого обращения к полям, и ни одна ветка программы не читает конфиг мимо проверки. Пересадка чипа из платы в плату — тот же «чужой» сценарий, но с сюрпризом: если раскладка адресов совпадает, слово остаётся, настройки валидны — прибор продолжает работать без заливки дефолтов.
Слои проверки: слово, версия, сумма
| Слой защиты | Байтов | Что ловит | Шанс пропустить |
|---|---|---|---|
| Маркер-байт | 1 | свежую память со всеми 255 | 1 из 256 на совпадение с мусором чужого скетча |
| Слово 0xAA55 | 2 | свежую память и чужой мусор | 1 из 65536; пустая пара ловится отдельной веткой |
| Слово плюс байт версии | 3 | и чужую раскладку полей | версия мигрирует или сбрасывает честно |
| Слово плюс CRC8 блока | 3-4 | и тихую порчу своего байта | 1 из 256 на сбойную ячейку при живой печати |
Слои не конкурируют, а складываются: слово отвечает на вопрос «моя ли память», версия — «та ли раскладка полей», контрольная сумма — «цела ли моя запись». Слово с версией стоят рядом во всех моих конфигах; сумма — по потребности: CRC8 по блоку ловит тихую деградацию ячейки, которую слово пропустит, если печать цела, а поля испортились. Цена вопроса — один-два байта карты.
И про свежую память отдельно: 255 — стёртое состояние ячейки, а не ноль; пара 255 255 — самый честный признак первого запуска, механика разбиралась в уроке про 0xFF. Но плата после заливки нулями — кто-то прогнал очистку — читается как 00 00: это тоже «не совпало», вердикт будет тройным. Пользователю разница не видна: обе ветки ведут к дефолтам. Разработчику — видна: строка в мониторе с прочитанными байтами говорит, что именно лежало на месте печати.
- 1. Почему слово из двух байт надёжнее одиночного маркера?
- 2. Где лежит слово и когда оно пишется?
- 3. Плата пережила чужой скетч, слово не сошлось. Что делает loadCfg?
- 4. Свежая плата: на месте печати читается 255 и 255. Какой вердикт?
- 5. Чего слово 0xAA55 не делает ни при каких условиях?
- Слово 0xAA55 — два байта: шанс чужого мусора совпасть 1 из 65536 против 1 из 256 у одиночного маркера
- Печать стоит в конце блока и пишется последней: обрыв питания оставляет блок без печати, заливка повторится сама
- Три вердикта вспышками: одна — загрузка, две — первый запуск, три — чужой мусор и заводской сброс
- На Uno EEPROM переживает перепрошивку, на ATtiny85 стирается без фьюза EESAVE — глубина защиты зависит от платы
- Слово — подпись, не шифр; рядом живут версия раскладки и CRC, каждый слой ловит своё
Частые вопросы по теме урока
- Почему слово из двух байт надёжнее одиночного маркера?
- Одиночный байт угадывается мусором с шансом 1/256, и мусор в живой памяти не случайный — его писал прошлый скетч по тем же адресам. Шанс чужого мусора совпасть — 1 из 65536 против 1 из 256
- Где лежит слово и когда оно пишется?
- Печать в конце блока пишется последней: обрыв питания оставляет блок без печати, и заливка дефолтов повторится сама при следующем старте. В конце блока, после всех полей
- Плата пережила чужой скетч, слово не сошлось. Что делает loadCfg?
- Заводской сброс по несоответствию — штатная ветка: чужие байты приняты за мусор, прибор продолжает жизнь с заводскими настройками и новой печатью. Заливает дефолты, ставит новую печать, мигает трижды
- Свежая плата: на месте печати читается 255 и 255. Какой вердикт?
- Пара 255 255 — стёртое состояние ячеек, самый честный признак первого запуска. Заливка дефолтов заканчивается установкой печати. Первый запуск: дефолты, печать, двойная вспышка
- Чего слово 0xAA55 не делает ни при каких условиях?
- Слово читается из памяти любым — это подпись, а не шифр. От чтения оно не защищает; от чужого мусора и битой печати — защищает. Не защищает байты от чтения чужой программой