Термостат гаражного ларя держал минус восемнадцать для заготовок, и в какой-то день компрессор перестал выключаться: цель уехала на минус двадцать два, мотор молотил сутки без паузы. Никто порог не трогал — я единственный пользователь, кнопок у прибора нет вообще. В ячейке порога вместо 11101110 лежало 11101010: перевернулся один бит весом четыре, и минус восемнадцать стал минусом двадцатью двумя. Magic-байт был цел — сто семьдесят как стоял.
Подозреваемый — пускатель сварочника соседа: тот щёлкает в розетку в трёх метрах от моей платы, и наводки в момент коммутации я ловил не раз на других проектах. Доказать не могу уже никогда, да и не нужно: важен вывод. Magic проверяет сам себя — один байт из девяти — и честно ничего не заметил. Ячейка с порогом подловила наводку, сбой питания или просто старость, а моя проверка целостности смотрела в другую сторону.
Лекарство — контрольная сумма по полям ящика: маленькое число, которое получается прокруткой всех значимых байтов через один цикл и хранится рядом. Изменится хоть один бит в любом поле — сумма перестанет сходиться, и ящик признаётся битым. В этом уроке положим рядом с Config две версии такой суммы: простую XOR и каноничную CRC8, разберём, от чего каждая спасает и от чего нет.
Почему magic недостаточно
Разложим угрозы по полкам. Первая — свежая плата: EEPROM без единой записи читается всеми единицами, каждый байт 255. Здесь magic справляется — приём ещё из первого урока про настройки: 255 не равен 170, код ставит дефолты. Вторая — обрыв записи: питание пропало посреди сохранения, в ящике половина старого и половина нового; magic может оказаться на месте и пропустить гибрид. Третья — порча хранящегося ящика: наводка, провал питания, старость ячейки у конца ресурса, о котором считали в уроке про износ. Один бит перевернулся — magic снова слеп.
Все три случая закрывает сумма по полям. Идея из урока про контрольную сумму в протоколах ложится на EEPROM один в один: отправитель — теперь это ваш же скетч при сохранении — считает сумму байтов и кладёт её в ящик; получатель — тот же скетч при старте — считает сумму по прочитанным полям и сверяет с сохранённой. Не сошлась — ящик битый, ставим дефолты и записываем их заново. Настройки дешевле перезадать, чем применять мусор с уверенным видом.
Один нюанс с свежей памятью, который я проглядел в первой версии. XOR всех 255 по чётному числу байтов даёт ноль, а в байте суммы на свежей плате лежит 255 — не совпало, поймали. А по нечётному числу полей XOR даёт 255 — совпадает с лежащим там мусором, и одна лишь сумма свежую плату пропустит. Поэтому парой к сумме всегда идёт magic: сумма ловит порчу, magic ловит пустоту. Вместе они закрывают почти всё.
#include <EEPROM.h> // Карта: 0 - magic, 1 - порог, 2 - интервал, 3 - контрольная суммаconst int addrMagic = 0;const int addrPorog = 1;const int addrInterval = 2;const int addrCrc = 3; byte crcXor() { byte c = 0; for (int a = addrPorog; a <= addrInterval; a++) { c = c ^ EEPROM.read(a); // magic не входит: он проверяется отдельно } return c;} void saveBox(byte porog, byte interval) { EEPROM.update(addrPorog, porog); EEPROM.update(addrInterval, interval); EEPROM.update(addrCrc, crcXor()); // сумма пишется последней} bool boxOk() { return EEPROM.read(addrMagic) == 170 && EEPROM.read(addrCrc) == crcXor();} void setup() { Serial.begin(9600); if (!boxOk()) { Serial.print("defolty: pamyat pusta ili isporchena"); Serial.println(""); saveBox(40, 10); } Serial.print("porog="); Serial.print(EEPROM.read(addrPorog)); Serial.print(" interval="); Serial.println(EEPROM.read(addrInterval));} void loop() { delay(100);}Что тут важно. Сумма считается по полям — с первого байта данных; magic не входит, он проверяется отдельно, и пусть каждая проверка отвечает за свою зону. Байт суммы не входит в собственный расчёт — иначе сходилось бы всегда. Записывается сумма последней, после всех полей: если питание пропадёт посреди saveBox, старая сумма не сойдётся с новым набором полей, и гибрид будет пойман при следующем старте.
XOR-сумма стоит один байт и одну строку цикла, скорость — микросекунды на ящик из девяти байтов. Слабое место тоже честное: XOR ловит нечётное число испорченных битов, а чётное пропускает. Два перевёрнутых бита в разных ячейках гасят друг друга, и сумма сходится как ни в чём не бывало. Для одного переворота за годы хранения — как в моей истории с компрессором — хватает за глаза. Но если рядом с платой варит сварочник, а ячейки отработали половину ресурса, берите CRC8.
CRC8 — тот же однобайтовый итог, но с перемешиванием: каждый входящий байт проворачивается восемью сдвигами с полиномом 0x07, и порча в любом месте меняет итог почти наверняка. Из случайного мусора проверку проходит один набор из 256 — вероятность 0,4 процента. Двойная порча, которую XOR проглотил бы, у CRC8 ловится с той же вероятностью 255 из 256. Таблицу на 256 байт ради ящика из девяти полей заводить не стоит: побитовый вариант медленнее на порядок, но на фоне 3,3 миллисекунды одной записи это по-прежнему микросекунды.
| Что случилось с ящиком | Проверка magic | Сумма по полям |
|---|---|---|
| Свежая плата: во всех ячейках 255 | Не совпал — дефолты | По чётному числу полей не сойдётся — дефолты |
| Один бит перевернулся за годы хранения | Не заметит | Поймает |
| Питание пропало посреди записи | Может пропустить | Поймает: сумма писалась последней |
| Мусор случайно дал правильную сумму | Не заметит | Пропустит: один случай из 256 |
CRC8: считаем по полям
#include <EEPROM.h> // Ящик: 0 - magic, 1 - порог, 2 - интервал, 3 - суммаconst int addrBox = 0; byte crc8(int from, int len) { byte crc = 0; for (int i = 0; i < len; i++) { crc = crc ^ EEPROM.read(from + i); for (byte b = 0; b < 8; b++) { if (crc & 128) { crc = (crc << 1) ^ 7; // полином 0x07 } else { crc = crc << 1; } } } return crc;} void setup() { Serial.begin(9600); EEPROM.update(0, 170); EEPROM.update(1, 40); EEPROM.update(2, 10); EEPROM.update(3, crc8(1, 2)); // по полям: без magic и без самой суммы byte saved = EEPROM.read(3); byte fresh = crc8(1, 2); Serial.print("saved="); Serial.print(saved); Serial.print(" fresh="); Serial.println(fresh); if (saved != fresh) { Serial.print("yashchik bitiy - defolty"); Serial.println(""); }} void loop() { delay(100);}Собираем проверки в порядок чтения. Первый рубеж — magic: не он, значит память пустая или чужая, ставим дефолты. Второй — сумма: magic на месте, сумма не сошлась — ящик был, но испортился, снова дефолты и перезапись. Третий — версия из урока про структуру настроек: magic и сумма в порядке, но раскладка старая — новым полям из резерва нужны дефолты. Только после всех трёх проверок поля считаются годными и едут в рабочий код.
Честные границы метода. Вероятность один из 256 — это не гарантия: из 256 случайных битых ящиков один пройдёт проверку, и прибор молча возьмёт мусор за истину. Для порога термостата цена вопроса — сутки молотящего компрессора; для данных подороже храните два экземпляра ящика с отдельными суммами и читайте тот, что сошёлся — механику копии ящика по второму адресу ящик из урока про put и get позволяет без всяких новых библиотек, только две копии структуры и два адреса. И вторая граница: сумма защищает целостность, а не правильность. Если сам скетч честно записал ошибочный порог — проверка сойдётся, потому что портить ничего не пришлось.
И граница снизу, о которой тоже стоит договориться заранее: ящику из одного-двух байтов сумма не нужна. Порог в одной ячейке проверяется диапазоном — значение обязано лежать в разумных пределах, и мусор за пределами отсеется без всякой арифметики. Сумма окупается, когда полей три и больше: именно тогда один перевёрнутый бит перестаёт отличаться от честного значения, и проверка диапазоном уже не спасает.
Где сумма спасала мне данные уже по факту: плата после chip erase от программатора — magic и сумма вместе разводят «пусто» и «битое»; обрыв записи при просадке питания, когда поля старые, а magic новый; и конденсатор на линии питания из урока про фильтр питания снижает саму частоту таких обрывов, хотя проверку не отменяет. Зуммер в симуляторе ниже пищит ровно в момент, когда сумма не сошлась — такое звуковое подтверждение я оставил в гаражном термостате навсегда.
- Magic защищает один байт; контрольная сумма защищает весь ящик: считайте по полям, храните последним байтом
- XOR-сумма: одна операция на байт, ловит одиночные перевороты бита, но пропускает чётные — две порчи гасят друг друга
- CRC8 с полиномом 0x07 ловит все одиночные и почти весь случайный мусор: проходит один набор из 256
- Свежую память из 255 ловит magic, даже там, где XOR по нечётному числу полей совпадает
- Порядок чтения: magic, потом сумма, потом версия; не сошлось — дефолты и перезапись ящика
- 1. Magic-байт цел, а порог сменился из-за одного перевёрнутого бита. Что это показывает?
- 2. Свежая EEPROM читается одними 255, XOR по нечётному числу полей даёт 255, в байте суммы лежит 255. Что спасает?
- 3. Какая доля случайного мусора проходит проверку однобайтового CRC8?
- 4. Чем XOR-сумма хуже CRC8?
- 5. Куда и когда писать байт контрольной суммы?
Частые вопросы по теме урока
- Magic-байт цел, а порог сменился из-за одного перевёрнутого бита. Что это показывает?
- Magic — контролька одного байта. Порчу любого другого байта видит только сумма по полям, которая сверяется при каждом старте. Magic проверяет только себя: поля он не защищает
- Свежая EEPROM читается одними 255, XOR по нечётному числу полей даёт 255, в байте суммы лежит 255. Что спасает?
- По нечётному числу полей XOR свежей памяти совпадает с 255. Пустоту ловит magic — поэтому сумма и magic едут парой. Проверка magic: 255 не равен 170
- Какая доля случайного мусора проходит проверку однобайтового CRC8?
- Однобайтовая сумма имеет 256 значений: случайный набор байтов совпадает с хранимой суммой в одном случае из 256, то есть 0,4 процента. Один набор из 256
- Чем XOR-сумма хуже CRC8?
- XOR гасит парные искажения, CRC8 перемешивает биты сдвигами с полиномом, поэтому пары тоже ловятся с вероятностью 255 из 256. Пропускает чётное число перевёрнутых битов: две порчи гасят друг друга
- Куда и когда писать байт контрольной суммы?
- Сумма пишется после данных и считается без себя самой. Тогда обрыв записи оставит несовпадение, а включённая в расчёт сумма сходилась бы всегда. Последним, после всех полей, и не включать его в собственный расчёт