Инкубатор на Uno отработал сезон, и в конце владелец спросил: что было в ночь на среду — температура просела или датчик отошёл? Ответа не было: скетч жил настоящим моментом, история умирала вместе с питанием. Ставить SD-модуль ради одного сезона показалось излишним, и журнал я переехал в саму EEPROM — килобайт, который уже стоит на плате и переживает отключения.
Кольцевую механику раздел уже собирал: в уроке про кольцевой буфер был журнал из 32 слотов по 4 байта с кодом события и трёхбайтовым временем. Здесь растим запись до полноценной строки лога — 32 байта с меткой millis и счётчиком перезагрузок, восстанавливаем хронологию между запусками и делаем печать журнала в Serial по команде. Порядок записей на границе сбоя — отдельный сюжет, его разбирает урок про маркер порядка; мне же нужен журнал как чёрный ящик, отвечающий на любой вопрос последними событиями.
Арифметика объёма — сразу, чтобы не разочаровываться потом: килобайт Uno — это ровно 32 события по 32 байта, весь объём без остатка. На практике я отдаю журналу половину: 16 событий по 32 байта, остальное забирают настройки и калибровки. Шестнадцати последних событий хватает, чтобы ответить на главный вопрос чёрного ящика: что происходило перед сбоем.
Слот 32 байта: код, перезагрузка, метка
Раскладка слота: байт 0 — код события (1 — включение платы, 2 — выход за пределы датчика, 3 — команда оператора, 4 — штатная отметка), байт 1 — счётчик перезагрузок, байты 2–5 — метка millis() четырьмя байтами, байты 6–7 — значение датчика int, остальные 24 — резерв. Резерв выглядит расточительством ровно до первой попытки дописать поле: он съедает правку раскладки без миграции, и журнал продолжает читаться старым кодом.
Счётчик перезагрузок — второй ключ сортировки. Метка millis живёт только внутри одного запуска: после ребута она отсчитывает заново, и без парного ключа журнал слился бы в кашу. Пара (номер перезагрузки, метка) восстанавливает хронологию однозначно: события перезагрузки 7 старше любых событий перезагрузки 8, внутри одной — порядок по метке. Сам счётчик лежит в отдельной ячейке и прибавляет единицу при каждом старте: по одной записи за включение, сто включений в день исчерпают его ячейку за 2,7 года — потом её просто переносят на соседний адрес.
Метка считается байтами честно. int не годится: уже 33 секунды — потолок, двух байтов хватает на 65,5 секунды. Четыре байта millis дают 49,7 суток — ровно до переполнения самого millis, как считали в уроке про millis вместо delay. Нюанс для ресурса: младший байт метки меняется четыре раза в секунду, средний — раз в 65,5 секунды, старшие — в часы и сутки, поэтому update пропускает большую часть слота почти всегда, и реальный износ ячеек заметно ниже наивной прикидки.
#include <EEPROM.h> // Журнал событий: 16 слотов по 32 байта (на Uno их 32 - весь килобайт).// Слот: kod@0, perezagr@1, metka@2..5, znach@6..7, резерв 8..31.// Кнопка D2 - событие, команда 'p' в мониторе - печать журнала.const int SLOTOV = 16;const int DLYA_SLOTA = 32;int head = 0; // в живом скетче ищется сканом по коду 255int perezagr = 0; int svobodnyjSlot() { for (int i = 0; i < SLOTOV; i++) { if (EEPROM.read(i * DLYA_SLOTA) == 255) { return i; } } return 0; // журнал полон - пишем поверх самого старого} void zapisSobytiya(byte kod, int znach) { int adr = svobodnyjSlot() * DLYA_SLOTA; unsigned long metka = millis(); EEPROM.update(adr + 0, kod); EEPROM.update(adr + 1, perezagr); EEPROM.update(adr + 2, (metka >> 24) & 255); EEPROM.update(adr + 3, (metka >> 16) & 255); EEPROM.update(adr + 4, (metka >> 8) & 255); EEPROM.update(adr + 5, metka & 255); EEPROM.update(adr + 6, highByte(znach)); EEPROM.update(adr + 7, lowByte(znach)); head = (adr / DLYA_SLOTA + 1) % SLOTOV;} void pechatZhurnala() { Serial.println("--- zhurnal ---"); for (int i = 0; i < SLOTOV; i++) { int adr = ((head + i) % SLOTOV) * DLYA_SLOTA; byte kod = EEPROM.read(adr); if (kod == 255) { continue; } // пустой слот unsigned long metka = ((unsigned long)EEPROM.read(adr + 2) << 24) + ((unsigned long)EEPROM.read(adr + 3) << 16) + ((unsigned long)EEPROM.read(adr + 4) << 8) + EEPROM.read(adr + 5); Serial.print("kod="); Serial.print(kod); Serial.print(" perezagr="); Serial.print(EEPROM.read(adr + 1)); Serial.print(" metka="); Serial.print(metka); Serial.print(" znach="); Serial.println(EEPROM.read(adr + 6) * 256 + EEPROM.read(adr + 7)); }} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); perezagr = EEPROM.read(1000); if (perezagr == 255) { perezagr = 0; } perezagr++; EEPROM.update(1000, perezagr); Serial.print("start, perezagruzka nomer "); Serial.println(perezagr); zapisSobytiya(1, 0); // событие "включение платы"} void loop() { if (digitalRead(2) == LOW) { zapisSobytiya(2, analogRead(A0)); delay(400); // антидребезг для примера } if (Serial.available() > 0) { char c = Serial.read(); if (c == 'p') { pechatZhurnala(); } }}zapisSobytiya делает восемь update-ов по адресам слота — и это не расточительство, а экономия: update сравнивает байт перед записью и пропускает неизменившийся. При статичной метке реально перезаписываются код события, счётчик перезагрузок, младший байт метки и значение — четыре ячейки из восьми занятых, старшие байты метки молчат днями. Сдвиг head после записи держим в RAM и восстанавливаем сканом по коду 255: отдельная ячейка-указатель приняла бы по записи на каждое событие и стала бы концентратором износа — та же история, что с маркером в wear leveling.
Счётчик перезагрузок подробно разбирался в отдельном уроке — здесь он работает журнальным ключом. Для чёрного ящика важно его сочетание с кодами событий: если последняя запись — включение платы, а перед ней не было штатной отметки, питание пропало само, и смотреть надо метки перед этим. Свежая EEPROM читается единицами — на этом построен и скан пустых слотов, и распознавание первого пуска, как разобрано в уроке про первый запуск.
Время записи тоже держите в голове: восемь update-ов по 3,3 мс — до 26 мс, если все байты слота меняются разом, как бывает при событии «включение» на свежем слоте. В обычном loop это незаметно, поэтому журнал и пишите оттуда: обработчик прерывания с записью в EEPROM замораживает остальную логику на эти миллисекунды, а если само прерывание и генерирует события, оно легко получает ту же запись в рекурсию. Схема на все случаи: attachInterrupt ставит флаг, loop видит флаг — пишет событие и сбрасывает его; журнал растёт, прерывания остаются короткими.
Печать журнала и чтение хронологии
Печать по команде — это Serial-протокол в три строки: прочитали символ, сравнили с 'p', вызвали pechatZhurnala(). Печатаем от старого к свежему: начинаем от head и идём по кругу — тогда первые строки дампа старше последних. Пустые слоты пропускаем по коду 255, занятые печатаем одной строкой: код, номер перезагрузки, метка, значение. Для чтения команд из монитора пригодится урок про available и read — здесь я беру из него только посимвольное чтение без блокировок.
Сортировка по паре (перезагрузка, метка) при печати делается на глаз: записи с меньшим номером старше, внутри номера порядок по метке. Хуже обстоят дела с событием, попавшим в момент сбоя: если питание ушло посреди записи слота, слот остаётся с частью байтов и код 255 в середине может его замаскировать. Честное лечение — контрольная сумма в резервных байтах слота: не сошлась — строка печатается с пометкой «битый слот» и в хронологию не встаёт.
Инкубаторная история закончилась хорошо: дамп показал код 2 с меткой 3 ч 12 мин при перезагрузке 41, а за ним через полминуты — код 1 новой перезагрузки. Ночная просадка нашлась в розетке: компрессор и нагреватель висели на одном удлинителе с плохим контактом. Именно такие вопросы — «что было перед сбоем» — журнал закрывает за минуту, а без него сезон диагностики превращается в пересказ по памяти.
В тренажёре механика кольца видна целиком: каждое четвёртое событие печатает дамп — сначала слоты в порядке старости, потом свежие, а после восьмой записи кольцо идёт по второму кругу поверх самых старых. Тумблер изображает перезагрузку: счётчик растёт и попадает во все новые записи, поэтому хронология между запусками не разваливается. Память перед стартом заполняется единицами, как на свежей плате, — движок симулятора стартует с нулевых ячеек, и без этой строки «пустые слоты» не отличались бы от занятых.
Честная оговорка: в симуляторе восемь слотов вместо шестнадцати — чтобы дамп влезал в экран, а счётчик перезагрузок и метки считает модель. EEPROM движка — массив ячеек: и раскладка слота, и кольцевой сдвиг head в тренажёре те же, что уедут на плату. Ограничение объёма тоже честное: 16 событий по 32 байта — это половина килобайта, и под журнал его стоит отдавать осознанно, а не по остаточному принципу.
Сколько событий брать
Выбор размера — баланс между длиной истории и местом для остального. 32 слота по 32 байта забирают весь килобайт: история полная, но настройкам, калибровкам и счётчикам места нет — нормально для чисто логирующей платы. 16 × 32 — мой рабочий компромисс: события за сутки-другие и свободная половина карты. 8 × 32 — вариант, когда журнал нужен как страховка «а что было вчера». Обратный полюс — слоты по 8 байтов — даёт 128 событий, но без метки и значения записи превращаются в голые коды: годится для счётчиков, не годится для диагностики.
Сколько бы слотов вы ни выбрали, рисуйте карту памяти на 1 КБ до кода: журнал, блок настроек, счётчики, калибровки — каждому свой диапазон адресов, чтобы кольцо не наступило на конфиг при переполнении. Длину слота держите в одной константе во всех циклах: дописали поле — константа сдвинула адреса сама, а не вручную по восьми функциям.
Моя инкубаторная карта делила килобайт так: журнал — адреса 0–511, блок настроек с magic и суммой — 512–527, резерв до 999 и счётчик перезагрузок в самой ячейке 1000, на краю карты, чтобы ни рост слота, ни расширение журнала не добрались до него. Резерв выглядит шикарно первые полгода, а потом в него влезают второй счётчик, калибровка нуля датчика и флаг техобслуживания — и карта, нарисованная на бумаге за пятнадцать минут до кода, отвечает на вопрос «кто кого затёр» без вскрытия платы.
| Вариант | Что даёт | Цена |
|---|---|---|
| 32 слота × 32 байта | Полная история из 32 событий | Весь килобайт: настройкам и калибровкам места нет |
| 16 × 32 | События за сутки-другие и свободная половина карты | Мой рабочий компромисс для диагностики |
| 8 × 32 | Страховка «что было вчера» в 256 байтах | Короткая история при частых событиях |
| 128 слотов × 8 байтов | Долгая история из голых кодов | Без метки и значения сбои не диагностируются |
- 1. Сколько событий по 32 байта вмещает килобайт EEPROM на Uno?
- 2. Зачем в слоте журнала счётчик перезагрузок?
- 3. Почему восемь update-ов на слот не расходят ресурс впустую?
- 4. Последняя запись журнала — код 1 «включение», штатной отметки перед ней не было. Что это значит?
- 5. Что случится при добавлении unsigned long в struct журнала без пересчёта раскладки?
- Килобайт Uno — это 32 события по 32 байта целиком; половина журнала (16 событий) — рабочий компромисс
- Слот: код события, счётчик перезагрузок, метка millis на четырёх байтах, значение и резерв под рост
- Пара (перезагрузка, метка) восстанавливает хронологию между запусками — millis после ребута считает заново
- Запись через update: реально перезаписываются только код и младшие байты, старшие метки молчат днями
- Последняя запись без штатной отметки — маркер сбоя питания; сумма в резервных байтах ловит недописанный слот
Частые вопросы по теме урока
- Сколько событий по 32 байта вмещает килобайт EEPROM на Uno?
- 1024 / 32 = 32 события без остатка. На практике половину отдают журналу, а остальное делят настройки, калибровки и счётчики. Ровно 32 — весь килобайт
- Зачем в слоте журнала счётчик перезагрузок?
- Метка живёт только внутри одного запуска. Пара (номер перезагрузки, метка) восстанавливает хронологию между запусками однозначно. Он снижает износ ячеек
- Почему восемь update-ов на слот не расходят ресурс впустую?
- Старшие байты метки меняются в часы и сутки, update сравнивает и пропускает их. Реальный износ слота заметно ниже восьми записей на событие. Неизменившиеся байты update пропускает: реально перезаписываются код и младшие байты метки
- Последняя запись журнала — код 1 «включение», штатной отметки перед ней не было. Что это значит?
- Штатное выключение оставляет свою отметку. Если её нет, а журнал открывается включением — это маркер внезапного сбоя питания, и виновника ищут в событиях перед ним. Питание пропало само: анализируйте метки перед этим
- Что случится при добавлении unsigned long в struct журнала без пересчёта раскладки?
- long занимает четыре байта, sizeof слота растёт, адреса за журналом сдвигаются. Пересчитывайте карту памяти sizeof-ом при каждой правке состава полей. Слот вырастет на 4 байта, соседние блоки на карте уедут и затрутся