Счётчик включений — первый EEPROM-скетч почти каждого, и мой не был исключением: read, плюс один, update, готово. Так я посчитал запуски насосной станции, пока одна гроза не оборвала питание ровно в тот момент, когда update был на полпути. Не знаю, что именно успело записаться в ячейку, но при следующем включении счётчик показывал что-то между двумя значениями, и вся идея «плюс один при старте» пошла пеплом: одна плохая запись испортила доверие ко всей статистике. Простые скетчи из урока про счётчик перезагрузок и урока про счётчик включений отлично считают ровно до первого такого сбоя — они молча считают и молча теряют.
Здесь я собираю счётчик, у которого потеря при обрыве питания равна нулю: значение хранится в двух ячейках-двойниках, а отдельный байт-маркер говорит, какая из них свежая. Инкремент всегда пишется в старую копию, и только потом переключается маркер — обрыв между этими двумя шагами оставляет маркер на старой, целой ячейке. Цена вопроса: два байта памяти вместо одного и одна лишняя запись на цикл. Механика одиночной записи и того, почему она вообще может порваться, разбиралась в уроке про стирание и запись битов — там окно записи на даташите, здесь — что случается, когда питание уходит прямо в этом окне.
Маркер чётности: порядок записи важнее силы записи
Раскладка памяти: ячейка 0 — копия A, ячейка 1 — копия B, ячейка 2 — маркер, в котором лежит 0xAA («свежая A») или 0x55 («свежая B»). Свежая память читается как 0xFF — это ваш сигнал первого запуска, отдельно урок про первый запуск не оставляет здесь шансов пропустить случай. Чтение при старте: маркер 0xAA — берём A, 0x55 — берём B, что-то другое — считаем, что памяти касается первый раз, и начинаем с нуля.
Инкремент идёт всегда в неактивную ячейку: маркер говорит «свежая A» — значит, пишем A+1 в B, убеждаемся чтением, что легло, и только теперь переключаем маркер на 0x55. Питание умерло после записи в B, но до переключения маркера? Маркер всё ещё указывает на A — счётчик вернёт старое значение, потерян один инкремент, и это лучший исход из возможных. Питание умерло посреди записи в B? B — мусор, но маркер всё ещё на A, мусор никто не читает. Худший случай во всей схеме — потеря одного шага счёта, потому что данные никогда не пишутся поверх последнего честного значения.
Отдельно про update: писать надо им, а не write — update трогает ячейку только при изменении, и на паре двойников это экономит реальные циклы: маркер физически не меняется, пока счёт не сдвинулся. Разницу write и update раскладывал урок про износ ячеек, здесь напомню лишь цену: два update на включение при ресурсе 100000 циклов дают запас в пятьдесят тысяч включений на одни и те же ячейки — для счётчика на плате это десятилетия.
#include <EEPROM.h> const int ADDR_A = 0; // копия Aconst int ADDR_B = 1; // копия Bconst int ADDR_M = 2; // маркер: 0xAA - свежая A, 0x55 - свежая B const byte MARK_A = 0xAA;const byte MARK_B = 0x55; int readCount() { byte m = EEPROM.read(ADDR_M); if (m == MARK_A) return EEPROM.read(ADDR_A); if (m == MARK_B) return EEPROM.read(ADDR_B); return 0; // 0xFF в маркере - первый запуск} void writeCount(int value) { byte m = EEPROM.read(ADDR_M); int target = (m == MARK_B) ? ADDR_A : ADDR_B; // пишем в НЕактивную EEPROM.update(target, value); if (EEPROM.read(target) != value) { // не легло - выходим, Serial.println("[счётчик] запись не подтвердилась, маркер не трогаем"); return; // старая копия осталась честной } EEPROM.update(ADDR_M, (target == ADDR_A) ? MARK_A : MARK_B);} void setup() { Serial.begin(9600); int n = readCount(); n += 1; writeCount(n); Serial.print("[старт] включение номер "); Serial.println(n); Serial.print("[дамп] A="); Serial.print(EEPROM.read(ADDR_A)); Serial.print(" B="); Serial.print(EEPROM.read(ADDR_B)); Serial.print(" маркер="); Serial.println(EEPROM.read(ADDR_M), HEX);} void loop() {}Прогон модели показывает всю механику за минуту: каждое включение пишет в ту ячейку, которую маркер сейчас не любит, и только потом переворачивает маркер — в дампе видно, как двойники поочерёдно обгоняют друг друга на единицу. Клавиша F втыкает сбой в самое опасное место: байт уже в памяти, маркер ещё не перевёрнут — и следующее чтение возвращает старую копию, как ни в чём не бывало. Потерян один инкремент, целая схема.
На плате всё то же самое, только модель сбоя заменяет жизнь: вы выдёргиваете питание, когда захотите. Практический совет из моего стенда — после writeCount при отладке печатать дамп трёх ячеек в Serial: за один вечер с выдёргиванием провода вы увидите больше, чем за месяц теоретических рассуждений. И держите счётчик в границах байта, если пишете одним байтом: 255 включений — потолок, дальше либо два байта через /256 и %256, как в уроке про счётчик включений, либо слово целиком, но тогда двойников понадобится два на каждое значение.
| Схема счётчика | Ячеек | Потеря при сбое | Записей на цикл | Когда брать |
|---|---|---|---|---|
| одна ячейка, update | 1 | мусор в ячейке | 1 | статистика, которую не жалко |
| двойник + маркер чётности | 3 | один инкремент | 2 | счётчики, которым верят |
| двойник + CRC вместо маркера | 4+ | один инкремент | 3 | когда маркеру не доверяют |
| кольцо из N ячеек + маркер | N+1 | один инкремент | 2 | записи чаще раза в минуту |
Границы схемы честные: двойник с маркером защищает одиночную запись, но не частую. Если ваш счётчик тикает раз в минуту, пары ячеек хватит на месяцы, и тут выходят на сцену кольцевые журналы — разбор в уроке про кольцевой журнал и в уроке про запись по кругу с маркером поколения; двойник встаёт в каждое кольцо без переделки. А если записей много и они ценные, то EEPROM вообще не тот носитель — внешняя 24C256 с сотней тысяч ячеек или лог на SD карту, у которой ресурса на порядки больше.
И не смешивайте счётчик с конфигом: настройки требуют проверки целостности через контрольную сумму, механика в уроке про CRC настроек и в уроке про два слота с откатом. Счётчику CRC не нужен — одиночный байт либо лег, либо нет, и подтверждение чтением это ловит; но если однажды заведёте структуру, где счётчик и настройки живут рядом, маркеры у них должны быть разные и адреса — не пересекаться.
- 1. Куда схема с маркером пишет новое значение счётчика?
- 2. Почему маркер хранит 0xAA и 0x55, а не 1 и 0?
- 3. Что делает скетч, если подтверждение чтением не совпало с записанным?
- Двойник + маркер чётности: значение в двух ячейках, маркер 0xAA/0x55 указывает свежую.
- Инкремент всегда в неактивную копию, маркер переключается вторым update после подтверждения чтением.
- Свежая память читается 0xFF — первый запуск опознаётся по маркеру, который не 0xAA и не 0x55.
- Потеря при обрыве посреди записи — один инкремент, целостность счёта не страдает никогда.
- Для частых записей двойник встраивается в кольцевой журнал; для ценных данных — внешний EEPROM или SD.
Частые вопросы по теме урока
- Куда схема с маркером пишет новое значение счётчика?
- Новое значение уходит в копию, которую маркер сейчас не считает свежей. Обрыв между записью и переключением маркера оставляет читаемой старую целую копию — теряется один шаг счёта, не больше. В неактивную копию, а маркер переключает второй записью
- Почему маркер хранит 0xAA и 0x55, а не 1 и 0?
- Пустая ячейка — 0xFF. Маркер из 0 и 1 на свежей плате читался бы как валидный, и счётчик начинался с мусора. 0xAA и 0x55 однозначно отделяют записанное от свежего. Свежая память читается 0xFF, и маркеры не должны с ней совпадать
- Что делает скетч, если подтверждение чтением не совпало с записанным?
- Маркер двигается только когда новая копия доказанно лежит в памяти. Не совпало — старая копия остаётся честной, инкремент потерян, но счёт не испорчен. Оставляет маркер на старой копии и сообщает о беде