Аквариумный контроллер прожил полгода на одном блоке настроек: яркость света, порог температуры, часы кормления. Блок лежал в EEPROM под magic-байтом и контрольной суммой, и всё было хорошо ровно до вечера, когда сосед включил сварку: свет мигнул дважды, плата перезагрузилась, а наутро аквариум стоял с заводской яркостью и графиком — кораллы получили двенадцать часов прямого света вместо мягкой закатной программы. Сумма честно не сошлась, модуль честно ушёл в дефолты, и никакой журнал этого не отменял: данных больше не было.
Виноват не CRC, а одиночество блока. Контрольная сумма ловит порчу, но не лечит её: обрыв питания посреди записи оставляет половину байтов от старого конфига и половину от нового, сумма такое смешение бракует — и это всё, на что она способна. Восстановить нечего, потому что второго экземпляра не было. Модуль настроек с magic и суммой мы собирали в уроке про put/get и CRC, а математику CRC8 — в уроке про побитый конфиг; здесь добавляется недостающий этаж — второй экземпляр и порядок записи.
План такой: два одинаковых слота по 16 байтов, флаг актуальности в отдельной ячейке, запись всегда в неактивный слот с проверкой суммы и только потом переключение флага. Разберём, почему обрыв в любой момент записи оставляет живой конфиг, где схема бессильна, и честно поговорим о том, когда хватает одного слота с дефолтами — двойная запись не бесплатна ни по циклам, ни по коду.
Слёт A/B: флаг актуальности решает всё
Раскладка: слот A занимает адреса 0–15, слот B — 16–31, флаг актуальности — ячейка 32. Внутри слота: magic на нулевом адресе, версия рядом, поля со второго по четырнадцатый, контрольная сумма последним байтом. Флаг хранит номер актуального слота — единицу или двойку. Свежая память читает 0xFF: ни единица, ни двойка не путаются с заводским состоянием, и загрузка однозначно выбирает слот A как стартовый. Держать флаг нулём я не рискую: ноль соседствует с 0xFF слишком близко, один перевёрнутый бит — и флаг «помолодел».
Дисциплина записи — единственное жёсткое правило схемы: данные всегда едут в неактивный слот. Полный порядок: записать поля, записать сумму, прочитать слот обратно и убедиться, что байты легли, и только затем переключить флаг на новую копию. Пока флаг не тронут, актуальным по-прежнему считается старый слот — сбой на любом шаге до переключения портит неактивную копию, которую мы и так собирались переписать.
Размер слота — решение, а не случайность. Шестнадцать байтов — строка карты памяти: magic, версия, с десяток полей и сумма укладываются в одну строку дампа, и глаз сверяет слот с картой без счёта. Когда полей становится больше восьми, я расту до 32 байтов — второй строкой, — но не выше: чем шире слот, тем больше байтов переписывает каждое сохранение и тем быстрее тает ресурс. Сто тысяч циклов на ячейку — паспорт ячейки, не микросхемы разом, и бюджет сводится по самой старшей из них. Сумма в последнем байте — CRC8 с полиномом 0x07, тем же, что в коде ниже: старший бит полинома неявный, восемь сдвигов на байт, и полином обязан совпадать у писавшего и у читающего. Пропустить случайную порчу восьмибитная сумма может с шансом один из 256 — поэтому поверх CRC стоят magic и версия, а не голая арифметика.
Загрузка идёт по флагу: флаг → слот → проверка magic и суммы. Сошлось — конфиг жив. Не сошлось — проверяем второй слот: цел, значит откатываемся на него и возвращаем флаг. Битые оба — дефолты, как в одиночной схеме. Родственный приём для больших карт памяти — чередование двух страниц на внешней 24C256: урок про резервные копии держит те же идеи, только копия живёт за пределами чипа и не боится килобайтовых масштабов.
#include <EEPROM.h> // Два слота настроек по 16 байтов: слот A @0, слот B @16,// флаг актуальности @32 (1 = актуален A, 2 = актуален B).// Слот: magic@+0, ver@+1, porogLo@+2, porogHi@+3, crc@+15.const int BAZA_A = 0;const int BAZA_B = 16;const int A_FLAG = 32;const byte MAGIC = 42;const byte VER = 1; int porog = 400; byte crc8Slota(int baza) { // сумма по ячейкам +1..+14 byte crc = 0; for (int i = 1; i < 15; i++) { crc ^= EEPROM.read(baza + i); for (byte j = 0; j < 8; j++) { crc = (crc & 0x80) ? (byte)((crc << 1) ^ 0x07) : (byte)(crc << 1); } } return crc;} bool slotZdorov(int baza) { return EEPROM.read(baza) == MAGIC && EEPROM.read(baza + 1) == VER && EEPROM.read(baza + 15) == crc8Slota(baza);} void pisatSlot(int baza, int znach) { // данные, потом сумма EEPROM.update(baza + 0, MAGIC); EEPROM.update(baza + 1, VER); EEPROM.update(baza + 2, lowByte(znach)); EEPROM.update(baza + 3, highByte(znach)); EEPROM.update(baza + 15, crc8Slota(baza));} void sohranit() { // пишем ТОЛЬКО в неактивный слот int bazaAkt = (EEPROM.read(A_FLAG) == 2) ? BAZA_B : BAZA_A; int bazaNov = (bazaAkt == BAZA_A) ? BAZA_B : BAZA_A; pisatSlot(bazaNov, porog); // четыре update - до 13 мс EEPROM.update(A_FLAG, (bazaNov == BAZA_A) ? 1 : 2); // флаг последним} bool zagruzit() { byte flag = EEPROM.read(A_FLAG); int bazaAkt = (flag == 2) ? BAZA_B : BAZA_A; // 0/255 считаем за A if (slotZdorov(bazaAkt)) { porog = EEPROM.read(bazaAkt + 2) + EEPROM.read(bazaAkt + 3) * 256; Serial.println("cfg: aktivnyj slot celyj"); return true; } int bazaDrug = (bazaAkt == BAZA_A) ? BAZA_B : BAZA_A; // откат if (slotZdorov(bazaDrug)) { porog = EEPROM.read(bazaDrug + 2) + EEPROM.read(bazaDrug + 3) * 256; EEPROM.update(A_FLAG, (bazaDrug == BAZA_A) ? 1 : 2); Serial.println("cfg: aktivnyj pobit - otkat na vtoroj slot"); return true; } porog = 400; // оба битых: дефолты и запись sohranit(); Serial.println("cfg: oba slota bit - defolty"); return false;} void setup() { Serial.begin(9600); zagruzit(); Serial.print("porog="); Serial.println(porog);} void loop() { // меню/кнопки правят porog - затем один вызов sohranit()}Почему схема вообще работает: EEPROM ATmega пишет и стирает одиночные байты, и одна операция записи атомарна — обрыв не может оставить «полбайта». Значит, цепочка update-ов опасна только между операциями, а между ними состояние всегда валидно: старый слот цел, и флаг смотрит на него. Самое уязвимое место — переключение флага, одна ячейка и около 3,3 мс записи: если свет уйдёт именно в этом окне, флаг останется старым — запись не доехала, или станет новым — доехала целиком. Оба исхода корректны, третьего в байтовой записи не бывает.
Контрольное чтение сразу за записью я долго считал паранойей, пока не поймал плату, у которой сумма сходилась сразу после сохранения и расходилась после холодного старта: ячейка деградировала на границе питающего спада. Одно чтение стоит ноль циклов — чтение ресурса не тратит — и закрывает целый класс косяков записи до того, как они доживут до перезагрузки.
Флаг живёт в отдельной ячейке, и при двойной записи это самая нагруженная ячейка схемы, хотя на вид самая скромная: слоты чередуются через раз, а флаг переписывается при каждом сохранении. Правки пальцем раз в день дают ей при паспортных 100000 циклов срок в два с лишним столетия; автосохранение по таймеру в пять секунд без проверки «менялось ли хоть что-то» сжигает те же 100000 за неполных шесть суток — при том что оба слота ещё почти свежие. Поэтому автосейв у меня стоит за грязным флажком в RAM: поля не тронуты — ни одной записи. Поля же едут через update, а не write: update пишет байт, только если тот изменился, и повторное сохранение того же порога не расходует ни цикла. Флагу эта скидка не достаётся — он меняется каждый раз, потому и числится в бюджете отдельной строкой.
Цена схемы — двойной объём и двойная работа при каждом сохранении: шестнадцать update-ов вместо восьми, до полусотни миллисекунд блокировки, если все байты меняются разом. Для настроек, которые правят пальцем раз в неделю, это ничто; для блока, который перезаписывается сам по таймеру, — уже бюджет, который считает урок про update и износ. Слет не отменяет update: он лишь удваивает запас ячеек, в которых этот update живёт.
Свет погас посреди записи
Разберём ловушку по шагам, потому что снаружи она выглядит как неслучайный сбой железа. Одиночный блок умирает ровно на записи: magic стоял, поля перезаписались наполовину, сумма осталась от старых данных — итог «настройки слетают при каждом отключении» и вечер с осциллографом. Со слётом тот же обрыв пишет неактивный слот: флаг ведёт на целую копию, загрузка проходит мимо битых ячеек, не заметив их, — а недописанный слот спокойно долежит до следующего сохранения и будет переписан целиком.
Хуже обрыва — старение: ячейка переворачивает бит через год работы, и активный слот бракуется по сумме без всякого сбоя питания. Именно для этого случая существует откат: второй слот, записанный в прошлый раз, обычно цел, и плата продолжает на его значениях — теряется максимум одна правка. При откате я всё же проверяю диапазон значений: порог из восьмибитной пары должен попадать в ожидаемый интервал, иначе верю сумме меньше, чем хотел бы.
Когда хватает одного слота: настройки восстанавливаются из головы за минуту, устройство стоит на столе, питание от сетевого адаптера с приличным фильтром, полей три-четыре. Тогда дефолты дешевле слота кода. Два слота я ставлю, когда устройство уезжает далеко — дача, гараж, чужой объект, — когда настройки это калибровки, набитые потом и кровью, и когда простой стоит денег. Дополнительный плюс: сохранение по таймеру в пять секунд вместо каждого прохода loop сокращает сами окна, в которые свет может погаснуть, — приём из урока про сброс пятью секундами. А если оба слота легли разом, magic и дефолты из урока про первый пуск остаются последним рубежом.
В тренажёре полный цикл слёта прожимается руками: сохранение дважды гоняет флаг A → B → A, каждый раз запись уходит в дальнюю копию, и дамп ячеек видно в Serial. Включите тумблер и нажмите кнопку — запись оборвётся на полдороге, флаг останется на прежнем слоте, и перезапуск покажет конфиг целым: сбой даже не всплыл. Команда b переворачивает бит в активной копии — вот теперь загрузка бракует сумму и откатывается на второй слот, в точности как при старении ячейки через год работы.
Честная оговорка: слоты в тренажёре — по три ячейки, magic, значение и сумма, а не полные шестнадцать, чтобы дамп читался с экрана. Порядок «данные → сумма → флаг» модель соблюдает один в один с платой, update-ы настоящие, суммы считаются по тем же сдвигам. На реальной плате те же строки вердикта печатают функции pisatSlot и zagruzit из скетча выше — раскладка байтов отличается только количеством резервных полей.
| Сценарий | Один слот с CRC | Слёт A/B |
|---|---|---|
| Обрыв питания в середине записи полей | Блок бракован по сумме, дефолты | Битый неактивный слот, флаг на целой копии — сбой незаметен |
| Обрыв перед записью суммы | Сумма старая, поля новые — блок бракован | То же: неактивный слот недописан, актуальный цел |
| Перевёрнутый бит от старения ячейки | Блок бракован, данные потеряны | Откат на второй слот, потеря максимум одной правки |
| Оба слота побиты: чужая прошивка, глубокий разряд | Дефолты | Тоже дефолты — слот не бессмертен, magic и версия обязательны |
- 1. Свет погас посреди записи полей в неактивный слот. Что увидит загрузка после перезагрузки?
- 2. Почему флаг актуальности хранит 1 или 2, а не 0 и 1?
- 3. Что даёт порядок «сначала данные, потом CRC, потом флаг»?
- 4. Активный слот бракуется по сумме после года работы. Что происходит?
- 5. Когда я соглашаюсь на один слот с дефолтами?
- Два слота по 16 байтов и флаг в отдельной ячейке: значения 1 и 2 не путаются с заводским 0xFF
- Запись всегда в неактивный слот: поля, сумма, контрольное чтение — и только потом флаг
- Байтовая запись EEPROM атомарна, поэтому обрыв между update-ами оставляет валидный конфиг под старым флагом
- Откат на второй слот спасает от старения ячеек: теряется максимум одна правка, а не весь конфиг
- Один слот с дефолтами уместен на столешнем устройстве; дальние объекты и калибровки — аргументы за слёт
Частые вопросы по теме урока
- Свет погас посреди записи полей в неактивный слот. Что увидит загрузка после перезагрузки?
- Флаг не переключался, активную копию запись не касалась. Недописанной осталась неактивная, которую следующее сохранение перепишет целиком. Целый активный слот: сбой вообще не заметен
- Почему флаг актуальности хранит 1 или 2, а не 0 и 1?
- 0xFF завода не спутается ни с единицей, ни с двойкой, а нулевой флаг от перевёрнутого бита имитирует «актуален A» на свежей плате. Свежая память читает 0xFF: ноль слишком близок к заводскому состоянию
- Что даёт порядок «сначала данные, потом CRC, потом флаг»?
- Пока флаг не переключён, актуален прежний слот: сбой до переключения портит только неактивную копию. Атомарность обеспечивает байтовая запись EEPROM. Обрыв в любой момент оставляет валидный конфиг под старым флагом
- Активный слот бракуется по сумме после года работы. Что происходит?
- Второй слот обычно цел — плата работает на его значениях, потеряв максимум последнюю правку. Это старение ячейки, а не сбой записи. Загрузчик откатывается на второй слот и возвращает флаг
- Когда я соглашаюсь на один слот с дефолтами?
- Дальние объекты, дорогие калибровки и платящий за простой владелец — аргументы за слёт. Столешний макет с тремя полями живёт с одним блоком. На устройстве на столе, где настройки восстанавливаются из головы за минуту