Узел мониторинга теплицы в оранжерее за городом отработал месяц и однажды замолчал: питание есть, светодиод на плате горит, данных нет. Ехать полтора часа, чтобы передёрнуть питание, — занятие с сомнительной романтикой. Тогда я впервые собрал скетч со сторожем: аппаратный таймер, который живёт отдельно от loop и умеет одно — если его долго не кормить, он сбрасывает чип, и скетч начинается заново. Зависание, которое раньше означало поездку, стало означать минуту тишины и самостоятельное пробуждение.
Механика простая до обидного. wdt_enable(WDTO_8S) в setup() заводит сторожевой таймер на восемь секунд — это максимальный интервал на ATmega328, дальше только лестница вниз: 15 мс, 30, 60, 120, 250, 500 мс, 1 с, 2 с, 4 с, 8 с. wdt_reset() в loop — кормление: каждый успешный круг обнуляет отсчёт. Пока loop крутится, сторож дремлет. Завис скетч на while или утонул в длинной функции — восемь секунд тишины, и процессор получает жёсткий сброс, как от кнопки, только без вашего пальца. Отдельный бонус: сторож тикает не от кварца, а от собственного медленного генератора около 128 кГц внутри чипа, поэтому отсчитывает свои секунды даже в глубоком сне и при уехавшей тактовой конфигурации — выключить его может только явный wdt_disable() или полный сброс.
Здесь собираю схему целиком: сторож как страховка от зависания, чтение причины сброса из MCUSR, счётчик сбоев в EEPROM, который переживает и ребут, и перепрошивку, и разбор ловушки, когда сам сторож превращает устройство в вечную перезагрузку. Про вторую профессию сторожа — будильник для сна — рассказано в уроке про watchdog и сон; там же полная лестница интервалов и арифметика батарейки.
Флаг причины: почему чип проснулся
Сброс сбросу рознь: включили питание, нажали кнопку, дёрнул сторож. За ответом — регистр MCUSR: бит WDRF ставит сторож, PORF — включение питания, EXTRF — кнопка reset, BORF — провал напряжения. Прочитали регистр в самом начале setup — и скетч сам знает, обычный это старт или следствие зависания. На следствии счётчик в EEPROM инкрементируется, в монитор уходит строка, и через неделю у вас статистика: сколько раз узел тонул и в какие часы — с ней видно, виноват ли код или, скажем, питание в морозы. Четыре бита, четыре сюжета — и все они лежат в одном регистре шириной в байт, который чип заполняет сам в момент сброса.
Подводный камень на Uno: загрузчик Optiboot затирает MCUSR раньше, чем скетч успевает прочесть, — к первой вашей строке в setup() регистр уже нулевой. Классическое лечение — голая функция без тела в секции .init3, которая выполняется сразу после загрузчика и копирует флаги в зеркальную переменную из секции .noinit: компилятор не трогает её при старте, и зеркало доезжает до setup целым. Выглядит экзотично, работает годами — дальше покажу строками. Тот же приём ценен и на голом чипе, прошитом через программатор: там загрузчика нет, MCUSR никто не трогает, и зеркало остаётся единственным источником причины.
Вторая строка в чужих огородах: Nano и Pro Mini со старым загрузчиком ATmegaBOOT сторожевой сброс не переживают вовсе. Чип сбрасывается, загрузчик вместо старта скетча засыпает в ожидании прошивки, и плата выглядит мёртвой до перепрошивки через программатор. Факт малоприятный, но лучше узнать его на столе, чем на вышке. Проверка простая: прошейте скетч со сторожем на пять секунд и посмотрите, оживает ли плата сама. На Uno с Optiboot сторож работает как обещано; для остальных плат — сначала проверка на столе, потом монтаж.
// Сторож-спасатель: сброс после зависания, причина в MCUSR,// счётчик сбоев в EEPROM - переживает ребут и перепрошивку.#include <avr/wdt.h>#include <EEPROM.h> const byte PIN_SVET = 12; // маячок живого loopconst byte PIN_KNOP = 2; // модель зависания: зажата - loop не вернулсяconst byte ADR_SBOEV = 10; // ячейка счётчика в EEPROMvolatile byte prichina = 0; // зеркало MCUSR: 1 - сторож, 2 - питание, 4 - кнопка // Голая функция в .init3 выполняется первой после загрузчика:// снимаем копию флагов, пока их не затёрли, в переменную из .noinit.void zerkaloMcusr(void) __attribute__((naked)) __attribute__((section(".init3")));void zerkaloMcusr(void) { prichina = MCUSR; MCUSR = 0;} void setup() { wdt_disable(); // собака на привязи, пока настраиваемся pinMode(PIN_SVET, OUTPUT); pinMode(PIN_KNOP, INPUT_PULLUP); Serial.begin(9600); if (EEPROM.read(ADR_SBOEV) == 255) { // чистый чип: в ячейке 0xFF EEPROM.update(ADR_SBOEV, 0); } if (prichina & (1 << WDRF)) { // разбудил сторож - был сбой byte sb = EEPROM.read(ADR_SBOEV) + 1; EEPROM.update(ADR_SBOEV, sb); Serial.print(F("SBOR! chip visel, vsego sboev: ")); Serial.println(sb); } else { Serial.println(F("start: pitanie ili knopka")); } wdt_enable(WDTO_8S); // теперь собака на посту: 8 секунд на отзыв} void loop() { wdt_reset(); // кормление: дошёл сюда - значит жив digitalWrite(PIN_SVET, (millis() >> 7) & 1); // маячок loop if (digitalRead(PIN_KNOP) == LOW) { while (true) { } // модель зависания: зажали кнопку - стоим } delay(100); // нормальный круг ~100 мс: запас в 80 раз}Разбор по строкам. Зеркало MCUSR снимается раньше всего остального — это единственный момент, когда флаги ещё живы. Первой строкой setup стоит wdt_disable(): после сторожевого сброса бит WDRF держит собаку включённой с минимальным интервалом 15 мс, и любой долгий init превращается в перезагрузку до конца настройки. Optiboot сам чистит флаги и запускает собаку заново, но wdt_disable первой строкой ничего не стоит и снимает вопрос целиком — привычка из эпохи старых загрузчиков, о которых подробнее в уроке про вечный ребут. Секунда экономии здесь экономит часы: узел, который ребутается быстрее, чем успевает включиться, выглядит мёртвым даже с исправным кодом.
EEPROM.update вместо write: update пишет байт только при изменении, ресурс ячейки — порядка ста тысяч циклов, и счётчик, который растёт на единицу раз в неделю, живёт вечно; дёргать write каждый круг loop — путь к протёртой ячейке за сутки. После записи — wdt_reset(): запись одного байта блокирует процессор на 3,4 мс, а таблица калибровки на пару десятков байт — на десятки миллисекунд, и собака должна услышать, что цикл дошёл до конца, а не умер на середине записи. Механику ячеек и защиту значений разбирали в уроке про счётчик перезагрузок. Отдельный штрих: счётчик я держу однобайтовым, и при переполнении оставляю 255 — максимум, который и так говорит «сбоев больше сотни, хватит считать, ищите причину».
И про бюджет setup: Serial на 9600 бод вывозит строку в двадцать символов за двадцать миллисекунд — на фоне восьми секунд это мелочь. Но если в setup у вас дисплей с инициализацией на полсекунды, три датчика с ожиданием ответа и запись журнала — сложите всё и убедитесь, что сумма в разы меньше восьми секунд. Схему «последняя строка в журнале — место сбоя» я развивал отдельно в уроке про сторожевой журнал. Печать перед входом в рискованный кусок — половина диагноза: строка есть, а следующей нет, значит, чип ушёл именно там.
Ловушка: восемь секунд, которых не хватило
Теперь о том, как сторож портит жизнь. История из моей практики: узел с калибровкой датчика раз в час делает длинный замер — прогрев сенсора десять секунд, усреднение, запись. Сторож я включил, wdt_reset поставил в начале loop. Калибровка живёт внутри одного захода в функцию, loop стоит, кормления нет, восемь секунд истекают — сброс ровно посреди калибровки. Каждый час, как часы. Счётчик сбоев за неделю показал 168, и я сначала не поверил числу, а потом посчитал сам: 24 замера в сутки, каждый длиннее восьми секунд.
Диагноз занял вечер: печать millis() перед каждым шагом — и в журнале видно, что после строки «калибровка начата» чип молчит ровно восемь секунд и стартует заново. Лечение честное: wdt_reset внутри длинного участка, между шагами, — вы сообщаете сторожу: жив, занят, не вмешивайся. Либо переделать структуру: калибровку разбить на шаги по секунде с возвратом в loop между ними. Второй путь дороже по переработке, но принцип тот же, что в неблокирующих скетчах из урока про планировщик — длинные куски не должны держать loop в заложниках. Шаблон для чужого кода, который нельзя перекроить: найдите самую долгую функцию, померьте её millis() до и после, и если она съедает больше половины интервала — кормите собаку внутри неё.
Мнение, за которое меня упрекали в перестраховке: на этапе отладки сторож включают последним, а не первым. Пока скетч болен, сторож молча перезагружает его ровно в тот момент, когда вы ищете причину, — и журнал сбоя умирает вместе с RAM. Сначала наладьте журнал, потом ставьте собаку. И живой узел читайте: счётчик в EEPROM, который ползёт по три-четыре ребута в сутки, — не «работает с самовосстановлением», а симптом. Один мой узел так раскрыл просевшее питание на длинном кабеле: без сторожа он «просто иногда замирал», со счётчиком — дал статистику за три дня и повод померить вольтметром. Без счётчика я бы списал это на «программную нестабильность» и добавил бы delay — классическое лечение, которое делает только хуже. Итоговый узел с журналом, сном и сторожем собран в уроке про термостат.
Симулятор ниже — модель, и оговорка у неё жёсткая: сторожевой таймер движок не исполняет, wdt_enable и wdt_reset в нём нет. Суть модели — память: кнопка D2 изображает зависание, после каждого «сброса» скетч инкрементирует счётчик в EEPROM и печатает его. Маячок на D12 изображает wdt_reset: каждый круг loop дёргает его, и до нажатия кнопки он мигает ровно.
Зажмите кнопку подольше — увидите второй слой: серия сбросов подряд, как узел с постоянным зависанием, тот самый вечный ребут. Отпустите — маячок снова замигает: loop жив, собака накормлена. Разница с платой одна: здесь «перезагрузка» — это сброс переменных, на плате — честный аппаратный сброс с полным прогоном setup, включая инициализацию датчиков.
| Интервал сторожа | Что успевает loop | Где подожмёт |
|---|---|---|
| WDTO_1S | сотни быстрых кругов опроса, запись EEPROM (3,4 мс на байт), короткая печать | SD-карта, длинные калибровки, медленные датчики |
| WDTO_2S | опрос плюс строка SoftwareSerial на 9600 бод — сорок символов за сорок миллисекунд | датчики с ожиданием конверсии в несколько секунд |
| WDTO_8S | даже небыстрый круг с печатью, ретраями и инициализацией дисплея | зависание живёт без присмотра до восьми секунд |
- 1. Что случится с платой, если loop не вызовет wdt_reset() дольше восьми секунд при WDTO_8S?
- 2. Почему в скетче с Optiboot флаги MCUSR копируют в зеркальную переменную секции .init3?
- 3. Где хранить счётчик сбоев, чтобы он пережил и перезагрузку, и перепрошивку скетча?
- 4. Плата перезагружается каждые восемь секунд по кругу. Первое место поиска?
- 5. На какой плате сторожевой сброс может не вернуть скетч к жизни?
- wdt_enable(WDTO_8S) в setup и wdt_reset() в начале каждого круга loop: не покормили восемь секунд — чип сбросился сам
- Причину сброса даёт MCUSR: WDRF — сторож, PORF — питание, EXTRF — кнопка; на Uno флаги снимают зеркалом в .init3, пока загрузчик их не затёр
- Счётчик сбоев в EEPROM переживает и ребут, и перепрошивку: обновляйте через update, после длинных записей — wdt_reset
- Ровный ребут каждые 8 секунд — почерк длинного участка без wdt_reset; на Nano со старым загрузчиком сторож может вообще не вернуть скетч
- Мнение: сначала журнал, потом сторож — иначе собака молча съедает историю каждого сбоя
Частые вопросы по теме урока
- Что случится с платой, если loop не вызовет wdt_reset() дольше восьми секунд при WDTO_8S?
- wdt_reset() обнуляет отсчёт. Восемь секунд без кормления — аппаратный сброс чипа, скетч стартует заново с setup, как после нажатия reset. сторож сбросит чип, скетч начнётся с setup
- Почему в скетче с Optiboot флаги MCUSR копируют в зеркальную переменную секции .init3?
- Optiboot очищает MCUSR, решая, было ли обновление прошивки. Голая функция в .init3 выполняется сразу после загрузчика и успевает снять копию в .noinit-переменную. загрузчик затирает MCUSR до старта скетча
- Где хранить счётчик сбоев, чтобы он пережил и перезагрузку, и перепрошивку скетча?
- RAM обнуляется при сбросе, флеш перезаписывается при прошивке. EEPROM держит значение годами: обновляйте через update и не пишите каждый круг. в EEPROM: она переживает и ребут, и новую прошивку
- Плата перезагружается каждые восемь секунд по кругу. Первое место поиска?
- Ровный период ребутов, равный интервалу сторожа, — почерк длинного участка без кормления: калибровка, SD-карта, медленная печать. забытый wdt_reset в длинном участке loop
- На какой плате сторожевой сброс может не вернуть скетч к жизни?
- Старый ATmegaBOOT после сторожевого сброса не стартует скетч, а ждёт прошивку — плата выглядит мёртвой до перепрошивки через программатор. Nano со старым загрузчиком ATmegaBOOT