Теплица на участке третий раз за месяц ловила один и тот же вечер: насос включался, порог срабатывал, всё по плану — а утром хозяин звонил с вопросом, что это было в полночь. Плате было что рассказать: смена режима, превышение порога, команда с кнопки — но рассказывать она умеет только в монитор, а монитор к ней приезжает раз в неделю. Потоковый журнал в файл мы собирали в уроке про лог с кнопкой — он живёт, пока жив провод и ноутбук. Для узла в ящике нужен бортовой самописец: последние минуты жизни на борту, выгрузка по требованию.
Решение из авиации — чёрный ящик: кольцевой журнал на N ячеек в оперативке. Каждое событие — метка millis, код, значение — пишется в следующую ячейку; круг замкнулся — пишем поверх старейшей. Журнал всегда полон последних минут, размер известен заранее, запись стоит микросекунды и не тормозит боевой цикл. Одна улика перед зависанием — это паттерн из урока про сторож и последний лог; здесь улик шестнадцать, и они складываются в хронологию, по которой виден сценарий, а не одна строка-обрывок.
Разберём структуру записи, указатель головы и печать дампа в хронологическом порядке, посчитаем, почему поток событий в EEPROM не заливаем, и соберём скетч, где ящик живёт рядом с боевым кодом, не мешая ему. Заодно — грабля порядка печати, из-за которой журнал читается задом наперёд, и вопрос переполнения millis для узлов, которые живут месяцами.
Шестнадцать ячеек и указатель головы
Запись — три параллельных массива: unsigned long t[16] под метки, byte kod[16] под коды событий, int zn[16] под значения. Указатель head показывает, куда писать следующим: head = (head + 1) % 16, и когда круг замкнётся, свежая запись ляжет поверх старейшей — то самое кольцо, которое аппаратный UART крутит для принятых байтов в буфере из урока про буфер UART. Ноль копирований, ноль сдвигов: запись — всегда одна ячейка, микросекунды на любой частоте.
Печать дампа идёт от старейшей к свежей: начинаем с ячейки head + 1 и крутим по модулю 16 — тогда хронология читается как журнал, а не как шифр. Коды событий — байты: 0x01 кнопка, 0x02 порог пробит, 0x03 возврат в норму, 0x04 команда с порта; при дампе код печатается в HEX рядом с текстом — шестнадцатеричный формат из урока про BIN, HEX и DEC. Метка времени — сырые миллисекунды: 86400000 — это сутки, и читать их столбиком привыкаешь за вечер; перевод в часы с минутами делает таблица на компьютере, а не плата.
Значение — int: уровень с делителя, счётчик, длительность. Три массива на 16 записей — около ста байтов оперативки из двух тысяч: на Uno бюджет уходит незаметно. Захотите 64 записи — умножайте на четыре и проверяйте свободную память: журнал обязан помещаться рядом с боевым кодом, а не вместо него.
Каждая запись дублируется печатью в порт, если монитор подключён: смена состояния печатается на ходу, а кольцо хранит её же для потом. Двойная запись стоит одну строку кода в pishi(): печать сверху, массив снизу. Без этого журнал превращается в тайну за семью печатями: плата молчит неделями, и единственная возможность узнать, что было, — приехать с монитором и сказать dump.
// Чёрный ящик: кольцевой журнал в RAM и дамп по команде.// Коды: 0x01 knopka, 0x02 porog probit, 0x03 vozvrat v normu, 0x04 komanda.#include <Arduino.h> const byte N = 16;unsigned long t[N];byte kod[N];int zn[N];byte head = 0; // куда писать следующимint porog = 800;bool vyshe = false; // состояние «выше порога» для гистерезиса void pishi(byte k, int z) { t[head] = millis(); kod[head] = k; zn[head] = z; head = (head + 1) % N; digitalWrite(12, HIGH); // огонёк записи delay(15); digitalWrite(12, LOW);} void dump() { Serial.println("=== DUMP ot starykh k svezhim ==="); for (byte i = 0; i < N; i++) { byte idx = (head + 1 + i) % N; // от старейшей к свежей if (t[idx] == 0 && kod[idx] == 0) continue; // пустые до первой записи Serial.print("t="); Serial.print(t[idx]); Serial.print(" kod=0x"); if (kod[idx] < 16) Serial.print("0"); Serial.print(kod[idx], HEX); Serial.print(" zn="); Serial.println(zn[idx]); } Serial.println("=== KONEC ===");} void setup() { Serial.begin(9600); pinMode(12, OUTPUT); pinMode(2, INPUT_PULLUP); Serial.println("chernyi yashchik: dump; kod: 01 knopka, 02 porog, 03 norma, 04 komanda");} void loop() { if (Serial.available() > 0) { String line = Serial.readStringUntil(10); line = line.trim(); if (line == "dump") { pishi(4, 0); // вскрытие ящика — тоже событие dump(); } } if (digitalRead(2) == LOW) { pishi(1, analogRead(A0)); delay(250); // антидребезг в простом скетче } int uroven = analogRead(A0); if (!vyshe && uroven > porog) { vyshe = true; pishi(2, uroven); } if (vyshe && uroven < porog - 50) { // гистерезис 50 вниз vyshe = false; pishi(3, uroven); }}Порог с гистерезисом — обязательная пара: пробитие ловится один раз при пересечении вверх, возврат — при спуске на 50 ниже порога. Без гистерезиса значение, пляшущее около порога, завалит журнал сотнями однотипных записей и вытеснит остальное — кольцо честно забудет старое, и улики не будет. То же правило спама мы проводили в потоковом журнале: пишем смену состояния, а не каждое измерение.
Дамп по команде dump сам попадает в журнал кодом 0x04 — и после выгрузки видно, сколько раз ящик вскрывали. Метки с millis() здесь хватает: разность двух unsigned long корректна, пока событие не старше 49,7 суток, — та же механика вычитания, что в планировщике на миллис. Для узла, живущего месяцами без перезагрузок, метьте вместо millis счётчик проходов loop: переполнение меток тоже событие, и журнал его честно поймает.
Метки читаются столбиком, но смысл у них человеческий: 60000 — минута, 3600000 — час, 86400000 — сутки. Перед разбором я делю их на 60000 с округлением до минут в таблице на компьютере — и хронология событий становится читаемой на глаз, без калькулятора и пересчёта в голове. Колонку с сырыми миллисекундами при этом не выбрасывайте: точность до единиц миллисекунд отличает «порог пробит до нажатия кнопки» от «после».
Почему поток не заливаем в EEPROM
Первый вопрос к кольцу — почему RAM, если есть энергонезависимая EEPROM. Арифметика ресурса: ячейка EEPROM держит около 100 000 перезаписей, мы считали её в уроке про износ ячеек. Событие каждые 100 мс — это 864 000 записей в сутки; даже распределённый по кругу износ тянется дни, не месяцы. Поток — в RAM, и это не компромисс, а осознанный выбор: оперативка не имеет ресурса записи и стоит цикл в пару тактов.
EEPROM в схеме всё равно занимает своё место — двумя редкими ходами. Первый: вехи — смены режима, старты, сбои — с частотой раз в часы и реже живут в энергонезависимом журнале с тем же кольцевым принципом, его разбор в уроке про кольцевой журнал EEPROM. Второй: последнее событие перед падением — байт-флаг поверх паттерна из урока про сторож: RAM умирает вместе со сбросом, а флаг остаётся. Два этажа вместе покрывают и разбор полёта на живой плате, и разбор после смерти.
Отдельный трюк для упорных: секция .noinit — массив с атрибутом секции, который компилятор не чистит при старте, и после сброса в нём лежит то, что было до. На AVR с optiboot это работает, но требует понимания инициализации переменных — я такой ящик собирал один раз на логгере погоды и в бой не ставил: флаг в EEPROM плюс RAM-кольцо закрывают девять сценариев из десяти, а .noinit — десятый, редкий и капризный.
Если журнал всё-таки едет в EEPROM, два правила продлевают его жизнь. Первое: update вместо write — ячейка не перепрошивается, если байт не изменился, и пороги, меняющиеся раз в сутки, перестают жечь ресурс зря. Второе: шаг записи не мельче пары минут и только по смене состояния — тогда круг из 16 записей тратит за год десятки тысяч циклов, а не миллионы. Третий ход — счётчик израсходованных циклов в отдельной ячейке: он честно скажет, когда журнал пора переносить на новый адрес.
| Хранилище | Переживает сброс | Ресурс | Когда беру |
|---|---|---|---|
| RAM-кольцо 16-64 записи | нет: обнуляется со сбросом | без износа, ~100 байтов ОЗУ | последние минуты живой платы, выгрузка по требованию |
| Байт-флаг в EEPROM | да | 100 000 циклов на ячейку | последний шаг перед падением — поверх сторожа |
| Кольцевой журнал в EEPROM | да | износ распределяем по кругу | вехи с частотой реже раза в час |
| Потоковый журнал в Serial | нет: не хранится | без износа | стол с ноутбуком, разбор на месте |
Эмуляция в симуляторе честная до предела: ребут здесь — команда crash, которая очищает журнал и печатает заставку, как это делает настоящий сброс с оперативкой. На живой плате глобальные массивы после сброса обнуляются точно так же — ящик начинается с чистого листа, и за ту часть улик, что обязана пережить смерть, отвечает байт-флаг в EEPROM из урока про сторож.
- 1. Кольцо на 16 ячеек, голова стоит на ячейке 5. Куда ляжет следующая запись?
- 2. Почему поток событий в EEPROM не заливаем?
- 3. От какой ячейки начинать печать дампа?
- 4. Порог 800 без гистерезиса, уровень пляшет между 790 и 810. Что с журналом?
- 5. Что переживает watchdog-перезагрузку?
- Кольцо на 16-64 записей в RAM: метка millis, код события, значение — запись за одну ячейку, поверх старейшей
- Дамп — от head + 1 по модулю N: хронология от старых к свежим, пустые ячейки до первой записи пропускаем
- Порог — с гистерезисом: пишем смену состояния, а не каждое измерение
- Поток — в RAM: 100 000 циклов EEPROM поток убивает за дни; в EEPROM — вехи и байт-флаг перед падением
- Таблица кодов и версия прошивки в шапке скетча: улика без расшифровки — не улика
Частые вопросы по теме урока
- Кольцо на 16 ячеек, голова стоит на ячейке 5. Куда ляжет следующая запись?
- Голова показывает, куда писать: запись ляжет в пятую ячейку, после чего указатель шагнёт дальше. Когда круг замкнётся, свежая запись ляжет поверх старейшей. В ячейку 5, затем голова сдвинется на 6
- Почему поток событий в EEPROM не заливаем?
- Событие каждые 100 мс — сотни тысяч записей в сутки на круг. Поток — в RAM, в EEPROM — вехи и флаги с частотой раз в часы и реже. Ресурс около 100 000 циклов на ячейку: поток убьёт память за дни
- От какой ячейки начинать печать дампа?
- Обход от head + 1 по кольцу выдаёт хронологию; печать по индексам 0..N-1 читает журнал задом наперёд. С head + 1 по модулю N — от старейшей к свежей
- Порог 800 без гистерезиса, уровень пляшет между 790 и 810. Что с журналом?
- Значение около порога пишет событие на каждом пересечении; гистерезис в 50 вниз ловит смену состояния, а не каждое измерение. Сотни однотипных записей вытеснят остальное
- Что переживает watchdog-перезагрузку?
- Сброс обнуляет глобальные массивы; пережить его готов байт-флаг в EEPROM — паттерн из урока про сторож. Секция .noinit — отдельная история для упорных. Только байт-флаг в EEPROM