RS232 устройство 3-в-1 для домашнего Linux сервера: Часть 2 (Серверная)
Для устранения некоторых недостатков сервера, собранного из бытовых комплектующих, разработал недавно устройство, которым хочу поделиться. Его подробное описание, со схемой и исходными кодами, доступно на Geektimes в первой части.
Устройство получило наименование WRN от составляющих его подсистем:
- Аппаратный сторожевой таймер, работающий с watchdog демоном;
- Генератор истинно случайных чисел;
- Радиомодуль nRF24L01+ для сбора данных с автономных датчиков.
В этой части статьи будет рассмотрено как взаимодействовать с последовательным портом из пространства ядра (kernel space) и как организовать работу с несколькими подсистемами устройства через RS232 в Linux.
Последовательный порт, условно говоря, это две конечные точки (endpoint). В данном случае их нужно как минимум четыре: для передачи команд, считывания ответов, приёма потока случайных чисел и получения данных от сенсоров.
Задачу можно решить, если отдавать команды напрямую устройству, а трафик от него разбирать с помощью диспетчера. Идея, после некоторых раздумий, приняла окончательный вид:
(кликнуть для увеличения)
В роли диспетчера здесь выступает фоновый процесс (демон) wrnd
, его назначение фильтровать трафик в три FIFO канала:
- rng.fifo — поток случайных чисел;
- nrf.fifo — поток данных от сенсоров;
- cmd.fifo — данные, возвращаемые командами.
Также на схеме показаны:
- wrn_wdt — драйвер символьного устройства
/dev/watchdog
, управляющий сторожевым таймером; - wrnctrl — утилита для прошивки, мониторинга и управления устройством;
- оранжевым обозначены сервисы, с которыми взаимодействует проект.
Команды передаются устройству в текстовом виде, в формате [C|W|R|N][0-99]:[аргумент1]
, где первая буква, это идентификатор подсистемы, далее номер команды и через двоеточие может следовать аргумент.
В основном программное обеспечение написано на чистом C, содержит скрипты на Bash и Makefile. Установщик предназначен для Gentoo, но при желании его легко можно адаптировать под другие дистрибутивы.
Исходный код проекта доступен на GitHub alexcustos/wrn-project в директории wrnd. В коде встречается обработка данных с сенсора, ознакомиться с которым можно на Geektimes по ссылке: ATtiny85: прототип беспроводного сенсора.
Далее подробнее обо всех компонентах.
Драйвер
С точки зрения программирования, драйверы в Linux устроены достаточно просто. Также в дереве исходных кодов ядра доступно множество хорошо задокументированных примеров. Некоторые сложности могут возникнуть только при попытке скомпилировать и отладить задуманное. Дело в том, что в пространстве ядра не доступны привычные функции из библиотеки glibc, работать можно только с функциями представленными в ядре. Кроме этого, сильно затруднена интерактивная отладка кода.
Но в данном случае, это не страшно, поскольку задача предельно простая, реализовать символьное устройство /dev/watchdog
(код полностью: wrn_wdt.c). Обслуживается оно драйверами номер 10 (miscdevice), поэтому сначала нужно определить соответствующую структуру:
static struct miscdevice wrn_wdt_miscdev = {
.minor = WATCHDOG_MINOR, // стандартное устройство watchdog
.name = "watchdog", // имя файла в /dev
.fops = &wrn_wdt_fops, // обработчики операций над файлом устройства
};
Затем задать обработчики в соответствующей структуре данных:
static const struct file_operations wrn_wdt_fops = {
.owner = THIS_MODULE, // указатель на владельца структуры
.llseek = no_llseek, // перемещение по файлу не предусмотрено
.write = wrn_wdt_write, // вызывается при запись в файл
.unlocked_ioctl = wrn_wdt_ioctl, // ioctl
.open = wrn_wdt_open, // вызывается при открытии файла
.release = wrn_wdt_release, // вызывается при закрытии файла
};
Здесь unlocked_ioctl, это обычный обработчик обращения к дескриптору устройства через ioctl (), только с некоторых пор, вызов стал не блокирующим, следовательно нужно предпринимать дополнительные меры для синхронизации, если это необходимо.
Теперь нужно определить обработчики для операций загрузки и выгрузки модуля:
module_init(wrn_wdt_init); // вызывается при загрузке модуля в память
module_exit(wrn_wdt_exit); // вызывается при выгрузке модуля
При необходимости можно добавить модулю параметры через module_param (), MODULE_PARM_DESC () и для порядка указать:
MODULE_DESCRIPTION("..."); // описание
MODULE_AUTHOR("..."); // автора
MODULE_LICENSE("..."); // лицензию
Посмотреть эту информацию можно командой:
modinfo [path]/[module].ko
На этом шаге драйвер почти готов, осталось сделать его полезным. Для этого нужна возможность, как минимум, отправлять данные в последовательный порт. Сделать это на прямую нельзя, поскольку необходимый API не доступен из пространства ядра. Вариант связанный с удалением стандартного драйвера и разработкой собственного, можно сразу исключить как идеологически не верный. Поэтому остаётся два варианта:
- из пространства ядра обращаться к файловой системе в пространстве пользователя;
- зарегистрировать в пространстве ядра LDISC (line discipline) для перехвата и управления трафиком последовательного порта.
Думаю уже ясно, что я выбрал первый вариант, и нет мне в этом оправдания. Если серьёзно, то line discipline стоило бы использовать для размещения диспетчера трафика в пространстве ядра. Но, как уже отмечалось, программирование и отладка в этом пространстве, дело не тривиальное, и по возможности его нужно избегать, как и операций ввода/вывода, которые можно сделать в пространстве пользователя.
Но основная причина нецелесообразности разработки такого драйвера, заключается в том, что невозможно добиться самостоятельности, как если бы это был драйвер для USB. Line discipline это всего-лишь прослойка, которой необходим код в пространстве пользователя для настройки параметров порта и установки нужного line discipline.
Как бы то ни было, выбор сделан и основной код драйвера получился следующим:
static int __init wrn_wdt_init(void)
{
int ret;
// здесь нет возможности настроить порт, для корректной работы нужен wrnd демон
filp_port = filp_open(serial_port, O_RDWR | O_NOCTTY | O_NDELAY, 0);
if (IS_ERR(filp_port)) ... // обработка ошибки
else {
ret = misc_register(&wrn_wdt_miscdev); // регистрация miscdevice
if (ret == 0) wdt_timeout(); // установка таймаута сторожевого таймера
}
return ret;
}
static void wdt_enable(void)
{
... // локальные переменные
spin_lock(&wrn_wdt_lock); // синхронизация (блокировка доступа)
// watchdog демон отправляет keep-alive на каждую проверку в пределах interval,
// спамить WRN устройство не желательно, поэтому нужно ограничение
getnstimeofday(&t);
time_delta = (t.tv_sec - wdt_keep_alive_sent.tv_sec) * 1000; // sec to ms
time_delta += (t.tv_nsec - wdt_keep_alive_sent.tv_nsec) / 1000000; // ns to ms
if (time_delta >= WDT_MIN_KEEP_ALIVE_INTERVAL) {
// настройка доступа к буферу cmd_keep_alive из пространства пользователя
fs = get_fs(); // сохранение содержимого регистра FS
set_fs(get_ds()); // запись в него KERNEL_DS (указатель на сегмент данных ядра)
ret = vfs_write(filp_port, cmd_keep_alive, strlen(cmd_keep_alive), &pos);
set_fs(fs); // восстановление FS
if (ret != strlen(cmd_keep_alive)) ... // обработка ошибки
getnstimeofday(&wdt_keep_alive_sent);
}
spin_unlock(&wrn_wdt_lock); // синхронизация (разблокирование доступа)
}
static long wrn_wdt_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
... // локальные переменные
switch (cmd) {
case WDIOC_KEEPALIVE:
wdt_enable();
ret = 0;
break;
case WDIOC_SETTIMEOUT:
ret = get_user(t, (int *)arg); // получение данных из пространства пользователя
... // проверка входных данных
timeout = t;
wdt_timeout();
wdt_enable();
/* проходим дальше */
case WDIOC_GETTIMEOUT:
ret = put_user(timeout, (int *)arg); // отправка данных в пространство пользователя
break;
... // остальные команды
}
return ret;
}
Код немного сокращён и за рамками осталась поддержка MAGICCLOSE. Она нужна поскольку драйвер отключает сторожевой таймер при закрытии файла устройства, и важно распознать не штатное завершения работы watchdog демона, при котором файл будет закрыт системой. Чтобы этот сценарий приводил к ожидаемой перезагрузке системы, необходим механизм MAGICCLOSE. Его поддержка предусматривает деактивацию сторожевого таймера только при закрытии файла устройства непосредственно после получения специального символа, обычно это V.
Сборка драйвера производится с помощью Makefile командой make driver
, за это отвечает его часть:
TARGET_WDT = wrn_wdt
ifneq ($(KERNELRELEASE),)
# определение модуля, если вызов был из системы сборки ядра
obj-m := $(TARGET_WDT).o
else
# иначе это обычный вызов командой make
KERNEL := $(shell uname -r)
# обращение к системе сборки ядра для компиляции
driver:
$(MAKE) -C /lib/modules/$(KERNEL)/build M=$(PWD)
# обращение к системе сборки ядра для установки модуля
install: driver
$(MAKE) -C /lib/modules/$(KERNEL)/build M=$(PWD) modules_install
endif
Чтобы после установки модуль загружался при старте системы, нужно добавить в файл /etc/conf.d/modules
строчку:
modules="wrn_wdt"
Вручную модуль можно загрузить командами: modprobe wrn_wdt
или insmod ./wrn_wdt.ko
; выгрузить: modprobe -r wrn_wdt
или rmmod wrn_wdt
; убедиться, что модуль загружен: lsmod | grep wrn_wdt
.
Демон
Назначение wrnd
демона заключается в сортировке потока бинарных данных, поступающих из последовательного порта, и преобразование их в формат удобный для сервисов.
Настройки последовательного порта по умолчанию оптимизированы для текстовых терминалов, поэтому их необходимо привести в соответствие, а режим работы согласовать с устройством (код полностью: serialport.c):
// в отличии от termios имеет два поля для установки скорости приёма/передачи данных
struct termios2 ttyopts;
memset(&ttyopts, 0, sizeof ttyopts);
// получение текущих настроек
if (ioctl(fd, TCGETS2, &ttyopts) != 0) ... // обработка ошибки
// установка произвольный скорости порта для приёма и передачи данных
ttyopts.c_cflag &= ~CBAUD;
ttyopts.c_cflag |= BOTHER;
ttyopts.c_ispeed = speed; // unsigned int, а не константа вроде B9600
ttyopts.c_ospeed = speed;
... // установка требуемого режима работы и отключение управления трафиком
// блокировка операции чтения до получения заданного числа байт
ttyopts.c_cc[VMIN] = vmin;
// блокировка (ожидание данных) на указанное в десятых долях секунды время
ttyopts.c_cc[VTIME] = vtime;
// запись изменённых настроек
if (ioctl(fd, TCSETS2, &ttyopts) != 0) ... // обработка ошибки
Здесь важно выбрать оптимальные значения VMIN и VTIME. Если они равны нулю, опрос порта будет производится без задержек потребляя ресурсы системы без необходимости. Не нулевое значение VMIN, при отсутствии данных, может заблокировать поток на неограниченное время.
В данном случае, данные считываются по одному байту в основном потоке программы. Блокировать его надолго не хорошо, поэтому VMIN всегда равен нулю, а VTIME можно изменить через параметры, по умолчанию он равен 5 (максимальная задержка 0.5 сек).
Принятые байты поступают в буфер фиксированного размера. При получении ожидаемого числа байт, буфер преобразуются в соответствующую структуру данных (struct). Этот метод хорош, но имеет особенности, которые необходимо иметь в виду. Компилятор оптимизирует структуры данных, добавляя промежутки между полями, выравнивая их по своему усмотрению, обычно до границы слова. Поскольку данные пересылаются между разными платформами, возможны несоответствия из-за различного размера слова и порядка следования байт в нём (endian).
Чтобы избавить от выравнивания, необходимо объявить структуры данных упакованными (packed). Проекты в AtmelStudio по умолчанию собираются с ключом -fpack-struct
, поэтому достаточно убедиться, что нет предупреждений об отмене действия данного ключа. Собирать wrnd проект с этим ключом не желательно, поскольку тут нет задачи экономить память за счёт скорости доступа к данным. Достаточно указать соответствующий атрибут там, где это необходимо:
struct payload_header {
... // поля структуры
} __attribute__ ((__packed__));
... // аналогично для остальных структур
Процесс запускается в фоновом режиме функцией daemon
:
if (arguments->daemonize && daemon(0, 0) < 0) ... // обработка ошибки
В результате создаётся копия процесса (fork) с новым PID, которая продолжает работу дальше, а текущий процесс завершает работу. В аргументах функции указанно, что для нового процесса, необходимо установить корневую директорию в качестве рабочей и перенаправить стандартные потоки ввода, вывода и ошибок в /dev/null
.
Чтобы демон не завершал работу при попытке записи в FIFO, к которому не подключён читатель, необходимо игнорировать сигнал SIGPIPE:
signal(SIGPIPE, SIG_IGN);
Назначение остального кода, вполне можно перечислить следующим списком:
- разбор переданных параметров;
- проверка условий для запуска, создание и открытие необходимых директорий и файлов;
- работа с лог-файлами;
- обработка сигналов SIGINT, SIGTERM для корректного завершения работы;
- обработка сигнала SIGHUP для переоткрытия логов после
logrotate
; - синхронизация с устройством и его инициализация;
- обработка поступающих данных и запись результата в соответствующий FIFO канал.
Канал rng.fifo
Данные попадают в канал на прямую в бинарном виде на скорости примерно 636 байт/сек. Для подмешивания энтропии в /dev/random
используется rngd
демон. Чтобы он использовал только нужный источник, ему необходимо передать параметры »--no-tpm=1 --no-drng=1 --rng-device /run/wrnd/rng.fifo
».
Здесь стоит отметить, что для решения проблемы нехватки энтропии, генератор истинно случайных чисел не обязателен. Достаточно запустить rngd
с параметром »--rng-device /dev/urandom
». Рекомендации так не делать, как правило, полностью не обоснованы. Алгоритмы используемые в /dev/urandom
очень хороши и генерируемые им числа нравятся тестам даже больше, чем от данного аппаратного генератора. В тестах FIPS (rngtest
) и dieharder
разница не значительная, но определённо в пользу /dev/urandom
. Результаты тестирования можно посмотреть в первой части, ближе к концу публикации.
Мой выбор в пользу генератора истинно случайных чисел прост — захотел собрать подобное устройство, и не нашёл для себя доводов против.
Канал nrf.fifo
Данные от сенсоров преобразуются и отправляются в канал в виде SQL запросов на вставку записей в таблицу. В примере wrnsensors.sh показана работа с SQLite3 базой данных, но INSERT запрос универсален и должен подойти к любой SQL базе данных.
Канал cmd.fifo используется утилитой управления wrnctrl
, о ней чуть ниже.
Собрать wrnd
можно с помощью Makefile командой make daemon
. Для сборки отладочной версии предусмотрена цель debug.
Утилита управления
Утилита wrnctrl
работает совместно с wrnd
демоном, который должен быть запущен, поскольку утилита получает данные от устройства из канала cmd.fifo.
Открыть FIFO с предсказуемым результатом, можно только на запись, при этом читатель получит данные от всех источников. Если же несколько читателей откроют один FIFO, то предсказать, кто из них получит данные невозможно. Такое поведение верно по определению, но не желательно, следовательно нужно синхронизировать доступ к cmd.fifo.
В Linux для этого можно объявить файл занятым с помощью flock
, затем проверять данный статус в своём коде. Но этот механизм не работает для именованных каналов (pipe). Поэтому для этой цели необходимо использовать файл в /tmp
(код полностью: wrnctrl):
function device_cmd()
{
cmd=$1 # команда устройству
# запуск новой оболочка (subshell), вывод направляется в файл с дескриптором 4
(
# блокировка файла с дескриптором 4, если ещё не занят
if ! flock -w ${FIFO_MAX_WAIT} 4; then return; fi # выход если занят
# предоставление права на запись всем, если достаточно полномочий
if [ -O ${LOCK_NAME} ]; then chmod 666 ${LOCK_NAME}; fi
# чтение cmd.fifo в дескриптор 3, ограничено таймаутом
exec 3< <(timeout ${FIFO_MAX_LOCK} cat ${WRND_CMDFIFO})
sleep 0.2 # иначе дескриптор может быть ещё недоступен
if [ -r /dev/fd/3 ]; then
echo "$cmd" >${WRND_DEVICE} # запись команды в порт устройства
# цикл прервётся, когда демон закроет FIFO или по timeout
while read log_line <&3; do
echo "$log_line" # вывод ответа
done
exec 3>&- # явное закрытие дескриптора 3
fi
) 4>${LOCK_NAME}
}
Устройство прошивается командой wrnctrl flash [firmware].hex
при этом демоны wrnd
и watchdog
должны быть остановлены. Для работы этой команды также требуется утилита avrdude
, установить её можно через менеджер пакетов, например:
emerge -av dev-embedded/avrdude
Помимо описанных выше файлов, установочный пакет проекта включает также:
- файл с настройками демона;
- OpenRC скрипт для управления демоном;
- файл с настройками для logrotate;
- скрипт, запускаемый через cron, для записи в лог статусов всех подсистем устройства.
Сборка и установка проекта производится командой make install
. Установка должна запускаться с правами суперпользователя (root). Файлы копируются системной утилитой install
, которая позволяет сразу устанавливать права и владельца на целевые файлы.
Заключение
Интерфейс USB для данного устройства, несомненно, был бы предпочтительнее. Но отсутствие в моём сервере доступного USB порта, привело к появлению проекта именно в таком виде. Тем не менее, получилось довольно простое и стабильно работающее устройство, которое вполне могу рекомендовать для воспроизведения.