Курятник с автоматической дверцей — мой самый долгоживущий проект: прибор висит в сарае четвёртый год. Когда я его собирал, на энергонезависимую память было ровно три задачи: запомнить порог освещённости и время открытия, посчитать циклы открытия дверцы и держать журнал последних сбоев, чтобы по приезду понять, что случилось без меня. Три задачи — и я трижды переписывал код с нуля, потому что каждая попытка обрастала граблями: то счётчик съедал ячейку за полгода, то журнал затирал конфиг, то после обновления прошивки прибор читал старые поля как новые.
К четвёртому проекту я перестал изобретать и завёл три заготовки, которые теперь таскаю из скетча в скетч почти без изменений: конфиг с версией и контрольной суммой, счётчик с кольцевой раскладкой износа и кольцевой журнал последних событий. Они выросли не из учебника, а из поломок: счётчик сгорел в гаражном контроллере, конфиг читался мусором после обновления, журнал затирал себе же соседа. В этом уроке разбираю все три по строкам. По моему опыту они покрывают девять потребностей из десяти, а десятая собирается из этих же кирпичей.
Все заготовки собраны на update вместо write — после урока про update это дефолт, — и у каждого адреса в карте памяти есть хозяин, подписанный в шапке. Номера адресов вы подвинете под свой прибор, но не трогайте порядок: сначала конфиг, потом счётчик, потом журнал. Так карты разных проектов читаются на взгляд.
Паттерн 1: конфиг с версией и контрольной суммой
#include <EEPROM.h> // Карта: 0 - версия, 1 - порог, 2 - калибровка, 3 - XOR-суммаconst int A_VER = 0;const int A_THR = 1;const int A_K = 2;const int A_SUM = 3; byte cfgVersion = 2; // версия раскладки полейint threshold = 400;int calib = 106; // коэффициент x100 int xorSum() { int s = 0; for (int a = A_VER; a < A_SUM; a++) s = s ^ EEPROM.read(a); return s;} void cfgSave() { EEPROM.update(A_VER, cfgVersion); EEPROM.update(A_THR, threshold); EEPROM.update(A_K, calib); EEPROM.update(A_SUM, xorSum()); // сумма пишется последней} bool cfgLoad() { if (EEPROM.read(A_VER) != cfgVersion) return false; if (EEPROM.read(A_SUM) != xorSum()) return false; threshold = EEPROM.read(A_THR); calib = EEPROM.read(A_K); return true;} void setup() { Serial.begin(9600); if (cfgLoad()) { Serial.print("load thr="); Serial.println(threshold); } else { Serial.println("cfg: default"); cfgSave(); }} void loop() { delay(100);}Зачем версия, если данные уже защищены суммой? Сумма ловит случайный мусор: обрыв питания посреди записи, свежую плату, брак ячейки. Версия ловит плановые изменения: вы добавили в конфиг третье поле, прошили обновлённую прошивку — и она честно прочитала старый расклад, где на месте нового поля лежал вчерашний гистерезис. Версия не совпала, cfgLoad вернула false, прибор стартовал на дефолтах — и вы узнали о переезде данных заранее, а не по странному поведению через месяц.
XOR-сумма — минимум защиты: один байт, три строки кода. Она ловит одиночные ошибки и чётное число порченых битов в разных байтах, но пропускает, например, одновременную порчу двух одинаковых байтов. Если настройка стоит денег — инкубатор, аквариум с рыбами, — берите CRC8 из урока про контрольные суммы: на Arduino это пара строк побитового цикла или таблица на 256 байт, а ловит он заметно больше комбинаций. Моё правило: XOR там, где старт на дефолтах безболезнен, CRC — где дефолт уже потеря.
Обратите внимание на порядок записи в cfgSave: сумма пишется последней. Если питание пропадёт посреди cfgSave, в памяти останется старая сумма при новых данных или наоборот — в обоих случаях проверка не сойдётся и прибор уйдёт на дефолты, а не возьмёт полуфабрикат за рабочий конфиг. Тот же принцип мы применяли к разорванной записи int в уроке про байты int и float: контрольные байты всегда последними.
В cfgLoad нет отдельного magic-байта, и это не лень, а осознанный выбор: роль magic здесь играет версия. На чистой EEPROM ячейка версии читается как 255 — не равно двум, прибор уходит на дефолты и сразу же записывает правильный конфиг с суммой. Если вы привыкли к паре magic-плюс-версия из первых уроков — добавьте magic в адрес 0 и включите его в сумму, хуже не станет. Я перестал его писать, когда заметил, что версия покрывает оба случая сразу: и пустую память, и чужой расклад.
Паттерны 2 и 3: счётчик по кругу и журнал событий
#include <EEPROM.h> // Паттерн 2. Счётчик по кругу. Карта: значения 10-17, теги 20-27.// Тег - порядковый номер записи; свежая ячейка - с самым большим тегом.const int RING_N = 8;const int A_VAL = 10;const int A_TAG = 20; int ringNewest() { int best = 0; for (int i = 1; i < RING_N; i++) { if (EEPROM.read(A_TAG + i) > EEPROM.read(A_TAG + best)) best = i; } return best;} int ringRead() { return EEPROM.read(A_VAL + ringNewest());} void ringSave(int v) { int next = (ringNewest() + 1) % RING_N; int tag = EEPROM.read(A_TAG + ringNewest()) + 1; if (tag > 250) { // теги упёрлись в потолок байта for (int i = 0; i < RING_N; i++) EEPROM.update(A_TAG + i, 0); tag = 1; } EEPROM.update(A_VAL + next, v); EEPROM.update(A_TAG + next, tag);} // Паттерн 3. Журнал последних 8 событий. Карта: 30 - курсор, 31-38 - слоты.const int A_CUR = 30;const int A_JRN = 31; void logEvent(byte code) { int cur = EEPROM.read(A_CUR); if (cur > 7) cur = 0; // защита от мусора в курсоре EEPROM.update(A_JRN + cur, code); cur = cur + 1; if (cur > 7) cur = 0; EEPROM.update(A_CUR, cur);} void journalDump() { // свежие события сверху int cur = EEPROM.read(A_CUR); if (cur > 7) cur = 0; for (int k = 0; k < RING_N; k++) { int i = cur - 1 - k; if (i < 0) i = i + RING_N; Serial.print("ev="); Serial.println(EEPROM.read(A_JRN + i)); }} void setup() { Serial.begin(9600); Serial.print("ring="); Serial.println(ringRead()); journalDump();} void loop() { // ringSave - по ходу счётчика, logEvent - по событию delay(100);}Как работает кольцо. Восемь пар значение-тег; при записи ищем ячейку с максимальным тегом и пишем в соседнюю, помечая тегом на единицу больше. Свежей всегда оказывается ячейка с наибольшим тегом, отдельная ячейка-курсор не нужна — и износ равномерно делится на все восемь. Тег живёт в одном байте, поэтому на отметке 250 все теги обнуляются. Считаем честно: обнуление случается раз в 250 записей кольца; при записи раз в минуту это каждые четыре с небольшим часа, и одна запись за это время теряется. Для счётчика циклов дверцы, который читают по приезду, я считаю это приемлемым; для денег — нет, там теги делают двухбайтовыми, и потолок отодвигается до 65535 записей: при той же минутной частоте потеря случается раз в 45 дней, а при почасовой записи — раз в семь лет.
Журнал устроен проще, потому что события редкие. Курсор хранит номер следующего слота, каждое событие пишется на своё место, самое старое затирается. Восемь слотов — это восемь последних событий: мне хватает, чтобы восстановить последовательность до приезда. Коды событий фиксирую в шапке скетча: 1 — сработало реле, 2 — пропадало питание, 3 — калибровка, 9 — заводской сброс. Читаемость важнее экономии: код 12 через полгода вам ничего не скажет.
Единственное слабое место журнала — курсор: он пишет при каждом событии и стареет быстрее всех слотов. События раз в десять минут — это 144 записи в сутки, и курсору хватит 100000/144/365 — примерно двух лет, по цифрам таблицы ресурса. Если события чаще, курсор тоже уводите в кольцо или откажитесь от него вовсе: читайте все восемь слотов и ищите самый свежий по тегам, как в счётчике. А показания раз в секунду в EEPROM не тащат вовсе — их место описано в уроке про SD-логирование.
Сводка: что куда писать и как часто
| Паттерн | Адреса в примере | Частота записи | Прогноз ресурса |
|---|---|---|---|
| Конфиг с версией и суммой | 0-3 | при изменении настройки | десятилетия: записи считанные |
| Счётчик, кольцо из 8 | значения 10-17, теги 20-27 | раз в минуту максимум | около 1,5 года на ячейку при раз в минуту |
| Журнал: курсор | 30 | при каждом событии | раз в 10 минут — около 2 лет |
| Журнал: 8 слотов | 31-38 | по событию, по кругу | износ делится на восемь |
| XOR-сумма | 3 или рядом с блоком | вместе со своим блоком | как самый частый блок |
- Конфиг с версией и контрольной суммой: версия ловит смену раскладки, сумма — мусор; сумма пишется последней
- XOR-сумма — для настроек, где дефолт безболезнен; CRC8 — там, где старт на дефолтах уже потеря
- Кольцо из 8 пар значение-тег делит износ на 8 и обходится без курсора: свежая ячейка — с максимальным тегом
- Тег в один байт переполняется через 250 записей кольца; для частых счётчиков тег делают двухбайтовым
- Журнал последних событий: курсор плюс 8 слотов; при частых событиях узким местом становится сам курсор
- Карту памяти рисуйте в шапке скетча до кода: наложение двух заготовок на одни адреса ищется вечерами
- 1. Конфиг читается, XOR-сумма сошлась, а поля не те, что вы ожидали. Чего не хватило раскладке?
- 2. Счётчик пишется по кольцу из 8 ячеек раз в минуту. Сколько проживёт одна ячейка блока?
- 3. При старте XOR-сумма конфига не сошлась. Что делает cfgLoad из урока?
- 4. Куда складывать показания датчика раз в секунду?
- 5. Курсор журнала лежит в одной ячейке и обновляется при каждом событии раз в минуту. Что произойдёт?
Эти три заготовки я не менял по сути года четыре: меняются адреса, поля конфига и коды событий — каркас стоит на месте. Конфиг с версией живёт в каждом приборе с настройками, кольцо — в каждом счётчике, журнал — везде, где прибор работает без меня и должен рассказать, что с ним было.
Честное ограничение: EEPROM не резиновая, и на Uno с её килобайтом три заготовки занимают считанные десятки адресов, но не байты потоков. Как только в проекте появляются показания чаще раза в минуту или журнал на тысячи строк, эти данные уезжают на карту или в другой носитель — паттерны остаются только для того, что обязано жить годами. Собрать всё вместе — настройки, счётчик наработки, журнал и аккуратный заводской сброс в одном устройстве с меню — тема итогового проекта раздела: там эти три заготовки сложатся в прибор с планом износа на годы, и вы увидите, как они делят между собой одну карту памяти без единого конфликта.
Частые вопросы по теме урока
- Конфиг читается, XOR-сумма сошлась, а поля не те, что вы ожидали. Чего не хватило раскладке?
- Сумма проверяет целостность, но не смысл данных. Версия раскладки отличает конфиг старой прошивки от новой и уводит прибор на дефолты вместо чтения чужих полей. Версии раскладки: поля сдвинулись после обновления прошивки
- Счётчик пишется по кольцу из 8 ячеек раз в минуту. Сколько проживёт одна ячейка блока?
- Каждая ячейка принимает каждую восьмую запись: 100000 записей умножаем на 8 минут — 800000 минут, около 1,5 года. Без кольца та же частота убила бы ячейку за 69 дней. Около 1,5 года
- При старте XOR-сумма конфига не сошлась. Что делает cfgLoad из урока?
- Несошедшаяся сумма — признак мусора или разорванной записи. Дефолты с немедленной перезаписью возвращают прибор в предсказуемое состояние. Возвращает false, прибор стартует на дефолтах и перезаписывает конфиг
- Куда складывать показания датчика раз в секунду?
- Запись раз в секунду съедает ячейку за 27,8 часа, а кольцо из восьми растянет это до пары недель. Для потоков EEPROM не предназначена. Ни в EEPROM: потоковые данные — на SD или другой носитель
- Курсор журнала лежит в одной ячейке и обновляется при каждом событии раз в минуту. Что произойдёт?
- 1440 записей в сутки — 100000 циклов за 69 дней. Курсор при частых событиях сам нуждается в кольце либо от курсора отказываются в пользу поиска по тегам. Ячейка курсора выработает ресурс первой — примерно через 69 дней