Четыре линии RS-485 на одном IP. Сделали шлюз Modbus TCP на NAPI-C и FT4232H и что увидели под нагрузкой

Эта статья состоит из двух разных частей. В первой части — общая информация о шлюзах последовательных портов, как их можно собрать, немного про сферы применения и так далее. Во второй части — техническая информация как под наш 4-портовый шлюз на базе FT4232 собрать Linux, как проверить работоспособность и как используя наш программный шлюз Modbus RTU (Modbus TCP mbus‑gw‑t2r) мы делали долгие тесты и замеры. Если вас интересует техническая информация, смело переходите ко второй части статьи (она получилась очень объемная).
Содержание статьи:
Зачем нужны многопортовые шлюзы
Почему плата удобнее, а не набор модулей на проводах
Где это применимо (сферы применения 4-портового шлюза)
Насколько такой девайс востребован
Переходим к технической части. Про длительное тестирование
Железо готово. Собираем софт и стенд для тестирования
Пакеты
Проверка, что FT4232H виден
Прошивка EEPROM, RS-485 на всех четырех каналах
Резервная копия
Конфиг
Прошивка и последующая проверка
Постоянные имена портов и latency_timer
Watchdog, плата должна восстанавливаться сама
Режим RS-485 в ядре не нужен
Датчик KTH2-R, карта регистров
Проверка датчиков напрямую, без шлюза
Проверка с помощью Python скрипта
Шлюз Modbus RTU - Modbus TCP, mbusgw-t2r
Сборка и установка
Конфиг еще раз
Первый запуск вручную
Сервис systemd
Тестируем систему на стенде
Найденные типичные ошибки и диагностика
Чем mbusgw-t2r отличается от mbusd
Что делать с данными дальше? Складируем в базу через Telegraf
Выводы
Зачем нужны многопортовые шлюзы
Предположим, на производственном участке есть несколько линий RS-485 с датчиками Modbus RTU. Нужно отдать их в сеть как Modbus TCP, чтобы SCADA, OPC-сервер или обычный скрипт опрашивали датчики по Ethernet. Обычно для этого берут готовый промышленный шлюз. В российской рознице четырехпортовые модели стоят примерно от 55 до 80 тысяч рублей, то есть 15–20К за порт, и работают на закрытой прошивке. Второй путь — ПЛК, у которого шлюз остается побочной функцией.
Мы собрали то же самое из компактного промышленного компьютера NAPI-C (Rockchip RK3308, Armbian), микросхемы FT4232H (четыре независимых UART на одном USB) и открытого шлюза mbusgw-t2r (Apache 2.0, исходники на GitHub). Получилось устройство с четырьмя линиями RS-485, каждая на своем TCP-порту, с одним конфигом, одним сервисом и аппаратным watchdog.
Про промышленный шлюз на модуле NAPI Slot мы писали раньше. Здесь другая задача, несколько линий RS-485 на одном IP.
Почему плата удобнее, а не набор модулей на проводах
В целом, такую функциональность как у нашего шлюза, может собрать любой инженер из отдельных компонентов. Берется одноплатный компьютер с USB, внешний модуль или кабель на FT4232H, четыре приемопередатчика RS-485 и преобразователь питания, и все это соединяется проводами. Работать будет, но у каждого “соединения на проводах” есть вероятность ненадежного контакта, а питание и землю приходится разводить отдельно.

Рис. 1. Пример внешнего преобразователя для расширения портов
Мы сделали компактную плату, на которой FT4232H, четыре приемопередатчика и DC-DC уже распаяны, а модуль NAPI-C устанавливается на нее. Остается подать питание 9–36 В, подключить Ethernet и четыре пары A/B.
Софт шлюза тоже наш, mbusgw-t2r мы написали сами и выложили в открытый доступ под лицензией Apache 2.0 (github.com/napilab/mbus-gw-t2r). Из известных нам открытых решений для Linux не было готового шлюза, который обслуживает несколько линий RS-485 в одном процессе, с одним конфигом и очередью запросов на каждую линию. Самый распространенный mbusd работает с одним портом, и на четыре линии нужны четыре экземпляра (сравнение ниже). В итоге у пользователя есть и плата, и софт, и писать самому ничего не нужно.
И даже когда все собрано, отдельная задача - убедиться, что он проработает месяцы без присмотра. Поэтому большая часть статьи посвящена тому, как мы его проверяли, какие проблемы проявились только на нагрузочном стенде и что в итоге вошло в предсерийную конфигурацию.
Сразу несколько слов по цифрам производительности. 59 запросов в секунду на каждую из четырех линий при 19200 бод, медиана ответа 16 мс на самой плате, ноль ошибок шлюза на почти 400 тысячах запросов длительного прогона, память, потоки и дескрипторы шлюза до и после тестов не изменились.
Где это применимо (сферы применения 4-портового шлюза)
У данной платы в базовой версии 4 порта, но есть возможность расширения до 8 портов в сумме с помощью второй платы, которую мы скоро покажем тоже. На Рис. 2 маркером показан разъем расширения.

Рис. 2. Общий вид (рендер) платы шлюза с разъемом под расширение
Четыре независимые линии RS-485 с одним IP-адресом закрывают такие типовые задачи:
• Серверные, ЦОД, щитовые. Датчики температуры и влажности по рядам стоек, по линии на ряд. Несколько систем мониторинга опрашивают их одновременно, шлюз ставит запросы в очередь на линию, так что на шине всегда один мастер.
• Теплицы, склады, инкубаторы. Десятки датчиков климата по зонам. Каждая зона сидит на своей линии, обрыв одной не роняет остальные.
• Вентиляция и тепловые пункты. Контроллеры приточек, теплосчетчики, частотные преобразователи. На разных линиях можно держать разные скорости и четность, на одной шине такое не соберется.
• Оборудование разных производителей с одинаковыми адресами. Устройства с адресом 1 разводятся по линиям, а SCADA различает их по TCP-порту.
• Замена нескольких одноканальных шлюзов. Один IP, один сервис, один конфиг и один блок питания вместо четырех коробок.
• Шлюз и сборщик данных в одном корпусе. Плата управляется обычной Linux. Рядом со шлюзом поднимаются MQTT-брокер, Node-RED, локальная база или Telegraf. Как с Telegraf опрашивать Modbus-датчики без своего кода, мы разбирали в отдельной статье, здесь тот же подход ложится поверх шлюза (пример в конце).
Наверно, существуют и другие, более специфичные области применения для 4-портового шлюза, это уже нам расскажут заказчики, а мы с вами поделимся.
Насколько такой девайс востребован
Оценка ниже собрана из открытых данных. Вывод построен на ценах, структуре спроса и оценках рынка автоматизации с различных сайтов вендоров и дистрибьюторов. Цены взяты в интернет-магазине xcom-shop.ru на момент подготовки статьи. Все устройства в таблице имеют четыре последовательных порта.
Устройство | Тип | Цена, руб. | Цена за порт, руб. |
Moxa MGate MB3480 | Modbus-шлюз | 83 202 | около 20 800 |
Moxa NPort 5450-T | сервер последовательных портов | 82 353 | около 20 600 |
Kyland KGW3204 | Modbus-шлюз, DIN-рейка | 67 556 | около 16 900 |
Kyland KPS3204 | сервер последовательных портов | 58 345 | около 14 600 |
Для сравнения, китайский четырехпортовый шлюз Linovision IOT-C104 стоит 129,99 евро на европейском сайте производителя. Доступность этой модели в России мы не проверяли.
Moxa и Kyland в российской рознице есть (например, у Ниеншанц-Автоматики), так что аргументом служит не доступность, а цена за порт, открытость стека и возможность держать данные внутри периметра.
Отмечу важный аспект нашего решения: “сердце” изделия, т.е. вычислительный модуль NAPI-C, внесен в реестр Минпромторг, а значит можно считать наш шлюз российского происхождения.
Переходим к технической части. Про длительное тестирование
Еще одна мысль статьи: устройство, которое проходит проверку на стенде, не обязательно так работает всегда. Вот что мы нашли только потому, что гоняли стенд часами.
Потеря половины ответов, невидимая при ручной проверке. Драйвер FTDI по умолчанию копит принятые байты 16 мс и отдает их пачками. Modbus RTU определяет конец кадра по тишине в 3,5 символа (3,6 мс на 9600), и шлюз, который строго следует стандарту, видит «конец кадра» посреди ответа датчика.
Утилиты mbpoll и minimalmodbus эту проблему маскируют, потому что знают ожидаемую длину ответа и просто ждут ее. При проверке «руками» все работает, а через шлюз получается 50 % ошибок CRC. Лечится одной строкой udev, но найти ее без нагрузочного теста нельзя.
Что делать, когда плата зависла. Ядро, питание, помеха на USB. В полевых условиях зависание рано или поздно случится, и устройство без присмотра обязано перезагрузиться само. По умолчанию в Armbian это выключено. Мы включили аппаратный watchdog и перезагрузку при панике ядра и проверили, что после реального сбоя плата поднимается сама вместе со шлюзом.
Утечки. Процесс, который теряет по килобайту на каждое соединение, проработает неделю и упадет. Поэтому до и после тестов мы сравнивали память, число потоков и дескрипторов шлюза. Значения не изменились.
Поведение под перегрузкой. Важно не только сколько запросов в секунду выдерживает линия, но и что происходит, когда клиентов больше, чем она может обслужить. Правильно, когда клиенты ждут дольше, а ошибок и перепутанных ответов нет. Неправильно, когда ответы одного клиента приходят другому. Это проверяется только множеством параллельных клиентов с контролем содержимого ответов.
Дальше пойдет техническая часть, от прошивки EEPROM до методики тестирования. Все описанное воспроизводится на любой плате с USB-хостом под Armbian или Debian.
Железо готово. Собираем софт и стенд для тестирования
Стенд собран на несущей плате, на которую установлен модуль NAPI-C. Питание 24 В подается с лабораторного блока на DC-DC-преобразователь 9–36 В → 5 В / 4 А, а питание датчиков берется от того же источника. Модуль общается с FT4232H по USB, а четыре канала FT4232H выведены на четыре приемопередатчика RS-485. Ethernet идет на тестовую машину, USB-C используется как консоль.

Рис. 3. Стенд с подписями к элементам платы

Рис. 4. Структурная схема стенда
• Плата NAPI-C (Rockchip RK3308), Armbian 26.11 noble (Ubuntu 24.04), ядро 6.18 rockchip64. Проверено также на ядре 7.3.
• FT4232H — четыре UART на одном USB, каждый канал со своим приемопередатчиком RS-485.
• Четыре датчика температуры и влажности KTH2-R (Modbus RTU, по одному на линию).

Рис. 5. Фото шлюза на рабочем столе (с творческим беспорядком)
Про сам NAPI-C и его интерфейсы UART, I2C, SPI и GPIO мы писали в статье про платформу для прототипирования. Для учебных и первых экспериментальных проектов есть плата NapiSci на том же модуле, она описана в этой статье.
USB-хост на плате включается оверлеем.
# /boot/armbianEnv.txt
overlays=otg-hostСвои оверлеи добавляются командой armbian-add-overlay <файл.dts> (инструкция на napiworld.ru). Оверлеи из веток старых ядер (6.6) на 6.18 могут не подойти, брать нужно из ветки, соответствующей ядру.
Пакеты
apt update
apt install ftdi-eeprom mbpoll usbutilsftdi-eeprom подтягивает libftdi1-2. Собирать libftdi из исходников не нужно, пакет в noble версии 1.5 подходит. gcc, libftdi1-dev и libudev-dev пригодятся только тем, кто пишет свою программу на libftdi.
Проверка, что FT4232H виден
lsusb -d 0403:
lsusb -v -d 0403:6011 2>/dev/null | grep -iE "iManufacturer|iProduct|iSerial"• 0403:6011 — FT4232H (Quad UART, обычный режим), он и нужен.
• 0403:6014 — это FT232H, одноканальный чип. Их часто путают.
Прошивка EEPROM, RS-485 на всех четырех каналах
EEPROM (93C46/56/66) подключена к самому FT4232H. ftdi_eeprom читает и пишет ее через USB, поэтому драйвер ядра и оверлей device-tree для этого не нужны, достаточно работающего USB-хоста.
Резервная копия
ftdi_eeprom --read-eeprom пишет образ в файл из параметра filename=, поэтому для чтения нужен отдельный конфиг.
cat > backup.conf << 'EOF'
vendor_id=0x0403
product_id=0x6011
filename=ft4232h-backup.bin
EOF
ftdi_eeprom --read-eeprom backup.confВыгружать ftdi_sio (rmmod) не нужно, libftdi сама отсоединяет драйвер. Побочный эффект в том, что после ftdi_eeprom порты /dev/ttyUSB* этого адаптера пропадают до переподключения USB.
Конфиг
Серийный номер у каждой платы должен быть свой. Одинаковые серийники не дают отличить адаптеры друг от друга, а на них строятся постоянные имена портов (раздел 5).
cat > ft4232h.conf << 'EOF'
filename=ft4232h.bin
vendor_id=0x0403
product_id=0x6011
# Тип EEPROM: 0x46 = 93C46, 0x56 = 93C56, 0x66 = 93C66, смотреть маркировку на плате
eeprom_type=0x56
manufacturer="NapiWorld"
product="Quad RS485"
serial="NAPI0001" # уникальный для каждой платы
use_serial=true
# Каналы A..D: UART, драйвер VCP (ftdi_sio -> /dev/ttyUSBn), аппаратный TXDEN для RS-485
cha_type=UART
chb_type=UART
cha_vcp=true
chb_vcp=true
chc_vcp=true
chd_vcp=true
cha_rs485=true
chb_rs485=true
chc_rs485=true
chd_rs485=true
EOFПояснения по параметрам.
• chX_rs485=true — бит «RS-485» канала в EEPROM. Чип сам выставляет TXDEN (направление приемопередатчика) на время передачи. Этим исчерпывается управление направлением, в Linux ничего дополнительно включать не нужно.
• Опции cbus*, cbush*, cbusx* относятся к другим чипам (FT232R, FT232H, FT-X). У FT4232H шин CBUS нет, а в примерах из интернета их приходится вычищать.
• Параметров chc_type и chd_type не существует. Тип задается только для A и B, каналы C и D у FT4232H всегда UART.
• invert_txd трогать не нужно. Он инвертирует сам сигнал TXD, и линия перестанет работать.
Прошивка и последующая проверка
ftdi_eeprom --flash-eeprom ft4232h.conf
ftdi_eeprom --read-eeprom ft4232h.conf # вывод должен совпасть с конфигом
Новое содержимое EEPROM чип читает только при подключении, поэтому нужно переподключить USB или передернуть питание платы. После этого проверяем.
lsusb -v -d 0403:6011 2>/dev/null | grep -i iSerial # новый серийник
ls -l /dev/ttyUSB* # снова 4 портаПостоянные имена портов и latency_timer
/dev/ttyUSB0..3 раздаются в порядке обнаружения. Пока в системе один FT4232H и больше ничего, порядок обычно совпадает с каналами A..D. Но стоит подключить модем, второй адаптер или отладочный кабель, либо поймать другой порядок инициализации при загрузке, и ttyUSB0 окажется другим каналом. Шлюз и SCADA при этом молча начнут опрашивать не те датчики.
Решение — udev-правило, привязывающее имя к номеру канала (а при нескольких адаптерах еще и к серийному номеру). В то же правило мы добавляем два пункта.
• latency_timer=1, обязательно для Modbus RTU (см. раздел «Почему длительное тестирование обязательно»).
• Запрет ModemManager трогать порты. Увидев ttyUSB, он начинает слать туда AT-команды.
cat > /etc/udev/rules.d/99-ft4232h-rs485.rules << 'EOF'
# FT4232H (0403:6011): постоянные имена /dev/rs485-1..4 по номеру канала A..D
SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="0403", ENV{ID_MODEL_ID}=="6011", ENV{ID_USB_INTERFACE_NUM}=="00", SYMLINK+="rs485-1"
SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="0403", ENV{ID_MODEL_ID}=="6011", ENV{ID_USB_INTERFACE_NUM}=="01", SYMLINK+="rs485-2"
SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="0403", ENV{ID_MODEL_ID}=="6011", ENV{ID_USB_INTERFACE_NUM}=="02", SYMLINK+="rs485-3"
SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="0403", ENV{ID_MODEL_ID}=="6011", ENV{ID_USB_INTERFACE_NUM}=="03", SYMLINK+="rs485-4"
# Не пускать ModemManager к этим портам
SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="0403", ENV{ID_MODEL_ID}=="6011", ENV{ID_MM_DEVICE_IGNORE}="1"
# USB latency 16 мс -> 1 мс: иначе ответ датчика приходит кусками с паузой > t3.5,
# и шлюз режет кадр (CRC16ERR / BADFRAME / exception 4)
ACTION=="add", SUBSYSTEM=="usb-serial", DRIVER=="ftdi_sio", ATTR{latency_timer}="1"
EOF
udevadm control --reload
udevadm trigger --action=add --subsystem-match=tty --subsystem-match=usb-serial
udevadm settle
ls -l /dev/rs485-*
grep . /sys/bus/usb-serial/devices/ttyUSB*/latency_timer # везде 1Если FT4232H на хосте несколько, в первые четыре строки добавляется ENV{ID_SERIAL_SHORT}=="NAPI0001", а другим адаптерам даются свои имена (rs485-5..8).
Без своих правил можно использовать стабильные имена от systemd.
ls -l /dev/serial/by-id/ # usb-NapiWorld_Quad_RS485_NAPI0001-if00-port0 ... -if03-port0
ls -l /dev/serial/by-path/ # по физическому USB-порту платыДальше везде используется /dev/rs485-N, а не /dev/ttyUSBn.
Про latency_timer в цифрах. На стенде без правила через шлюз было около 50% ошибок, с правилом все 400 запросов из 400 прошли без ошибок. При этом mbpoll напрямую на тот же порт работал без ошибок в обоих случаях.
Watchdog, плата должна восстанавливаться сама
По умолчанию в Armbian kernel.panic = 0, то есть при панике ядра плата висит бесконечно, а аппаратный watchdog не используется. Для устройства без присмотра это недопустимо.
mkdir -p /etc/systemd/system.conf.d
cat > /etc/systemd/system.conf.d/90-watchdog.conf << 'X'
[Manager]
RuntimeWatchdogSec=30s
RebootWatchdogSec=2min
X
cat > /etc/sysctl.d/90-panic-reboot.conf << 'X'
kernel.panic = 10
kernel.panic_on_oops = 1
kernel.panic_on_warn = 1
X
sysctl -p /etc/sysctl.d/90-panic-reboot.conf
systemctl daemon-reexec
wdctl /dev/watchdog0 # Timeout: 30 seconds, Timeleft периодически сбрасывается• RuntimeWatchdogSec заставляет systemd «кормить» аппаратный watchdog (Synopsys DesignWare). Если система зависла, перезагрузка наступит примерно через 30 с.
• panic = 10 перезагружает плату через 10 с после паники ядра.
• panic_on_warn = 1 перезагружает плату и при внутренней ошибке ядра (WARN), после которой работа может быть ненадежной. Если на какой-то сборке ядра появятся безобидные WARN, значение возвращается в 0.
Проверено на реальном сбое. Плата перезагрузилась сама, шлюз поднялся без вмешательства.
Режим RS-485 в ядре не нужен
Во многих примерах RS-485 включают через ioctl TIOCSRS485. Драйвер ftdi_sio этот ioctl не поддерживает (в ядре 6.18 у модуля нет обработчика RS-485), в ответ приходит Inappropriate ioctl for device. Направлением у FT4232H управляет сам чип через TXDEN (раздел 4.2). По той же причине в конфиге шлюза для этих портов указано rs485 = 0.
Датчик KTH2-R, карта регистров
Используется функция 3 (holding registers). В Modbus есть две нумерации, отсюда вечная путаница «на единицу больше». Утилиты mbpoll, modpoll и OPC-серверы используют номер регистра (с 1), а в самом запросе и в minimalmodbus, pymodbus, libmodbus передается адрес (с 0), равный номеру минус 1.
Номер (mbpoll -r) | Адрес (в запросе) | Назначение |
1 | 0x0000 | Модель устройства |
2 | 0x0001 | Версия ПО |
3–5 | 0x0002–0x0004 | Имя «KTH2-R» (ASCII, 2 символа на регистр) |
13 | 0x000C | Адрес Modbus (по умолчанию 1) |
14 | 0x000D | Скорость, код 3 = 9600, 4 = 19200 (запись функцией 6) |
15 | 0x000E | Четность, код (0 = нет) |
769 | 0x0300 | Температура × 10, со знаком |
770 | 0x0301 | Влажность × 10 |
На стенде прочитано. Модель 0x0001, ПО 0x0120, имя KTH2-R, адрес 1, четность 0, T около 23 °C, RH около 34 %.
Переход на 19200. По умолчанию датчик работает на 9600, а 19200 — максимум по документации. Код скорости пишется в регистр 14 функцией 6 и применяется сразу, так что следующий запрос на старой скорости датчик уже не слышит. На стенде это делалось через работающий шлюз, без его остановки.
mbpoll -m tcp -a 1 -r 14 -t 4 -1 -p 502 127.0.0.1 4 # 4 = 19200; шлюз пока на 9600
# в конфиге шлюза у этого порта: chars = "19200, 8, N, 1", затем ystemctl restart mbusgw-t2r
mbpoll -m tcp -a 1 -r 14 -t 4 -1 -p 502 127.0.0.1 # проверка, должно быть [14]: 4Пока конфиг не исправлен, порт отвечает исключением 4 (датчик молчит). Вернуть 9600 можно тем же способом с кодом 3. Что дает 19200 — время одного запроса падает с 30 до 17 мс, пропускная способность растет в 1,8 раза (раздел 11, шаг 5).
Адрес датчика (регистр 13), по всей видимости, меняется той же функцией 6. Делать это нужно только на одном датчике на линии.
mbpoll -m rtu -a 1 -r 13 -t 4 -b 9600 -P none -1 /dev/rs485-1 2 # записать адрес 2
mbpoll -m rtu -a 2 -r 13 -t 4 -b 9600 -P none -1 /dev/rs485-1 # проверить по новому адресуПроверка датчиков напрямую, без шлюза
Порт может держать только одна программа, поэтому на время прямых проверок шлюз останавливается командой systemctl stop mbusgw-t2r. Скорость -b должна быть той, на которую настроен датчик.
# температура и влажность одним запросом, все 4 канала
for n in 1 2 3 4; do
echo "== rs485-$n"
mbpoll -m rtu -a 1 -r 769 -c 2 -t 4 -b 9600 -P none -1 /dev/rs485-$n
done
# все документированные регистры одного датчика
mbpoll -m rtu -a 1 -r 1 -c 5 -t 4 -b 9600 -P none -1 /dev/rs485-1
mbpoll -m rtu -a 1 -r 13 -c 3 -t 4 -b 9600 -P none -1 /dev/rs485-1
mbpoll -m rtu -a 1 -r 769 -c 2 -t 4 -b 9600 -P none -1 /dev/rs485-1Ключ -1 означает один опрос и выход. Без него mbpoll опрашивает датчик раз в секунду до Ctrl+C.
Проверка с помощью Python скрипта
python3-serial есть в репозитории, а minimalmodbus нет, поэтому ставим в venv.
apt install python3-venv
python3 -m venv /root/mbenv
/root/mbenv/bin/pip install minimalmodbusСкрипт read_kth2r.py.
import sys, minimalmodbus, serial
port = sys.argv[1] if len(sys.argv) > 1 else '/dev/rs485-1'
dev = minimalmodbus.Instrument(port, slaveaddress=1)
dev.serial.baudrate = 9600
dev.serial.bytesize = 8
dev.serial.parity = serial.PARITY_NONE
dev.serial.stopbits = 1
dev.serial.timeout = 1.0
# minimalmodbus принимает АДРЕС (с 0): 0x0300 = регистр 769 в mbpoll
t = dev.read_register(0x0300, number_of_decimals=1, functioncode=3, signed=True)
rh = dev.read_register(0x0301, number_of_decimals=1, functioncode=3)
print(f'{port}: T = {t} °C, RH = {rh} %')
for n in 1 2 3 4; do /root/mbenv/bin/python read_kth2r.py /dev/rs485-$n; doneПро эхо. Будет ли эхо собственного запроса, зависит от разводки приемопередатчика, то есть от того, отключается ли приемник, пока TXDEN активен. На нашей плате эха нет. Если на другой плате ответ начинается с копии запроса (01 03 03 00 00 01 84 4E), в minimalmodbus ставится dev.handle_local_echo = True. Вызов flushInput() до запроса, как советуют в примерах, эхо не убирает, потому что оно приходит уже после отправки.
Шлюз Modbus RTU - Modbus TCP, mbusgw-t2r
Шлюз отдает каждый канал RS-485 на свой TCP-порт.
SCADA / mbpoll mbusgw-t2r линии RS-485
TCP 502 ───────┐ ┌─ /dev/rs485-1 (A) ── KTH2-R
TCP 503 ───────┤ Modbus TCP ├─ /dev/rs485-2 (B) ── KTH2-R
TCP 504 ───────┤ → RTU ├─ /dev/rs485-3 (C) ── KTH2-R
TCP 505 ───────┘ └─ /dev/rs485-4 (D) ── KTH2-R
На один порт можно подключать несколько TCP-клиентов. Шлюз ставит их запросы в очередь, и на линию одновременно уходит только один. Адрес датчика шлюз не проверяет и передает как есть, поэтому на одной линии может висеть несколько датчиков с разными адресами.
Сборка и установка
Проверено на Armbian 26.11 noble, ядро 6.18. Шлюз собирается штатно, без правок. Описание относится к версии X.00-13 и новее. Шлюз ставится в /opt/mbusgw-t2r, а библиотека StarLet ставится отдельно.
# 1. Зависимости
apt install build-essential cmake pkg-config libconfig-dev git
# 2. Библиотека StarLet (ставится в /usr/local)
cd /usr/src
git clone https://gitlab.com/SysMan-One/utility_routines
cd utility_routines && mkdir build && cd build
cmake ../ -DCMAKE_BUILD_TYPE=Release && make -s && cmake --install .
# 3. Шлюз
cd /usr/src
git clone https://github.com/napilab/mbus-gw-t2r
cd mbus-gw-t2r && mkdir build && cd build
cmake ../ -DCMAKE_BUILD_TYPE=Release && make -s
# 4. Самопроверка без железа (виртуальный порт + имитатор датчика), должно быть ALL PASS
python3 ../tests/bench.py ./mbusgw-t2r
# 5. Установка (sbin/ бинарник, etc/ конфиги, share/doc/mbusgw-t2r/ документация)
make install
/opt/mbusgw-t2r/sbin/mbusgw-t2r /? 2>&1 | grep -m1 REVISNF # версия
# %T2R-I-REVISNF, Rev: T2R X.00-13ECO02/aarch64 ...make install не перезаписывает существующий конфиг, так что обновление (git pull, шаги 3 и 5) настройки не трогает. make uninstall удаляет все, кроме конфигов. В комплекте есть и правило latency_timer (share/doc/mbusgw-t2r/99-ftdi-latency.rules), но правило из раздела 5 его уже включает.
Конфиг еще раз
Файл /opt/mbusgw-t2r/etc/modbus-t2r_settings.conf.
# 4 x FT4232H -> TCP 502..505, KTH2-R 19200 8N1 (новый датчик - 9600)
serials = (
{device = "/dev/rs485-1"; chars = "19200, 8, N, 1"; iotmo = 1000;},
{device = "/dev/rs485-2"; chars = "19200, 8, N, 1"; iotmo = 1000;},
{device = "/dev/rs485-3"; chars = "19200, 8, N, 1"; iotmo = 1000;},
{device = "/dev/rs485-4"; chars = "19200, 8, N, 1"; iotmo = 1000;}
);
listeners = (
{bind = "TCP:0.0.0.0:502"; connlm = 16; idletmo = 60; target = "/dev/rs485-1";},
{bind = "TCP:0.0.0.0:503"; connlm = 16; idletmo = 60; target = "/dev/rs485-2";},
{bind = "TCP:0.0.0.0:504"; connlm = 16; idletmo = 60; target = "/dev/rs485-3";},
{bind = "TCP:0.0.0.0:505"; connlm = 16; idletmo = 60; target = "/dev/rs485-4";}
);• target должен дословно совпадать с device, иначе listener отбрасывается.
• chars задает скорость датчика. При несовпадении порт отвечает только исключением 4.
• iotmo — сколько ждать ответа датчика, в миллисекундах. Пока шлюз ждет, остальные клиенты этого порта стоят в очереди, так что молчащий адрес задерживает их на iotmo.
• connlm — сколько TCP-клиентов одновременно (от 1 до 512, по умолчанию 32). Лишний клиент сразу отключается с сообщением %T2R-W-CONNLIMIT. Каждый клиент обслуживается отдельным потоком.
• idletmo — клиент, молчащий столько секунд (по умолчанию 60), отключается. SCADA с опросом реже раза в минуту просто переподключится, а если не умеет, значение нужно увеличить.
• rs485 не указываем (то есть 0), потому что направлением управляет TXDEN чипа.
• Порты ниже 1024 требуют root, сервис ниже работает от root.
Первый запуск вручную
/opt/mbusgw-t2r/sbin/mbusgw-t2r /trace /settings=/opt/mbusgw-t2r/etc/modbus-t2r_settings.conf
В выводе должно быть по четыре строки каждого вида.
%T2R-S-DEVREADY, Device </dev/rs485-1> [19200 baud, answer tmo: 1000 msec, t3.5: 1820 usec] --- is ready
%T2R-S-LSNRRDY, [#3] Listener 0.0.0.0:502 [Target: </dev/rs485-1>] --- is readyКлюч /trace печатает каждый запрос и ответ в hex. Остановка по Ctrl+C.
Сервис systemd
Файл /etc/systemd/system/mbusgw-t2r.service.
[Unit]
Description=Modbus TCP to RTU gateway (mbusgw-t2r)
After=network.target dev-rs485\x2d1.device dev-rs485\x2d2.device dev-rs485\x2d3.device dev-rs485\x2d4.device
Wants=dev-rs485\x2d1.device dev-rs485\x2d2.device dev-rs485\x2d3.device dev-rs485\x2d4.device
[Service]
Type=simple
ExecStart=/opt/mbusgw-t2r/sbin/mbusgw-t2r /settings=/opt/mbusgw-t2r/etc/modbus-t2r_settings.conf
KillSignal=SIGTERM
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetПоследовательность \x2d — это дефис в именах юнитов (dev-rs485\x2d1.device соответствует /dev/rs485-1). Благодаря ей сервис при загрузке ждет появления портов.
systemctl daemon-reload
systemctl enable --now mbusgw-t2r
journalctl -u mbusgw-t2r -n 30 # DEVREADY / LSNRRDY x 4
ss -tlnp | grep mbusgw # слушает 502..505Трассировку у работающего сервиса можно включить без перезапуска командой kill -USR1 $(pidof mbusgw-t2r) (повторный USR1 выключает) и смотреть в journalctl -u mbusgw-t2r -f.
Тестируем систему на стенде
Шаг 1. Каждый канал по сети
С любой машины, где есть mbpoll.
for p in 502 503 504 505; do
echo "== TCP $p"
mbpoll -m tcp -a 1 -r 769 -c 2 -t 4 -1 -p $p IP_ПЛАТЫ
doneОжидается по два числа на порт, например [769]: 236 (23,6 °C) и [770]: 343 (34,3 %). Для Modbus TCP скорость и четность не указываются, их задает шлюз.
Шаг 2. Тот ли датчик на том ли порту
Датчик нужно нагреть рукой или подышать на него. Значение должно измениться только на ожидаемом TCP-порту. Так проверяется вся цепочка от канала FT4232H через /dev/rs485-N до TCP-порта. Тест простой, но именно он ловит перепутанные ttyUSB.
Шаг 3. Нагрузочный тест с контролем содержимого
Скрипт tests/stress_kth2r.py из репозитория шлюза (python3, без сторонних библиотек). Он читает все документированные регистры несколькими клиентами на каждом порту одновременно. Постоянные регистры (модель, ПО, имя, адрес, скорость) сверяются с первым чтением, так ловятся испорченные или перепутанные между клиентами ответы.
python3 stress_kth2r.py IP_ПЛАТЫ # 2 клиента x 4 порта, 60 с
python3 stress_kth2r.py IP_ПЛАТЫ --clients 4 --duration 600 # 10 минут, 16 клиентов
python3 stress_kth2r.py IP_ПЛАТЫ --ports 503 --interval 1 # один порт, раз в секундуНачало вывода.
port 502: model 0x0001, fw 0x0120, name "KTH2-R", addr 1, speed code 3, parity code 0, T 23.6 C, RH 34.3 %
...
port cycles errors cyc/s p50 ms p95 ms ...
502 66 0 2.0193.0218.0 ...Значение errors должно быть равно 0, иначе печатается разбор по видам ошибок. Итог выводится строкой RESULT: PASS или RESULT: FAIL.
Время запроса, измеренное с другой машины, в основном складывается из сети. У нас до стенда RTT около 160 мс, поэтому там около 186 мс на запрос. Чистое время шлюза и линии приведено в шаге 5.
Длинный тест заодно проверяет плату. Сообщения ядра лучше снимать на другую машину (ssh root@IP_ПЛАТЫ 'dmesg -w -T' > kmsg.log), потому что при зависании на самой плате они пропадут.
Шаг 4. Отказоустойчивость
Действие | Ожидается |
отключить датчик от линии | на этом порту исключение после iotmo, в журнале NOANSWER, остальные порты работают |
подключить обратно | порт сразу снова отвечает, перезапуск не нужен |
systemctl restart mbusgw-t2r во время теста | клиенты переподключаются, данные идут дальше |
перезагрузить плату | сервис поднимается сам, /dev/rs485-N на месте, latency_timer равен 1 |
Шаг 5. Производительность
Шлюз X.00-13ECO02, 4 датчика KTH2-R. Скрипт stress_kth2r.py запускался по сети, N клиентов на каждый из 4 портов, по 60 с на шаг. Цикл состоит из 3 запросов (10 регистров). Ошибок во всех шагах 0.
Скорость, бод | Клиентов на порт | Запросов/с на порт | На все устройство | Один запрос на линии |
9600 | 1 / 2 / 4 | 5 / 11 / 21 (упор в сеть) | — | — |
9600 | 8 / 12 | 33 / 33 | 132 | около 30 мс |
19200 | 8 | 44 | 176 | — |
19200 | 12 / 15 | 59 / 59 | 236 | около 17 мс |
• Потолок задают линия RS-485 и датчик, а не шлюз. Скорость 19200 дает прирост в 1,8 раза. Ровно вдвое не получается, потому что время ответа датчика от скорости не зависит.
• Порты друг другу не мешают. Восемь клиентов на одном порту дают столько же, сколько на каждом из четырех одновременно.
• При перегрузке клиенты просто ждут дольше, ошибок и перепутанных ответов нет.
Задержка одного клиента на самой плате (tests/robust_gw.py, без сети), 19200 бод, 300 запросов по 2 регистра на каждый порт.
Порт | p50 | p95 | p99 | max |
502–505 | 16,0 мс | 17,1–17,5 мс | 18,0–19,7 мс | 19,1–21,2 мс |
Сама передача на 19200 (8 байт запроса, 9 байт ответа и пауза t3.5) занимает около 10,9 мс. Остальные примерно 5 мс приходятся на ответ датчика и шлюз вместе. Длинных хвостов нет, p99 всего на 2–3 мс выше медианы.
Шаг 6. Надежность шлюза
Скрипт tests/robust_gw.py запускается на самой плате. Он только читает (функция 3) и отправляет намеренно испорченные запросы, в датчики ничего не пишет.
python3 robust_gw.py # ~2 мин, из них 60 с - проверка idletmo
python3 robust_gw.py --skip-idle --baud 9600Результат для версии X.00-13ECO02 на 19200 бод — RESULT: PASS.
Проверка | Результат |
10 запросов подряд в одном соединении | все 10 ответов по порядку, номера транзакций совпадают |
мусор, обрезанный заголовок, длина 0 и 300, protocol id = 1, 2 КБ нулей | шлюз жив, следующий клиент обслужен сразу |
неизвестная функция 0x44, адрес 0x9999, 126 регистров | исключения датчика C4/01, 83/02, 83/02 |
нет датчика с адресом 77 | исключение 4 через 1,01 с (iotmo), другой клиент порта ждал не больше 0,98 с |
1200 сессий «подключился, прочитал, отключился» за 6 с, все порты сразу | 0 ошибок |
24 клиента при connlm = 16 | 16 обслужены, 8 отклонены, после закрытия порт снова свободен |
молчащий клиент при idletmo = 60 | отключен через 63 с |
ресурсы шлюза до и после | потоки 2 → 2, дескрипторы 11 → 11, память 1880 → 1880 КБ, перезапусков нет |
Шаг 7. Длительный прогон
Два клиента на каждый из четырех портов, 9600 бод, 2 ч 40 мин подряд. Выполнено 131 000 циклов, это около 394 000 запросов. Ошибок 1109 (0,8 %), и все они совпали с известными событиями. Это перезапуски шлюза при обновлении версии на стенде и обрывы сети между тестовой машиной и стендом. Ошибок, которые можно было бы отнести на шлюз или на линию RS-485, не было. Через 5,5 ч после завершения всех тестов у шлюза остались те же 1880 КБ памяти, 2 потока и 11 дескрипторов, что и до них.
Следующий этап — прогон на сутки и дольше.
Найденные типичные ошибки и диагностика
В журнале или симптом | Что значит | Что делать |
CRC16ERR, BADFRAME, а mbpoll напрямую работает | ответ режется на куски | latency_timer не равен 1, раздел 5 |
DEVOPNERR ... errno: 2 | нет такого порта | нет симлинка /dev/rs485-N, раздел 5 |
DEVOPNERR ... errno: 16 | порт занят | fuser -v /dev/rs485-1, скорее всего висит mbpoll или screen |
LSNRERR ... errno: 98 | TCP-порт занят | уже запущен второй экземпляр, ss -tlnp \| grep 50 |
NOANSWER | датчик молчит | проверить адрес (-a), A/B, питание датчика, скорость |
клиент получает исключение 4 | ошибка на стороне RS-485 | смотреть предыдущую строку журнала, там причина |
нет /dev/ttyUSB* после ftdi_eeprom | libftdi отсоединила драйвер | переподключить USB |
/dev/rs485-N нет, а ttyUSB есть | правило не сработало | udevadm test $(udevadm info -q path -n /dev/ttyUSB0) \| grep -E "ID_\|SYMLINK" |
таймаут на всех каналах | перепутаны A/B, нет питания датчиков или не та скорость | проверить по порядку |
ответ равен копии запроса плюс ответ | эхо | раздел 9 |
Чем mbusgw-t2r отличается от mbusd
mbusd — самый известный открытый шлюз Modbus TCP→RTU, мы его применяем для шлюзов с одним портом. Он собирается с github () или можно скачать готовый пакет, подключив репозиторий repo.nzpilab.ru. Но он не поддерживает режим с множеством портов, плюс в процессе тестов мы заметили места, которые можно оптимизировать. .
mbusgw-t2r | mbusd | |
Портов RS-485 на процесс | сколько угодно, один процесс и один конфиг, каждый порт на свой TCP-порт | один, на 4 канала нужны 4 экземпляра (mbusd@.service) с 4 конфигами |
Несколько TCP-клиентов | да, очередь на линию, лимит connlm на TCP-порт | да, лимит -C |
Отключение молчащих клиентов | idletmo, в секундах | -T, в секундах |
Ожидание ответа датчика | iotmo, в мс, отдельно на каждый порт | -W (по умолчанию 500 мс) |
Повтор запроса при ошибке | нет, клиент сразу получает исключение 4 и сам решает | да, -N (по умолчанию 3) |
Пауза между запросами на линии | только t3.5 по стандарту (1,8 мс на 19200) | -R, по умолчанию 100 мс |
Конец ответа RTU | по ожидаемой длине для функций 01–06, 15, 16, 23, иначе по t3.5 | в документации не описано |
Направление RS-485 | через ядро (TIOCSRS485) | через ядро, RTS или GPIO в sysfs |
Эхо собственного запроса | не обрабатывается | отбрасывает (-e) |
Трассировка обмена | на ходу, kill -USR1, без перезапуска | уровнем журнала при запуске |
Сообщения в журнале | коды %T2R-x-CODE со справочником причин и действий | текст |
Лицензия | Apache-2.0 | BSD-3-Clause |
Для FT4232H разница в управлении направлением и в эхе не играет роли, потому что направлением управляет сам чип. Важное на практике — четыре канала в одном сервисе, отсутствие повторов и 100-мс пауз (быстрее отклик, выше потолок опроса). Обратная сторона в том, что повторы при сбоях линии становятся заботой SCADA.
Что делать с данными дальше ? Складируем в базу через Telegraf
Шлюз отдает датчики как Modbus TCP, а дальше их удобно забирать Telegraf. Все четыре датчика KTH2-R имеют адрес 1, и различаются они только TCP-портом. Конфиг на один датчик выглядит так.
[[inputs.modbus]]
name = "KTH2-R-1"
slave_id = 1
timeout = "2s"
controller = "tcp://127.0.0.1:502"
holding_registers = [
{ measurement="KTH2-R-1", name="Temp", byte_order="AB", data_type="INT16", scale=0.1, address=[768] },
{ measurement="KTH2-R-1", name="Humidity", byte_order="AB", data_type="UINT16", scale=0.1, address=[769] },
]Для остальных линий меняются порт (503, 504, 505) и имя измерения. Тайм-аут Telegraf стоит делать больше, чем iotmo шлюза. Тогда при молчащем датчике Telegraf дождется исключения 4 от шлюза и запишет понятную ошибку, а не оборвет соединение раньше времени. Адреса в конфиге Telegraf считаются с нуля, поэтому регистры 769 и 770 из таблицы раздела 8 превращаются в 768 и 769.
Если данные нужно складывать в InfluxDB и смотреть в Grafana, на NAPI-C с 512 МБ памяти лучше держать только шлюз и Telegraf, а базу и визуализацию вынести на другую машину. Весь стек на одной плате мы разбирали на RK3568 в статье про мониторинг iPDU.
Выводы
1. Связка NAPI-C, FT4232H и mbusgw-t2r дает четыре линии RS-485 на одном IP с запасом по производительности. Это 59 запросов в секунду на линию при 19200 бод и 236 на устройство.
2. Две настройки, без которых устройство не будет работать в поле, не видны при ручной проверке. Это latency_timer=1 и аппаратный watchdog. Обе найдены тестами, а не чтением документации.
3. Постоянные имена портов по номеру канала — не косметика. Без них перепутанные ttyUSB после перезагрузки ведут к тихой подмене данных.
4. Длительный прогон с контролем содержимого ответов — единственный способ убедиться, что ответы не теряются и не путаются между клиентами. Тест на сутки и дольше остается следующим этапом.
5. По рынку востребованность умеренная и проектная. Пустая зона между серийными шлюзами за 15–21 тысячу рублей за порт и дешевыми китайскими модулями есть, но для выхода за пределы ниши нужны корпус под DIN-рейку, развязка линий и длительные испытания.
Шлюз mbusgw-t2r открыт под лицензией Apache 2.0, исходники по ссылке https://github.com/napilab/mbus-gw-t2r. Скрипты тестов (bench.py, stress_kth2r.py, robust_gw.py) лежат там же, их можно использовать для любого шлюза Modbus TCP→RTU, не только для нашего.
По вопросам NAPI, NapiLinux и сборки таких устройств под конкретную задачу пишите мне на napi@nnz.ru.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.