
Мне понадобился последовательный порт, которого не было физически. Точнее — порт был нужен софту, который я писал и тестировал, а паять переходник и держать на столе плату ради пары проверок не хотелось. Так появился vseriald — небольшой демон для Linux и WSL2, который создает виртуальный последовательный порт /dev/ttyV0 и связывает его с сетью или другим процессом. Приложение работает с ним как с обычным /dev/ttyUSB0: открывает, настраивает скорость через stty, читает и пишет. А с другой стороны порт «выходит» в TCP, UDP, именованный канал или очередь сообщений.
Особенно это выручает в WSL2. WSL2 — это легкая виртуальная машина, и последовательных портов в ней попросту нет: ни физических COM-портов с Windows-хоста, ни /dev/ttyS*, ни /dev/ttyUSB* сами по себе не появляются. Единственный способ получить там настоящий порт — «прокинуть» USB-переходник из Windows через USB/IP (утилитой usbipd-win); только тогда внутри WSL2 возникнет /dev/ttyUSB0. Если переходника под рукой нет или возиться с пробросом не хочется, виртуальный порт — это вообще единственный способ дать своему софту «последовательный порт» внутри WSL2.
В этой статье я разберу несколько мест, которые оказались интереснее, чем я ожидал: как псевдотерминал устроил мне 100% загрузки одного ядра на ровном месте, как в однопоточном цикле событий притормозить источник данных, как эмулировать полудуплексный RS485 с его коллизиями, и как из одного интерфейса транспорта вырастают шесть способов «вывести» порт наружу. Будет код из реального проекта и грабли, на которые я наступил.
Исходники открыты (читать, форкать, заводить задачи): https://gitlab.com/trgv/vspd.
Это первая из двух статей. Здесь — про внутренности самого демона; во второй, «У меня работает»: десять способов узнать, что нет, — про дорогу к релизу: как первый же запуск CI на «полностью готовом и зеленом локально» проекте вскрыл десяток скрытых проблем.
Договоримся о терминах
Чтобы дальше не спотыкаться, коротко проясню термины.
Последовательный порт — интерфейс, который передает данные по одному биту, байт за байтом. Классический разъем COM, USB-переходники /dev/ttyUSB0, отладочные консоли плат — все это он.
RS232 / RS422 / RS485 — это электрические стандарты для такого порта. RS232 и RS422 — полнодуплексные: можно одновременно и принимать, и передавать. RS485 — полудуплексный: по одной паре проводов в каждый момент времени идет либо передача, либо прием, и узлы должны вовремя «отпускать» линию. Именно из-за этого RS485 интереснее всего эмулировать.
PTY (псевдотерминал) — пара виртуальных устройств в ядре: «мастер» и «слейв». Все, что пишут в мастер, читается из слейва, и наоборот. Слейв выглядит как настоящий терминал (/dev/pts/N) — у него есть termios, скорость, режимы. Это и есть основа фокуса: слейв я отдаю приложению под видом последовательного порта, а мастер держу у себя в демоне.
epoll — механизм Linux, который позволяет одному потоку ждать события сразу на множестве дескрипторов и просыпаться только тогда, когда есть что делать.
Что в итоге умеет демон
-
Создает псевдотерминал и публикует слейв как
/dev/ttyV0(для нескольких портов —ttyV1,ttyV2, …). -
Поддерживает обычную работу с портом:
open/read/write/close,select/poll/epoll,termios/stty, «сырой» и канонический режимы, блокирующий и неблокирующий ввод-вывод. -
Связывает порт с TCP-сервером, TCP-клиентом, UDP, именованным каналом (FIFO), а также с очередями сообщений POSIX и System V.
-
Эмулирует RS232, RS422 и RS485 — последний с конечным автоматом переключения направления и таймингами.
-
Интегрируется с systemd, сбрасывает привилегии после старта, имеет ограниченные буферы с настраиваемой политикой при переполнении.
Архитектурно это демон в пространстве пользователя, один поток, без блокировок и отдельных рабочих потоков. Весь ввод-вывод крутится в одном цикле epoll. Дескрипторами владеют объекты по принципу RAII: закрылся объект — закрылся и дескриптор.
«А почему не socat и просто /dev/pts?»
Честный вопрос, и я его сам себе задавал. Для простого моста «PTY ↔ TCP» действительно хватает одной строки:
socat PTY,link=/dev/ttyV0,raw TCP:127.0.0.1:7000
Если задача ровно такая — берите socat, это прекрасный инструмент, и городить демон незачем. Но я уперся в несколько вещей, которых у socat и голого /dev/pts/N нет:
-
Эмуляция RS485. socat — это «труба» для байтов. Он ничего не знает про полудуплекс: что узел не может принимать, пока передает; про задержки включения драйвера, удержания и разворота линии; про коллизии. А мне нужно было тестировать софт, который рассчитывает именно на это поведение. Это и есть главная причина — ее одной хватило бы.
-
Стабильный порт, который переживает клиентов. Голый PTY-мост норовит «умереть» или закрутиться вхолостую, когда клиент закрывает свой конец (тот самый
EPOLLHUPиз следующего раздела). Демон держит/dev/ttyV0на месте: клиенты подключаются и отключаются, порт остается. -
Ограниченные буферы с выбором политики при переполнении — в том числе притормаживание источника. Чтобы детерминированно воспроизводить сценарий «медленный получатель», нужен именно выбор поведения, а не «как сложится».
-
Несколько транспортов и портов одним конфигом. TCP-сервер/клиент, UDP, FIFO, очереди POSIX и System V — каждый со своим line-режимом, буферами и правами, описанные в одном YAML и запущенные как одна служба. Это можно собрать из россыпи socat-строк и обвязки, но в какой-то момент ловишь себя на том, что переписываешь демон, только хуже.
-
Эксплуатационная обвязка из коробки. Юнит systemd с усиленной защитой, сброс привилегий, владелец и режим устройства, управляющий сокет со счетчиками (коллизии, переподключения, отброшенные байты), логи в journald, hex-дамп для диагностики. socat — отличная утилита, но это утилита; превратить парк ее запусков в управляемую, наблюдаемую и защищенную службу — это уже отдельный проект.
Иногда в таких обсуждениях вспоминают еще пару инструментов — com0com и tty0tty. Это «виртуальный нуль-модем»: они создают пару связанных портов (например, /dev/tnt0 ↔ /dev/tnt1), чтобы соединить два локальных приложения «крест-накрест». Разница в задаче:
-
com0com — это драйвер для Windows; на Linux/WSL2 он неприменим (его уместно вспоминать как аналог на стороне Windows, но это другая ОС).
-
tty0tty — линуксовый нуль-модем, и тут честно: его модульная версия умеет то, чего у меня нет, — зеркалит линии модемного управления (RTS/CTS, DTR/DSR) между парой портов. Если вам нужны именно «дерганья» модемных сигналов между двумя локальными программами — это к tty0tty, а не ко мне.
Но у обоих задача — loopback-пара для двух локальных программ. Они не выводят порт наружу (в TCP/UDP/FIFO/очередь) и не моделируют RS485 — полудуплекс, тайминги, коллизии. Это решение для «соединить две программы между собой», а не для «дать программе порт, который ведет себя как RS485-линия и/или ходит в сеть».
Если коротко: socat — это «быстро пробросить байты», нуль-модемы (tty0tty/com0com) — «соединить две локальные программы парой связанных виртуальных портов», а vseriald — «эмулировать поведение последовательной линии (особенно полудуплексного RS485) и гонять это как настоящую службу с буферами, метриками и таймингами». Ну и, честно говоря, отчасти это был хороший повод разобраться с псевдотерминалами и epoll всерьез — но потребность в RS485 была настоящей.
Цикл событий: один поток и управление подпиской
Раз поток один, все держится на epoll. Обертка над ним нарочно минимальная: добавить дескриптор с колбэком, сменить набор отслеживаемых событий, убрать.
bool Add(int fd, Callback callback); // по умолчанию EPOLLINbool Add(int fd, Callback callback, std::uint32_t events); // явный набор событийbool Modify(int fd, std::uint32_t events); // сменить подписку
Самое важное здесь — Modify. Запись в неблокирующий сокет может принять не все байты сразу. Если я подпишусь на «сокет готов к записи» (EPOLLOUT) и буду держать эту подписку всегда, цикл будет просыпаться вхолостую каждый раз, когда сокет свободен, — а свободен он почти всегда. Поэтому EPOLLOUT я добавляю в подписку только когда есть что писать, и убираю, как только исходящий буфер опустел:
void TxQueue::UpdateInterest() { const bool want_out = !buffer_.Empty(); if (want_out == out_armed_) { return; // ничего не изменилось — не дергаем epoll } const std::uint32_t events = base_events_ | (want_out ? static_cast<std::uint32_t>(EPOLLOUT) : 0U); loop_->Modify(fd_, events); out_armed_ = want_out;}
Этот же прием «снять/вернуть событие» дальше окажется ключевым сразу в двух местах: в подавлении залипшего EPOLLHUP у псевдотерминала и в притормаживании источника данных.
Нюанс 1. Псевдотерминал и коварный EPOLLHUP
Самый первый «эффект на ровном месте» я получил, как только зарегистрировал дескриптор мастера в цикле epoll.
Идея простая: мастер — это файловый дескриптор, я добавляю его в epoll на чтение (EPOLLIN), и когда приложение пишет в /dev/ttyV0, цикл просыпается, я читаю байты и отправляю их в транспорт. Работает. Ровно до того момента, пока приложение не закроет свой конец порта.
Дело в том, что у псевдотерминала есть неприятная особенность. Пока слейв никем не открыт (или его только что закрыл последний читатель), мастер начинает сообщать о «разрыве» — событие EPOLLHUP, — а попытки чтения возвращают ошибку EIO. И вот тут важное: цикл я сделал level-triggered, то есть «по уровню». Это значит, что epoll будет будить меня снова и снова, пока условие сохраняется. А условие EPOLLHUP после закрытия слейва сохраняется постоянно. Результат — бесконечный цикл пробуждений и 100% загрузки одного ядра. Демон вроде бы ничего не делает, а ядро «раскалено».
Прежде чем чинить, я этот эффект воспроизвел «зондом»: открыл мастер, поставил poll() на короткий таймаут и посмотрел, что возвращается до и после закрытия слейва. До — пусто (как и ожидалось от простаивающего порта), после — постоянный POLLHUP на каждый вызов. Стало понятно: дело не в моем коде пересылки байтов, а в самом состоянии псевдотерминала.
Лечится это неочевидно, но красиво: демон сам держит один дескриптор слейва открытым на все время жизни порта — и никогда из него не читает. Пока слейв открыт хоть кем-то, мастер не считает линию «разорванной» и EPOLLHUP не выставляет. А поскольку этот служебный дескриптор я не читаю, он не «съедает» байты, предназначенные настоящему клиенту.
Вот как это задокументировано прямо в коде — я специально оставил подробный комментарий, потому что без него фокус выглядит как «зачем открыл слейв и забыл про него»:
// To keep the master usable with epoll, the object also holds one slave// descriptor open for the lifetime of the PTY (a "keepalive" handle that// is never read from). Without it, the master reports a persistent// EPOLLHUP and returns EIO whenever the client closes the slave, which// would busy-spin a level-triggered event loop. Holding a slave open// suppresses the hangup; because the keepalive handle is never read, it// does not consume any bytes destined for the real client.
Баг из тех, что легко вернуть случайным рефакторингом, — поэтому я закрыл его регрессионным тестом, который воспроизводит именно ту ситуацию: клиент открыл слейв, обменялся байтом, закрыл — и мастер после этого не должен без конца сигналить разрывом.
TEST(Bug0001PtyMasterKeepalive, NoHangupAfterClientClosesSlave) { PtyMaster master; ASSERT_TRUE(master.Open({})); const int slave = ::open(master.SlavePath().c_str(), O_RDWR | O_NOCTTY); ASSERT_GE(slave, 0); // ...обмен байтом опущен... ASSERT_EQ(::close(slave), 0); // Регрессия: keepalive-слейв должен подавить «разрыв». Без него мастер // вечно отдавал бы POLLHUP (busy-spin) и читал бы EIO/EOF. EXPECT_FALSE(ReportsHangup(master.Fd(), kQuiescentPollMs)); std::array<char, 8> drain{}; const ssize_t n = master.Read(drain.data(), drain.size()); EXPECT_EQ(n, -1); EXPECT_TRUE(errno == EAGAIN || errno == EWOULDBLOCK);}
Я специально проверил, что тест «настоящий»: если убрать удерживающий дескриптор, он краснеет. Тест, который не падает без фикса, бесполезен.
Вывод по нюансу. Level-triggered epoll удобен и прощает многое, но он безжалостно усиливает «залипшие» условия вроде EPOLLHUP. Прежде чем регистрировать дескриптор в цикле, стоит спросить себя: какое событие на нем может стать постоянным — и не закрутит ли оно цикл вхолостую.
Нюанс 2. Как притормозить источник в однопоточном цикле
Раз поток один, у меня нет роскоши «заблокироваться на записи в сокет и подождать». Если получатель (например, медленный TCP-клиент) не успевает забирать данные, а приложение продолжает сыпать байты в /dev/ttyV0, их надо где-то держать. Я держу их в ограниченном кольцевом буфере и, как только в буфере что-то появилось, добавляю в подписку EPOLLOUT (тот самый UpdateInterest из начала статьи); как только буфер опустел — убираю.
Но буфер ограничен намеренно. Что делать, когда он полон? Я сделал несколько политик: отбрасывать самые старые данные, отбрасывать новые, рвать соединение — и более интересную, block. Идея block: вместо того чтобы терять байты, демон перестает читать источник, пока получатель не разгребет очередь. То есть медленный потребитель тормозит производителя, и ничего не теряется. В англоязычной литературе это называют backpressure; дальше я буду говорить «притормаживание источника».
Тонкость в том, что «перестать читать источник» в однопоточном epoll-цикле — это не блокировка, а снятие подписки на чтение. Для порта-источника я просто убираю EPOLLIN: цикл перестает будить меня на «есть что прочитать из PTY», и приложение, продолжая писать, само упирается в заполненный буфер ядра и притормаживает. Когда исходящая очередь опустеет — возвращаю EPOLLIN.
Сама логика «когда тормозить, когда отпускать» живет в очереди передачи:
void TxQueue::UpdateBackpressure() { if (policy_ != OverflowPolicy::kBlock || !pause_) { return; } if (!paused_ && !buffer_.Empty()) { // пришлось буферизовать — получатель не успевает paused_ = true; pause_(); // притормозить источник } else if (paused_ && buffer_.Empty()) { // очередь разошлась paused_ = false; if (resume_) { resume_(); // снова читать источник } }}
А «притормозить» и «отпустить» для конкретного порта — это и есть игра с подпиской на чтение PTY:
// pause: снять EPOLLIN с мастера PTY — перестаем вычитывать из приложенияtransport_->SetTxBackpressure( [this] { pty_tx_->SetBaseEvents(0); }, [this] { pty_tx_->SetBaseEvents(EPOLLIN); });
Отдельная грабля здесь: раз я в момент «паузы» снимаю с дескриптора PTY вообще все базовые события, обработчик чтения нужно явно защитить — читать только тогда, когда реально пришел EPOLLIN, а не на любое пробуждение. Иначе в фазе паузы можно снова случайно прочитать байт, который я как раз и не хотел читать. Мелочь, но именно из таких мелочей складывается «то лишний байт потерялся, то порядок поехал».
Проверял я это интеграционным тестом с заведомо медленным получателем: поднимается TCP-сервер, клиент специально не читает, приложение пишет мегабайты в порт, упирается в EAGAIN, затем клиент начинает читать — и в конце все байты доходят в исходном порядке, ни один не потерян.
Вывод по нюансу. В однопоточной модели «притормаживание источника» — это не про блокировки и не про сон потока, а про управление подпиской на события. Снял EPOLLIN — и производитель сам замедлится через буфер ядра. Главное не забыть симметрично его вернуть.
Нюанс 3. RS485 как конечный автомат с таймингами
RS232 и RS422 в эмуляции скучные: они полнодуплексные, поэтому line-эмулятор для них — прозрачный «проводок», который просто перекладывает байты в обе стороны.
А вот RS485 — полудуплекс. По общей паре проводов в каждый момент времени работает кто-то один. Реальный передатчик: поднимает сигнал «драйвер включен», ждет небольшую задержку, передает кадр, держит линию еще чуть-чуть после последнего байта, потом отпускает драйвер и выжидает «время разворота», прежде чем снова слушать. Пока он передает — он не принимает. И если в это время по линии прилетает чужой байт — это коллизия.
Я смоделировал это конечным автоматом с пятью состояниями и таймером (timerfd). Состояния: kRxIdle (слушаем) → kTxEnable (драйвер включен, ждем задержку) → kTxActive (передаем) → kTxDrain (удержание после последнего байта) → kTurnaround (разворот линии) → снова kRxIdle.
Переходы по таймеру читаются почти как описание из даташита:
void Rs485HalfDuplexLine::OnTimer() { // ...сначала «вычитываем» сработавший timerfd... switch (state_) { case Rs485State::kTxEnable: { // задержка включения прошла: отдаем накопленные байты в линию state_ = Rs485State::kTxActive; const std::vector<std::uint8_t> held = std::move(tx_pending_); tx_pending_.clear(); TransmitActive(held.data(), held.size()); break; } case Rs485State::kTxActive: state_ = Rs485State::kTxDrain; ArmWindow(params_.tx_tail_delay_us); break; case Rs485State::kTxDrain: state_ = Rs485State::kTurnaround; ArmWindow(params_.turnaround_delay_us); break; case Rs485State::kTurnaround: state_ = Rs485State::kRxIdle; // драйвер отпущен, снова слушаем FlushBufferedRx(); break; case Rs485State::kRxIdle: break; // ложное срабатывание — игнорируем }}
Самое содержательное — что делать с входящим байтом, который пришел, пока линия передает. Это коллизия, и поведение я вынес в политику. Вариантов четыре, и они прямо перечислены в перечислении (enum):
enum class CollisionPolicy : std::uint8_t { kDropRxWhileTx, // отбросить пришедшие во время передачи байты kBufferRxWhileTx, // придержать и отдать, когда линия освободится kMarkCollision, // доставить, но пометить коллизию kDisconnectOnCollision, // отбросить байты и оборвать соединение};
Любая из политик считает коллизию в статистике, и эту статистику видно «снаружи» — у демона есть управляющий сокет, через который можно спросить счетчики порта: сколько было коллизий, сколько циклов передачи, в каком состоянии линия сейчас. Это оказалось полезно при отладке: видишь, что коллизии растут, — значит, тайминги разворота заданы слишком оптимистично.
Отдельная тонкость, которую легко пропустить: «время передачи кадра» зависит от скорости и формата (биты данных, четность, стоп-биты). Псевдотерминал не тактирует байты сам — он отдает все мгновенно. Поэтому, чтобы эмуляция RS485 была похожа на правду, длительность фазы kTxActive я считаю из параметров кадра, а не беру «на глаз». Иначе тайминги разворота линии не имеют смысла.
Вывод по нюансу. Когда железо по своей природе — это автомат состояний с задержками, не нужно бояться так его и записать. Явный конечный автомат + один таймер читается лучше, чем россыпь флагов и if-ов, и его проще покрыть тестами: на каждый переход — свой кейс.
Транспорты под одним интерфейсом
Порт надо куда-то «вывести», и способов я сделал шесть: TCP-сервер, TCP-клиент, UDP, FIFO, очереди POSIX и System V. Чтобы не плодить шесть почти одинаковых веток, все спрятано за одним интерфейсом:
class ITransport { public: using DataHandler = std::function<void(const std::uint8_t* data, std::size_t len)>; virtual bool Start(core::EventLoop& loop, DataHandler on_data) = 0; virtual void Stop() = 0; virtual ssize_t Send(const std::uint8_t* data, std::size_t len) = 0; virtual bool IsConnected() const = 0; // ...плюс необязательные хуки: Disconnect(), SetTxBackpressure(), Stats()...};
Транспорт сам владеет своими дескрипторами и сам регистрирует их в цикле событий — порт не знает, что у него «под капотом», TCP или очередь сообщений. Это удобно: например, притормаживание источника из второго нюанса включается одним и тем же вызовом SetTxBackpressure для всех транспортов, у которых есть исходящая очередь.
Нюанс реализации: TCP-транспорты используют ту самую очередь с EPOLLOUT и умеют притормаживать источник, а очереди сообщений и UDP устроены иначе — у них своя модель «готовности», и для них политика block мягко вырождается в «отбрасывать старое». Единый интерфейс не обязан означать единое поведение во всех деталях — он обязан давать единую точку подключения.
Защитные мелочи, которые легко проглядеть
Демон создает /dev/ttyV0 как символьную ссылку на свежий /dev/pts/N. И тут есть ровно один способ выстрелить пользователю в ногу: если по этому пути уже лежит что-то не ссылка — например, реальный файл или устройство, — затирать это нельзя ни в коем случае. Поэтому SymlinkManager сначала смотрит, что там лежит, и отказывается работать, если это не символьная ссылка:
if (::lstat(link_path.c_str(), &st) == 0) { if (!S_ISLNK(st.st_mode)) { spdlog::error("symlink: refusing to overwrite non-symlink path {}", link_path); errno = EEXIST; return false; // не трогаем реальный файл/устройство } ::unlink(link_path.c_str()); // снять можно только устаревшую ссылку}
Вторая мелочь — привилегии. Чтобы создать узел в /dev и при необходимости сменить владельца устройства, демон может стартовать под root. Но дальше он сбрасывает права до указанного пользователя — и делает это в правильном порядке: сначала группа, потом пользователь (после смены uid сменить gid уже не получится), а в конце проверяет, что сброс действительно состоялся и его нельзя откатить.
// Группа раньше пользователя: после смены uid поменять gid уже нельзя.if (::setgroups(1, &gid) != 0) { /* ошибка */ }if (::setresgid(gid, gid, gid) != 0) { /* ошибка */ }if (::setresuid(uid, uid, uid) != 0) { /* ошибка */ }// ...и убеждаемся, что getuid/geteuid/getgid/getegid теперь равны целевым.
Ни то, ни другое не «фича из README», но именно такие мелочи отличают «работает у меня» от «не испортит чужую систему».
Один демон — несколько портов
Портов в одном демоне может быть несколько, и описываются они в YAML. Минимальный конфиг для RS485 поверх TCP-сервера выглядит так:
ports: - name: ttyV0 symlink: /dev/ttyV0 mode: "0660" owner: root group: dialout pty: raw: true echo: false line: mode: rs485 baud: 115200 data_bits: 8 parity: none stop_bits: 1 transport: type: tcp_server bind: 127.0.0.1 port: 7000 buffering: tx_buffer_size: 1048576 overflow_policy: block
Каждый порт — это своя связка «PTY ↔ line-эмулятор ↔ транспорт», но все они крутятся в одном и том же цикле событий. Никаких потоков на порт: добавление второго порта — это просто еще несколько дескрипторов в epoll.
Чем все это проверялось
Тесты разложены по назначению: модульные (на чистую логику вроде кольцевого буфера и таймингов), интеграционные (на реальных примитивах — PTY, сокеты, FIFO, очереди, сигналы) и регрессионные (по одному на каждый пойманный баг, как тот самый keepalive). Поверх — прогон под valgrind для проверки памяти и санитайзеры. Каждый интеграционный и регрессионный тест ограничен таймаутом, чтобы зависший примитив не вешал сборку.
Отдельно я профилировал «горячий путь» — то, через что проходит каждый байт: кольцевой буфер и line-эмуляторы. Для этого есть маленький бенчмарк-прогон под callgrind, без сокетов и сна, чтобы профиль показывал именно мой код, а не системные вызовы. Это полезно как страховка: видно, не появилось ли в перекладывании байтов чего-то неожиданно дорогого.
Итоги
vseriald оказался отличным поводом вспомнить, что пространство пользователя в Linux полно острых углов: псевдотерминал, который без конца сигналит разрывом; однопоточный цикл, где притормаживание — это управление подпиской на события; полудуплекс, который честнее всего описывается автоматом состояний; и единый интерфейс транспорта, за которым прячется шесть разных способов общения. Ни один из этих углов не виден из README — они вылезают ровно тогда, когда садишься писать код и гонять его на настоящих примитивах.
Во второй статье — «У меня работает»: десять способов узнать, что нет — расскажу совсем про другое и не менее поучительное: как первый же запуск CI на «полностью готовом и зеленом локально» проекте вскрыл десяток скрытых проблем — от версии CMake на Ubuntu 22.04 до недоступного из сети реестра контейнеров.
Исходники и документация: https://gitlab.com/trgv/vspd. “На вентилятор” накидываем в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/1044386/