Watchdog в Arduino: wdt_enable, таймауты и ловля зависаний
Метеостанция на балконе прожила три дня, а на четвёртый перестала отвечать: экран застыл, данные не пишутся, кнопки мертвы. Дома скетч работал ночами без единого сбоя — а на балконе завис. Разбирать макет каждый раз жалко, перезагружать вручную можно, только если живёшь рядом с розеткой и не спишь. Для таких случаев в ATmega328P зашит аппаратный сторожевой таймер: он считает время по собственному генератору, и если программа в течение таймаута не подала сигнал «я живая», чип делает себе полный сброс — как будто кто-то нажал кнопку RESET.
Ниже — wdt_enable() с восемью секундами WDTO_8S, wdt_reset() первой строкой неблокирующего loop(), таблица всех таймаутов от 15 мс до 8 секунд и разбор ловушки, из-за которой плата после сторожевого сброса перезагружается бесконечно: виноват не скетч, а старый загрузчик. Живой пример надёжного цикла — итоговый термостат с watchdog и сном, а скетчи из статьи крутятся в симуляторе.
Почему плата зависает: три честные причины
Первая — блокирующее ожидание: длинный delay() в loop() зависанием формально не слывёт, но всё это время плата глуха к кнопкам и датчикам, а вот while(!Serial) или чтение шины без таймаута зависают по-настоящему — вызов не возвращается никогда. Вторая — внешняя железка: DS18B20 после длинного провода молчит, I2C клинит от помехи, и «подожди ответа» превращается в вечное ожидание. Третья — память: запись за край массива портит соседние переменные, и программа уходит бегать по чужим адресам. Сторож не разбирает, что именно случилось: он отсчитывает свой интервал по отдельному генератору на 128 кГц, и если wdt_reset() не пришёл вовремя — перезапускает чип. Этот генератор тикает даже когда основной код замер, поэтому delay() его не кормит: счёт идёт и во время «паузы».
wdt_enable и wdt_reset: скетч на millis-цикле
Схема из трёх строк: wdt_disable() в начале setup() — пока идёт медленная инициализация, Serial, дисплей, сеть; wdt_enable(WDTO_8S) в конце setup(); wdt_reset() первой строкой loop(). Дальше полезная работа живёт в неблокирующем millis-шаблоне: пока каждый проход loop() укладывается в восемь секунд, плата не перезагрузится никогда. Долгое, но легальное ожидание — конвертация датчика, пуск двигателя — сопровождайте собственным таймаутом и кормлением сторожа внутри. Выбирают интервал по простому правилу: дважды худший проход loop(). Если самый долгий штатный цикл занимает секунду — ставлю WDTO_2S и больше о нём не думаю.
#include <avr/wdt.h> unsigned long lastJob = 0;const unsigned long JOB_PERIOD = 1000; // работа раз в секунду void setup() { wdt_disable(); // на время медленной инициализации сторож спит Serial.begin(9600); pinMode(13, OUTPUT); wdt_enable(WDTO_8S); // восемь секунд на проход loop() — с запасом} void loop() { wdt_reset(); // первая строка: «я живой» if (millis() - lastJob >= JOB_PERIOD) { lastJob += JOB_PERIOD; // += держит ровную сетку меток, как и у millis() // полезная работа: датчики, логика, дисплей — без единого delay() }}Таблица таймаутов: от WDTO_15MS до WDTO_8S
Все константы живут в заголовке <avr/wdt.h>. На ATmega328P интервал выбирается только из фиксированного списка — произвольных «вот 5 секунд» не существует. Короткие значения от 15 до 120 мс чаще применяют как будильник из глубокого сна, длинные — как страховку рабочего цикла.
| Константа | Таймаут | На что годится |
|---|---|---|
| WDTO_15MS | 15 мс | будильник из сна, сверхкороткие циклы |
| WDTO_30MS | 30 мс | плотные циклы с активным опросом пинов |
| WDTO_60MS | 60 мс | опрос без длинных библиотечных вызовов |
| WDTO_120MS | 120 мс | циклы с мелкими задержками на драйверы |
| WDTO_250MS | 250 мс | опрос I2C-датчиков с живым кодом рядом |
| WDTO_500MS | 500 мс | типовой неблокирующий цикл новичка |
| WDTO_1S | 1 с | проход loop() с запасом на Serial и дисплей |
| WDTO_2S | 2 с | мой выбор по умолчанию для большинства проектов |
| WDTO_4S | 4 с | циклы с редкой, но долгой работой: конвертация, запись |
| WDTO_8S | 8 с | потолок ATmega328P, страховка «пока точно не завис» |
Ловушка загрузчика: сброс, который не кончается
На бумаге всё работает, а на столе плата после первого сторожевого сброса перезагружается каждые две секунды вечно. Это ловушка старых загрузчиков: Optiboot, стоящий на Uno с 2010 года, после сторожевого сброса сам гасит флаг WDRF и передаёт управление скетчу. А вот загрузчик клонов Nano («ATmega328P (old bootloader)») и стоковый у Mega 2560 сторож не трогают: чип уходит в бутлоадер со всё ещё тикающим таймером, тот через пару секунд снова сбрасывает плату — и так по кругу. Кнопка RESET обычно вырывает плату из цикла, надёжное лечение — перешить загрузчик на Optiboot: Tools → Burn Bootloader с программатором Arduino as ISP. Поэтому wdt_enable() включают уже на проверенном железе: сначала сторож на тестовом скетче, потом на боевом.
#include <avr/wdt.h> void setup() { Serial.begin(9600); if (bitRead(MCUSR, WDRF)) { // флаг сторожевого сброса уцелел Serial.println("Проснулся по watchdog: прошлый проход не вернулся"); } wdt_enable(WDTO_8S);} // Долгое, но легальное ожидание: конвертация DS18B20 занимает 750 мс.// Ждём с собственным таймаутом и кормим сторожа — а не полагаемся на везениеbool waitSensorReady() { unsigned long t0 = millis(); while (!sensorReady()) { // sensorReady(): ваш опрос флага готовности if (millis() - t0 > 2000) return false; // свой таймаут раньше сторожевого wdt_reset(); } return true;}ATmega328P — не единственный боец: на ESP32 и ESP8266 заголовок <avr/wdt.h> не работает, там свой набор сторожей. ESP8266 из коробки кормит программный watchdog сама между вызовами loop(), поэтому вечный цикл внутри одного прохода всё равно уйдёт в перезагрузку. ESP32 держит задачный сторож esp_task_wdt: задачу подключают вызовом esp_task_wdt_add(), кормят esp_task_wdt_reset(), забыли — задача получает сброс. Логика на всех чипах та же, а вот считать сбои и поднимать состояние после них — отдельное ремесло: как хранить счётчик сбоев в EEPROM и будить плату по сторожу, разобрано в уроке про счётчик сбоев после зависания.