[Из песочницы] Watchdog на базе Arduino Nano

Watchdog — это устройство, предназначенное для обнаружения и устранения проблем оборудования. Обычно для этого используется таймер, периодический перезапуск которого предотвращает отправку сигнала на перезагрузку.

5216a20198774a66bbc61882047bfe8b.jpg

Целевой сервер на Gentoo используется мной в основном для экспериментов, однако на нём работает ряд сервисов, которые, по возможности, должны быть доступны без перебоев. К сожалению, последствия некоторых экспериментов приводят к kernel panic, 100% загрузке CPU и другим неприятностям в самый не подходящий момент. Так что идея добавить watchdog давно требовала внимания и наконец материализовалась в данное устройство.
После пристального осмотра того, что было в наличии и оценки доступного времени, оптимальным вариантом стал watchdog собранный на базе Arduino Nano. Примерно также появился и список требований:

  1. Запуск и останов демона, для работы с таймером, штатным средством ОС (OpenRC).
  2. Собственный watchdog на устройстве, в ATmega он есть, нужно использовать.
  3. Лог событий на устройстве для фиксации перезагрузки и срабатывания таймера.
  4. Синхронизация времени устройства с хостом для записи в лог корректного времени.
  5. Получение и отображение статуса устройства и записей его лога.
  6. Очистка лога и сброс устройства в исходное состояние.


Таким образом, «микроскоп» был найден, «гвоздь» обозначен… можно забивать.

Аппаратная часть


Основой устройства стал китайский клон Arduino Nano, выполненный на базе чипа CH340. Свежие Linux ядра (проверял начиная с 3.16) имеют подходящий драйвер, так что устройство легко обнаруживается как USB последовательный порт.

Нежелательная перезагрузка Arduino


При каждом подключение терминала, Arduino перезагружается. Причина в отправке терминалом сигнала DTR (Data Terminal Ready), который вызывает перезагрузку устройства. Таким образом Arduino IDE переводит устройсво в режим для загрузки скетчей.

Существует несколько вариантов решения проблемы, но рабочим оказался только один — необходимо установить электролит 10µF (C1 на схеме ниже) между контактами RST и GND. К сожалению, это также блокирует загрузку скетчей на устройство.

Как итог — схема получилась следующий:

2e39f0ac019f42459a7d599306573f5c.png
Нарисовано с помощью KiCad

Пояснения к схеме
  • R1 — резистор для ограничения тока, рассчитывается согласно спецификации на оптопару PC817: (5V — 1.2V / 0.02A) = 190Ω, ближайшей стандартный номинал 180Ω.
  • U2 — оптопара для гальванической развязки Arduino и PC. Можно обойтись и транзистором, так как земля общая (через USB разъем), но лучше не нужно.
  • JP1 — джампер, в рабочем положении должен быть замкнут. Для загрузки скетча на устройство его необходимо разомкнуть.
  • С1 — конденсатор, блокирует перезагрузку устройства в ответ на сигнал DTR.
  • MB_RST, MB_GND — RESET активен при низком уровне сигнала, соответственно нужно замкнуть RST на землю (GND). В оптопаре используется транзистор, следовательно важно соблюсти полярность.
  • BTN_RST, BTN_GND — кнопка на корпусе, обычно это механический переключатель, следовательно, полярность не важна, но бывают исключения.


Boot-loop (циклическая перезагрузка) при работе с WDT


Микроконтроллеры ATmega имеют встроенный механизм перезагрузки по таймеру WDT (WatchDog Timer). Однако все попытки использовать данную функцию приводили к boot-loop, выйти из которого можно было только отключив питание.

Не долгие поиски выявили, что загрузчики большинства клонов Arduino не поддерживают WDT. К счастью, данная проблема была решена в альтернативном загрузчике Optiboot.

Для того, чтобы прошить загрузчик, необходим программатор умеющий работать по протоколу SPI, также желательно, чтобы Arduino IDE знала это устройство «в лицо». В данном случае идеально подойдёт ещё одна Arduino.

Если взять Arduino UNO, в качестве программатора, и последнию на данный момент версию Arduino IDE v1.6.5, то алгоритм будет следующий:

  1. Добавить содержимое файла boards-1.6.txt из пакета optiboot в конец файла hardware/arduino/avr/boards.txt в директории с Arduino IDE.
  2. В Arduino Uno, загрузить скетч из File → Examples → ArduinoISP.
  3. Соединить программатор с целевой Arduino Nano следующим образом:
    Arduino Uno (программатор) Arduino Nano (ICSP разъём)
    5V → Vcc
    GND → GND
    D11 → MOSI
    D12 → MISO
    D13 → SCK
    D10 → Reset

    Pin1 (MISO) ← D12 Pin2 (Vcc) ← 5V
    Pin3 (SCK) ← D13 Pin4 (MOSI) ← D11
    Pin5 (Reset) ← D10 Pin6 (GND) ← GND


    На фото это выглядит так
    8689194fe6a94d9cbd22b6ec284cc7e3.jpg
  4. В Arduino IDE в меню Tools установить настройки как на скриншоте:
    699f294af6cc48179e44c65543a758c5.png
  5. Выбрать пункт меню Tools → Burn Bootloader и убедиться, что процесс завершился без ошибок.

После этой процедуры, загружать скетчи в Arduino Nano нужно будет выбирая те-же настройки — Board: Optiboot on 32 pin cpus, Processor: ATmega328p, CPU Speed: 16MHz.

Пайка


Далее необходимо всё спаять, так чтобы выглядело одним куском.

eab32a27cc06458faf2589287cb886e4.jpg

Здесь USB штекер понадобился из-за того, что у меня mini-ITX мат.плата только с одним разъем на пару USB2.0, которые нужны на передней панели, а к контактной площадке USB3.0 нечем было подключиться. По возможности такие устройства нужно подключать прямо к мат.плате, чтобы провода наружу не торчали.

Пайка, как правило, проблем не вызывает, но в данном случае используется макетная плата, и тут есть своя специфика.

Как паять дорожки на макетной плате
Сначала необходимо напаять шарики на отверстия (долго греть нельзя, иначе олово вытечет с обратной стороны). Затем напаять перемычки между парами соседних шариков и закончить дорожку спаяв оставшиеся сегменты.

Выглядеть должно примерно так:

847e136d5af547abafdfcb4e40ced271.jpg


Результат:

86b5dc95b0d142288c0a007826abcaa0.jpg

1ce697fa32f04962a5a9324fb365fe48.jpg

Здесь может показаться, что некоторые контакты плохо пропаяны, но это не так, проблема в припое, он содержал 40% флюса. Учитывая расход припоя на макетных платах — это очень много. В итоге флюсом тут заляпано всё, что только можно. На самом деле, это хороший пример как не нужно оставлять изделие после пайки. Флюс необходимо смыть, иначе могут быть проблемы с коррозией соединений. Допишу и пойду отмывать… Вот так лучше:

a91fd11110b348f2b9dcc8d9326d4f37.jpg

Программная часть


Объективно говоря, код этого проекта особого интереса не представляет. Вводные далеко не экстремальные, а архитектура описывается одной фразой: отправить команду — подождать ответ. Для порядка опишу здесь основной функционал и кратко остановлюсь на самых интересных моментах, с моей точки зрения.

Весь код опубликован на GitHub, так-что если вы знакомы с Bash и С/C++ (в контексте Arduino скетчей), чтение на этом месте можно закончить. При наличии интереса, с готовым результатом можно ознакомиться здесь.

Подключение watchdog


При подключении watchdog создается файл устройства, содержащий порядковый номер. Если в системе есть другие ttyUSB устройства (в моём случае — модем), то возникает проблема с нумерацией. Чтобы однозначно идентифицировать устройство, необходимо создать симлинк с уникальным именем. Для этого предназначен udev, который наверняка уже есть в системе.

Для начала нужно визуально найти подключённый watchdog, например, подсмотрев в системный лог файл. Затем, заменив /dev/ttyUSB0 на нужное устройство, написать в терминале:

udevadm info -a -p "$(udevadm info -q path -n /dev/ttyUSB0)"


Пример вывода
looking at device '/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1.4/1-1.4:1.0/ttyUSB0/tty/ttyUSB0':
KERNEL==«ttyUSB0»
SUBSYSTEM==«tty»

looking at parent device '/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1.4/1-1.4:1.0/ttyUSB0':
KERNELS==«ttyUSB0»
SUBSYSTEMS==«usb-serial»
DRIVERS==«ch341-uart»

looking at parent device '/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1.4/1-1.4:1.0':

looking at parent device '/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1.4':
SUBSYSTEMS==«usb»
DRIVERS==«usb»
ATTRS{idVendor}==«1a86»
ATTRS{idProduct}==«7523»
ATTRS{product}==«USB2.0-Serial»

В данном случае, правило будет иметь следующий вид:

ACTION=="add", KERNEL=="ttyUSB[0-9]*", SUBSYSTEM=="tty" SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="ttyrst-watchdog"

Разместить его нужно в отдельном файле в директории /etc/udev/rules.d, например 51-ttyrst-watchdog.rules и скомандовать udev перезагрузить правила:

udevadm control --reload-rules

С этого момента, при подключении watchdog будет создаваться ссылка /dev/ttyrst-watchdog на нужное устройство, которая и будет использоваться далее.

Bash скрипт (ttyrst-watchdog.sh)


Общение с watchdog производится на скорости 9600 бод. Arduino без проблем работает с терминалами на больших скоростях, но команды для работы с текстом (cat, echo и т.п.), получают и отправляют только мусор. Не исключено, что это особенность только моего экземпляра Arduino Nano.

Для основного цикла перезапуска таймера и для функций командной строки, используется один скрипт. Причина в том, что оба компонента используют общий ресурс — файл устройства, и к нему необходимо обеспечить синхронный доступ.

Синхронизация по сути состоит цикла ожидания:

while fuser ${DEVICE} >/dev/null 2>&1; do true; done

и захвата устройства на необходимое время:

cat <${DEVICE}

Очевидно, такая схема подвержена состоянию гонки (race condition). Бороться с этим можно по взрослому (например, организовать очередь сообщений), но в данном случае, достаточно грамотно расставить таймауты, чтобы гарантированно получать результат за приемлемое время. По сути весь скрипт и есть работа с таймаутами.

Демонизация (запуск в фоновом режиме) производится средствами пакета OpenRC. Предполагается, что данный скрипт находится в файле /usr/local/bin/ttyrst-watchdog.sh, а OpenRC скрипт в /etc/init.d/ttyrst-watchdog.

При остановке демона требуется корректная дезактивации watchdog. Для этого в скрипте устанавливается обработчик сигналов, требующих завершение работы:

trap deactivate SIGINT SIGTERM

И тут всплывает проблема — OpenRC не может остановить демон, точнее может, но не часто.

Дело в том, что команда kill, отправляет сигнал скрипту, а программа sleep, которая используется для приостановки работы скрипта, выполняется в другом процессе и сигнал не получает. В результате функция deactivate запускается только после завершения работы sleep, а это слишком долго.

Решение заключается в том, чтобы запустить sleep в фоне и ждать завершения процесса в скрипте:

sleep ${SLEEP_TIME} & wait $!  # переменная $! содержит ID последнего запущенного процесса

Основные константы:

WATCHDOG_ACTIVE — YES или NO, соответственно, отправлять сигнал на перезагрузку при срабатывании таймера или нет.
WATCHDOG_TIMER — время в секундах на которое устанавливается таймер.
SLEEP_TIME — время в секундах через которое необходимо перезапускать таймер. Должно быть много меньше, чем WATCHDOG_TIMER, но не сильно маленькое, что бы не создавать чрезмерную нагрузку на систему и устройство. При текущих таймаутах разумный минимум — примерно 5 секунд.
DEFAULT_LOG_LINES — число последних записей лога устройства, возвращаемых командой log по умолчанию.

Команды скрипта:

start — запуск основного цикла перезапуска таймера. В функцию is_alive можно добавить код дополнительных проверок, например проверить возможность подключения по ssh.
status — вывод статуса устройства.
reset — обнуление EEPROM (данных лога) и перезагрузка устройства для приведения watchdog в исходное состояние.
log <число записей> — вывод заданного числа последних записей лога.

Arduino скетч (ttyrst-watchdog.ino)


Для успешной компиляции скетча потребуется сторонняя библиотека Time, необходимая для синхронизации времени.

Скетч состоит из двух файлов. Это связанно с тем, что Arduino IDE не воспринимает структуры (struct) объявленные в основном файле, их необходимо выносить во внешней файл заголовков. Также для объявления структуры не обязательно ключевое слово typedef, вероятно даже вредно… проверив стандартные варианты, подобрать подходящий синтаксис у меня не получилось. В остальном это более или менее стандартный C++.

Функции wdt_enable и wdt_reset работают со встроенным в микроконтроллер watchdog. После инициализации WDT главное не забывать сбрасывать его в основном цикле и внутри циклов всех длительных операций.

Записи лога пишутся в энергонезависимую память EEPROM, доступный её размер можно указать в logrecord.h, в данном случае это число 1024. Лог выполнен в виде кольца, разделителем служит структура с нулевыми значениями. Максимальное число записей для 1 KiB EEPROM — 203.

Запись о загрузке устройства попадает в лог только после синхронизации времени. Синхронизация производится одновременно с перезапуском таймера и перед выполнением любой команды во время инициализации устройства. По другому сопоставить корректное время данному событию не получится, да и информация о перезагрузках устройства, в отрыве от работающего демона, не сильно интересна.

11c823d3eac445b6b543cec6ca061a95.jpg

На этом всё, спасибо за внимание!

Исходные файлы проекта расположены на GitHub

© Geektimes