Регулятор полива для дачи собирался за два вечера: яркость индикатора, порог сухости почвы, имя устройства на будущее. Всё влезло в одну struct Config из шести полей, EEPROM.put по адресу 10 — и готово. Через месяц приехал к даче, включил — а порог стоит чужой: плата неделю работала на мусоре, который остался в ячейках от предыдущих экспериментов. Защиты в модуле не было никакой: ни magic, ни версии, ни контрольной суммы — один put и вера в хорошее.
Отдельные приёмы у раздела уже есть: magic-байт с дефолтами разбирали в уроке про magic и дефолты, CRC8 по блоку — в уроке про побитый конфиг, переезд между версиями раскладки — в уроке про миграцию версии. Я тогда применял эти приёмы по одному — и на том же поливе наловил грабли, которые вылезают ровно на стыках: каждый приём в отдельности закрывает свой угол, и ни один не закрывает чужой.
Здесь собираю комплект целиком: карта блока с magic, версией и суммой, загрузка с тремя ветками первого пуска, гигиена правок struct и разговор о том, где хватает CRC8, а где пора на CRC16. Яркость в примере крутится ШИМ-ом на пине — механику объяснял урок про analogWrite; тут она просто поле в структуре.
Карта блока: magic, версия, поля, сумма
Мой блок занимает 16 байтов: адрес 0 — magic 42, адрес 1 — версия раскладки, адреса 2–12 — поля: яркость byte, порог int, имя устройства восемью байтами, адрес 13 — запасной, адрес 14 — контрольная сумма, адрес 15 — снова запас. Версия соседствует с magic не для красоты: любое изменение состава полей поднимает её, и старые платы сами уходят в миграцию или дефолты, не дожидаясь, пока вы вспомните о них вручную.
Сумму крутим по данным, а не по RAM-образу структуры: проход по адресам 1–13 через crc8 с полиномом 0x07. Сумма по указателю на структуру захватила бы дырки выравнивания с мусором — главную ловушку разбора sizeof и дырок; здесь она стоит дороже: сходившаяся не туда сумма бракует живой конфиг при каждом старте.
И почему сумма дополняет magic, а не дублирует его: magic проверяет один байт из девяти — себя. Перевёрнутый бит в ячейке порога оставит magic целым, и мусор уедет в работу: у моего регулятора задержка перед клапаном выросла с 300 мс до трёх секунд, а magic продолжал стоять как вкопанный. Сумма по полям ловит перевёрнутый бит где угодно в блоке, кроме собственной ячейки — поэтому она последняя и пересчитывается всегда.
#include <EEPROM.h>#include <string.h> // Блок настроек 16 байтов: magic@0, ver@1, поля 2..12, crc@14.// Ветки первого пуска: fresh (magic не тот), ver (чужая раскладка),// fail (сумма не сошлась) - каждая кончается записью блока.const int A_MAGIC = 0;const int A_VER = 1;const int A_POLYA = 2; // яркость(1) + порог(2) + имя(8) = 11 байтовconst int A_CRC = 14;const byte MAGIC = 42;const byte VER = 1; struct Config { byte yarkost; // 0..255, ШИМ индикатора int porog; // 0..1023, порог сухости char imya[8]; // "POLIV-01"}; Config cfg; byte crc8Polya() { // по адресам блока, не по RAM-образу byte crc = 0; for (int adr = A_VER; adr < A_CRC; adr++) { crc ^= EEPROM.read(adr); for (byte i = 0; i < 8; i++) { crc = (crc & 0x80) ? (byte)((crc << 1) ^ 0x07) : (byte)(crc << 1); } } return crc;} void defolty() { cfg.yarkost = 120; cfg.porog = 400; strcpy(cfg.imya, "POLIV-01");} void pisatBlok() { EEPROM.put(A_POLYA, cfg); EEPROM.update(A_MAGIC, MAGIC); EEPROM.update(A_VER, VER); EEPROM.update(A_CRC, crc8Polya());} bool zagruzitKonfig() { if (EEPROM.read(A_MAGIC) != MAGIC) { defolty(); pisatBlok(); Serial.println("cfg: fresh - defolty zapisany"); return false; } byte ver = EEPROM.read(A_VER); if (ver != VER) { // здесь читают старую раскладку и конвертируют; для первого // релиза честнее дефолты, чем совместимость ради трёх плат defolty(); pisatBlok(); Serial.print("cfg: ver "); Serial.print(ver); Serial.println(" - migraciya ili defolty"); return false; } if (EEPROM.read(A_CRC) != crc8Polya()) { defolty(); pisatBlok(); Serial.println("cfg: fail - summa ne soshlas, defolty zapisany"); return false; } EEPROM.get(A_POLYA, cfg); Serial.println("cfg: ok"); return true;} void setup() { Serial.begin(9600); pinMode(LED_BUILTIN, OUTPUT); bool ok = zagruzitKonfig(); digitalWrite(LED_BUILTIN, ok); // горит = конфиг живой analogWrite(3, cfg.yarkost);} void loop() { // меню/кнопки меняют cfg.porog - затем один вызов pisatBlok()}Три ветки, и порядок проверки важен. Сначала magic: не совпал — память свежая либо её перетёрла другая прошивка, веры ячейкам нет ни капли: заливаем дефолты, ставим magic и версию, пишем блок со свежей суммой. Потом версия: чужая версия — не «битые данные», а другая раскладка; читаем старый формат и конвертируем либо честно сбрасываем на дефолты, если тащить совместимость незачем. Последней идёт сумма: версия моя, но байты не те, что были записаны, — обрыв питания посреди put или наводка; снова дефолты, снова запись.
Каждая ветка дефолтов обязана кончаться записью блока, а не присваиванием полей. Скетч, который залил дефолты в RAM и забыл положить их обратно с новой суммой, открывает «первый пуск» при каждом включении: настройки живут до первого рубильника и снова сливаются в заводские. Я ловил это на регуляторе полива ровно так — и час сидел в скетчах, пока не напечатал в Serial, какой веткой стартует плата.
Та самая строка вердикта — fresh, ok, fail или ver — стоит дороже любой функции модуля. Печатайте её при каждом старте: вопрос «почему настройки слетают» за минуту превращается из гадания в констатацию ветки. На даче после этой строки диагноз занял полминуты: fail при каждом пуске, значит, сумма не пересчитывается после записи дефолтов, — пропущенный вызов pisatBlok().
Изменил struct — старые настройки раскладываются мусором
Самая коварная правка модуля настроек — добавить поле в середину структуры. Перезалили прошивку, get читает старые байты по новой раскладке: байт яркости попадает в порог, кусок имени — в яркость, поля получают правдоподобный мусор. Правдоподобность хуже явной чуши: порог из чужого байта иногда проходит проверку диапазона, и плата молча работает с выдуманными настройками. Сумма по новой раскладке не сойдётся — и это спасение, а не поломка.
Правило гигиены: состав полей и есть версия. Добавили поле, сменили тип, переставили поля — версию вверх, а в скетче ветка миграции: прочитать прежнюю раскладку, перенести значения, записать в новой. Три дачных платы не стоят совместимости — им честнее дефолты. Но версия в EEPROM обязана меняться синхронно с struct в коде, иначе каждая заливка прошивки превращается в лотерею с чужими байтами. Это правило делает модуль ремонтопригодным: вы не «чините настройки», вы управляете их версиями.
Посчитаем, что реально уезжает в ячейки при pisatBlok(). В структуре Config три поля — byte, int, char[8] — это одиннадцать байтов полезных данных, а sizeof выдаёт двенадцать: компилятор вставил дырку после байта яркости, чтобы int встал по чётному смещению. put(A_POLYA, cfg) прогоняет все двенадцать: яркость ляжет на адрес 2, дырка с RAM-мусором — на адрес 3, порог — на адреса 4 и 5, имя — на 6–13. Сумма по адресам 1–13 накрывает и дырку, потому что дырка реально лежит в EEPROM и участвует в чтении: get вытащит её обратно, и при следующем put она вернётся в ячейку уже другой. Мусор в дырке внутри одного запуска стабилен, между запусками — нет: этим она и отличается от полей, которым вы значения присвоили. Один Serial.print(sizeof(Config)) в setup закрывает все догадки о раскладке.
Грабля сохранения, на которой я съел вечер: яркость менялась ручкой, и наивный код писал блок при каждом проходе loop, где analogRead дрожит на единицу вечно. Десятки put в минуту без всякого человеческого участия — update, правда, пропускает неизменившиеся одиннадцать байтов, но дрожащий байт яркости он пишет честно. Лечение — сохранять не при изменении, а по таймеру: значение крутим свободно, а pisatBlok вызываем, когда с последней правки прошло больше пяти секунд по millis. За день активной подстройки выходит пара десятков записей вместо тысяч, тот же таймер гасит и двойную запись от дребезга кнопки «сохранить». Порог сухости, меняющийся кнопками раз в сутки, живёт по расписанию из первого урока про update.
Теперь сам алгоритм, чтобы полином 0x07 не выглядел магией: это стандартный CRC-8, тот же, что в PEC-байте шины SMBus, — x⁸+x²+x+1, восемь сдвигов на байт, без отражения и без финального XOR. Сумма такой степени ловит любой пакетный сбой длиной до восьми битов подряд, и одиночный перевёрнутый бит под это условие попадает всегда; на редкие двойные совпадения остаётся шанс один из 256. Скорость тоже не проблема: на 16 МГц байт с восемью сдвигами считается за микросекунды, и тринадцать байтов блока — десятки микросекунд, так что пересчёт при каждом сохранении loop-у ничего не стоит. Криптостойкости тут нет и не нужно: конфиг защищают не от взлома, а от состарившихся ячеек и обрыва питания посреди put.
Какую сумму брать? Для блока в 14 байтов хватает CRC8: один байт накладных, шанс пропустить сбой порядка 1/256 — за годы жизни конфига это ничто. CRC16 удваивает накладные и ужимает шанс до 1/65536 — смысл появляется на больших блоках: в штатном примере eeprom_crc из документации Arduino 32-битная сумма гоняется по всей тысяче ячеек, и там восьми битам тесно физически. Мой рабочий порог: до 64 байтов блока — CRC8, дальше — CRC16, дамп всей памяти — CRC32.
В тренажёре ветки первого пуска прогоняются руками: модель блока из шести ячеек живёт по той же карте, ручка задаёт яркость, кнопка «перезапускает» плату с проверкой magic, версии и суммы, тумблер портит ячейку порога как сбой питания. Структур put/get в симуляторе нет — модель раскладывает поля по адресам update-ом, как на реальной плате делает один вызов put; раскладка получается байт-в-байт одинаковой.
| Ветка старта | Что видит скетч | Что делает |
|---|---|---|
| fresh | magic не равен 42: завод или чужая прошивка | Дефолты, magic, версия, сумма, запись блока |
| ver | magic на месте, версия раскладки чужая | Миграция старой раскладки или честный сброс к дефолтам |
| fail | версия своя, сумма не сошлась | Дефолты, свежая сумма, запись, строка в Serial |
| ok | версия своя, сумма сошлась | Чтение полей get-ом, работа |
- 1. Почему контрольную сумму считают по данным, а не по RAM-образу структуры?
- 2. Magic на месте, версия раскладки чужая. Что это значит?
- 3. Когда вместо CRC8 оправдана CRC16?
- 4. Настройки слетают при каждом отключении питания. Что проверить первым?
- 5. Блок полей — 14 байтов. Какая контрольная сумма разумна?
- Карта блока: magic@0, версия@1, поля со второго адреса, сумма последним байтом; версия меняется вместе с struct
- Три ветки первого пуска — fresh, ver, fail — каждая кончается записью блока со свежей суммой
- Сумма по данным, не по RAM-образу: дырки выравнивания с мусором ломают её между включениями
- CRC8 для блоков до 64 байтов, дальше CRC16: шанс пропуска 1/256 против 1/65536
- Строка вердикта fresh/ok/fail/ver в Serial при старте превращает «слетающие настройки» в диагноз за минуту
Частые вопросы по теме урока
- Почему контрольную сумму считают по данным, а не по RAM-образу структуры?
- put копирует sizeof байтов образа, включая заполнители, а мусор в дырках меняется от запуска к запуску. Сумма по адресам EEPROM сверяет то, что реально лежит в ячейках. В образ попадают дырки выравнивания с мусором — сумма плывёт между включениями
- Magic на месте, версия раскладки чужая. Что это значит?
- Magic подтверждает, что блок писала ваша программа, а версия — что другая её версия с иной раскладкой полей. Это не порча, а смена формата: конвертируйте или сбрасывайте. Другая раскладка полей: нужна миграция или дефолты
- Когда вместо CRC8 оправдана CRC16?
- CRC8 стоит один байт и даёт шанс пропуска около 1/256 — для блока в десятки байтов достаточно. На больших блоках и дампах всей памяти берут CRC16 или CRC32. На больших блоках, от нескольких десятков байтов
- Настройки слетают при каждом отключении питания. Что проверить первым?
- Если дефолты остаются только в RAM, каждый старт заново бракует блок и заливает заводские значения. Проверьте, что после свежей суммы блок реально записан, — и печатайте вердикт ветки. Пишет ли ветка дефолтов блок обратно в EEPROM со свежей суммой
- Блок полей — 14 байтов. Какая контрольная сумма разумна?
- Для блока в 14 байтов CRC8 — разумный минимум: один байт накладных, вероятность пропустить сбой порядка 1/256. Magic сам по себе проверяет только свой байт. CRC8: один байт, шанс пропуска около 1/256