Термостат в гараже я настраивал один раз: старт в шесть утра, гистерезис полградуса, ночная температура пониже. Через месяц заметил, что гараж к обеду выстывает: время старта показывало шесть, а вот гистерезис — четыре градуса, и котёл честно молчал, пока не похолодает на четыре. В журнале отключений нашёлся виновник: пропадание света на две секунды, ровно в момент, когда я сохранял настройки. Запись блока оборвалась на середине — половина блока осталась от старой версии, половина стала новой.
Математика уязвимости простая. Окно сбоя — это время записи: страница за 5 мс, побайтовый блок на 40 байт — сотни миллисекунд. Вероятность попадания мала, но конфиг пишется при каждой настройке, а прибор живёт годами — за год окон набегают сотни. Хуже того: гибрид из двух версий настроек выглядит как нормальные данные. Числа похожи на правду, просто прибор живёт по смешанным правилам, и вы узнаёте об этом по поведению, а не по сообщению на экране.
Лечится не железом, а раскладкой: две копии конфига в памяти и правило писать всегда в ту, которая сейчас не актуальна. Пока новая копия не дописана и не проверена, актуальной остаётся прежняя — сбой питания во время записи портит максимум черновик. В этом уроке соберём схему целиком: заголовок блока со счётчиком версий, контрольная сумма в конце, чередование двух страниц и восстановление при старте.
В симуляторе чип снова изображает массив, но логика настоящая: кнопка D2 пишет конфиг в очередную копию, а тумблер D3 работает как рубильник света — щёлкнули перед записью, и питание пропало на середине блока. Дальше в Serial видно, как восстановление выбирает целую копию и игнорирует битую.
Две копии, счётчик версий и CRC в конце
Схема чередования держится на трёх приёмах. Первый: блок конфига лежит в памяти дважды — у меня копия A на странице 0 (байты 0–63) и копия B на странице 1 (байты 64–127). Второй: у каждого блока есть заголовок — magic-байт 170 и счётчик версий, который растёт с каждой записью. Третий: контрольная сумма в самом конце блока. Пишем всегда в чужую копию: считали актуальную версию N — пишем N+1 в другую страницу, не трогая текущую.
Почему счётчик, а не отдельный флаг-указатель в нулевой ячейке? Флаг — это самостоятельная ячейка вне копий, и именно она может пострадать при сбое: флаг испорчен — обе копии целы, а какая актуальна, не знает никто. Счётчик лежит внутри каждой копии и защищён её же контрольной суммой: даже когда одна страница оборвалась, вторая честно сообщает свой возраст. Восстановление сводится к выбору старшей из валидных копий — внешней памяти состояний не нужно вовсе.
Контрольную сумму беру CRC8 с полиномом 0x07: восемь итераций на байт, честная защита от затёртых и недописанных байтов — та же сумма, что мы разбирали для протоколов с контрольной суммой. Считается она по всем байтам блока, кроме самой себя, и лежит последней ячейкой. Порядок здесь принципиален, и после скетча я объясню, почему CRC именно в конце, а не в начале.
#include <Wire.h>#include <EEPROM.h> // для наглядности на внутреннем EEPROM; // на 24C256 та же схема через eeWritePage/eeReadBytes const int BLK = 64; // размер блока-копии, одна страницаconst int baseA = 0; // копия A: страница 0 (байты 0-63)const int baseB = 64; // копия B: страница 1 (байты 64-127) struct Config { int startHour; // час старта котла int startMin; // минуты int hystX100; // гистерезис в сотых градуса}; byte crc8(const byte *buf, int n) { byte crc = 0; for (int i = 0; i < n; i++) { crc ^= buf[i]; for (int b = 0; b < 8; b++) { if (crc & 0x80) { crc = (crc << 1) ^ 0x07; } else { crc = crc << 1; } } } return crc;} void saveCopy(int base, int ver, const Config &cfg) { byte buf[BLK]; for (int i = 0; i < BLK; i++) { buf[i] = 255; } buf[0] = 170; // magic buf[1] = ver; // счётчик версий buf[2] = cfg.startHour; buf[3] = cfg.startMin; buf[4] = cfg.hystX100 / 256; buf[5] = cfg.hystX100 % 256; buf[BLK - 1] = crc8(buf, BLK - 1); // CRC - последним байтом for (int i = 0; i < BLK; i++) { EEPROM.update(base + i, buf[i]); // 24C256: eeWritePage(base, buf, BLK) }} bool loadCopy(int base, int &ver, Config &cfg) { byte buf[BLK]; for (int i = 0; i < BLK; i++) { buf[i] = EEPROM.read(base + i); } if (buf[0] != 170 || buf[BLK - 1] != crc8(buf, BLK - 1)) { return false; } ver = buf[1]; cfg.startHour = buf[2]; cfg.startMin = buf[3]; cfg.hystX100 = buf[4] * 256 + buf[5]; return true;} // сохранение: всегда в копию, которая сейчас НЕ актуальнаvoid saveConfig(const Config &cfg) { int verA = 0, verB = 0; Config a, b; bool okA = loadCopy(baseA, verA, a); bool okB = loadCopy(baseB, verB, b); int curVer = 0; if (okA) { curVer = verA; } if (okB && verB > curVer) { curVer = verB; } if (!okA && !okB) { saveCopy(baseA, 1, cfg); // первый запуск - в копию A } else if (okA && (!okB || verA >= verB)) { saveCopy(baseB, curVer + 1, cfg); // актуальна A - пишем в B } else { saveCopy(baseA, curVer + 1, cfg); // актуальна B - пишем в A }} void setup() { Serial.begin(9600); int verA = 0, verB = 0; Config a, b; bool okA = loadCopy(baseA, verA, a); bool okB = loadCopy(baseB, verB, b); if (okA && okB) { if (verA >= verB) { Serial.print("load A ver="); Serial.println(verA); } else { Serial.print("load B ver="); Serial.println(verB); } } else if (okA) { Serial.print("load A ver="); Serial.println(verA); } else if (okB) { Serial.print("load B ver="); Serial.println(verB); } else { Config d; d.startHour = 6; d.startMin = 0; d.hystX100 = 50; saveCopy(baseA, 1, d); // дефолты сразу в копию A Serial.print("fresh: defaults to copy A"); Serial.println(""); }} void loop() { delay(100);}Порядок байтов внутри блока — половина надёжности всей схемы. CRC пишется последней по одной причине: обрыв записи либо её не касается (блок цел), либо не даёт ей обновиться — и тогда старая сумма не сойдётся с новыми данными, блок честно бракуется. Поставьте сумму в начало блока, и гибрид из новых данных со старой суммой начнёт проходить проверку по обеим копиям: сумма-то вычислена, просто для данных, которых вы не писали. Тот же принцип, по которому magic-байт в первом уроке раздела ставится в блок последним из служебных полей: всё, по чему принимается решение о валидности, должно обновляться в конце.
Восстановление при старте — три ветки, проговорим их словами. Обе копии прошли magic и CRC — берём старшую по счётчику версий: это последняя удачная запись. Валидна одна — берём её, даже если её счётчик меньше: недописанный черновик не участвует в выборе вообще. Не валидна ни одна — ставим дефолты и немедленно записываем их в копию A, чтобы следующий старт уже встретил валидный блок. Никакой логики вроде «а вдруг там что-то осмысленное»: гибрид не чинится, он перезаписывается.
Счётчик версий попутно решает вторую задачу — износ. Записи идут по двум страницам попеременно, и каждая из них принимает половину всех циклов: тот же принцип ротации, что в счётчике включений, только страницы вместо ячеек. А побочный эффект заголовка — диагностика: недописанная копия с меньшим счётчиком прямо говорит, что был сбой при сохранении версии N+1. У меня в гаражном термостате по этим заголовкам за зиму нашлись два проседания питания, о которых я и не подозревал.
| Стратегия хранения конфига | Что случится при сбое питания во время записи | Когда схемы хватает |
|---|---|---|
| Одна копия | Гибрид из двух версий настроек, похожий на нормальные данные | настройки не жалко: дефолт ставится за минуту |
| Копия плюс флаг актуальности | Флаг — отдельная ячейка: испорченный флаг теряет обе целые копии | редкие записи и приборы с кнопкой сброса |
| Две копии со счётчиком и CRC | Портится максимум черновик; актуальная копия остаётся целой | термостаты, поливалки, вся автоматика с настройками |
| Три копии и больше | Переживает даже двойной сбой подряд | почти никогда не оправдан: место и износ растут, надёжность — нет |
Когда конфиг больше страницы
Блок на 64 байта помещается в одну страницу и пишется одним пакетом за 5 мс — окно сбоя микроскопическое. Когда конфиг дорос до сотен байт — таблица зон, расписания на неделю, — чередование остаётся прежним, меняется механика: каждая копия занимает несколько страниц и пишется обёрткой writePage из урока про страничную запись, а CRC считается по всей копии и лежит в её конце. Окно уязвимости вырастет до десятков миллисекунд — на порядок лучше побайтовой записи, но честно помните про него: пострадает черновик.
И граница здравого смысла: конфиг на килобайт и больше — уже не про EEPROM. Такие вещи переносят на SD-карту с файлом конфигурации, как в уроке про конфиги на SD: файл пишется под временным именем, старая версия остаётся до подтверждения, — та же схема чередования, только средствами файловой системы. EEPROM оставьте тому, для чего он хорош: маленькому, горячему и критичному при старте, когда до SD ещё не дошли руки.
Финальная проверка схемы делается руками, и я советую её устроить: соберите проект, настройте и в момент сохранения выдерните питание. Десять попыток, ноль потерянных конфигов — это и есть приёмочный тест на чередование. Симулятор повторяет тот же эксперимент без выдёргивания вилок: сбой питания здесь тумблер, который вы щёлкаете перед записью, а восстановление отчитывается в Serial, что выбрало и почему.
- Две копии конфига на разных страницах: пишем всегда в неактуальную, и только после проверки она становится актуальной
- Заголовок блока — magic 170 и счётчик версий; CRC8 в последней ячейке: недописанный блок бракуется по несовпадению
- Восстановление: обе валидны — старший счётчик; одна — она; ни одной — дефолты и немедленная запись в копию A
- Счётчик вместо флага: возраст копии лежит внутри самой копии и защищён её же CRC — отдельной ячейке-флагу ничего не грозит
- CRC не всесильна: примерно один гибрид из 256 проходит проверку — добавьте диапазонные проверки полей
- Чередование делит износ пополам между страницами; конфиг на килобайт и больше — уже файл на SD-карте
- 1. Почему конфиг пишут в копию, которая сейчас НЕ актуальна?
- 2. Счётчик версий вместо флага-указателя выбран потому, что…
- 3. CRC в блоке лежит последним байтом. Что это даёт?
- 4. Обе копии прошли проверку CRC. Что берёт скетч?
- 5. Копия A: версия 5, CRC сошлась. Копия B: версия 6, CRC не сошлась. Что делает скетч при старте?
На этом внешний EEPROM сложился в полный инструмент: подключение по I2C с адресом 0x50, страницы с резкой по границам и чередование копий со счётчиком и CRC. Все три приёма работают и на внутреннем килобайте — механика та же, меняется только функция записи одного байта, а ресурс памяти мы считали ещё в уроке про износ. Когда данных станет много — файл конфигурации на SD уже ждёт в разделе про SD-карты. Соберите чередование один раз — и моя гаражная история с гибридом настроек останется страшной сказкой, а не вашим сценарием.
Частые вопросы по теме урока
- Почему конфиг пишут в копию, которая сейчас НЕ актуальна?
- Пока черновик пишется, актуальная копия не трогается. Сбой питания портит максимум недописанную копию — прежняя остаётся для восстановления. Чтобы при сбое питания во время записи актуальная копия осталась целой
- Счётчик версий вместо флага-указателя выбран потому, что…
- Флаг — отдельная ячейка вне копий, и её порча делает обе копии неразличимыми. Счётчик едет внутри каждого блока под защитой его суммы. Возраст копии лежит внутри самой копии и защищён её CRC — испорченный отдельный флаг не теряет ничего
- CRC в блоке лежит последним байтом. Что это даёт?
- Пишем данные, сумму — в самом конце. Недописанный блок почти наверняка получит несовпадение; сумма в начале дала бы гибриду шанс пройти проверку. Обрыв записи почти всегда оставляет старую сумму поверх новых данных — она не сойдётся, и блок бракуется
- Обе копии прошли проверку CRC. Что берёт скетч?
- Когда валидны обе, старший счётчик указывает на последнюю успешно завершённую запись. Меньший счётчик — предыдущая удачная, она нужна только как запас. Старшую по счётчику версий — это последняя удачная запись
- Копия A: версия 5, CRC сошлась. Копия B: версия 6, CRC не сошлась. Что делает скетч при старте?
- Невалидная копия не участвует в выборе, даже если её счётчик больше: там может лежать гибрид. Берём последнюю удачную запись — копию A. Берёт копию A: единственную валидную