COM-порт из ничего: PTY, epoll и немного RS485-боли

от автора

Мне понадобился последовательный порт, которого не было физически. Точнее — порт был нужен софту, который я писал и тестировал, а паять переходник и держать на столе плату ради пары проверок не хотелось. Так появился 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.

RS485 как конечный автомат

RS485 как конечный автомат

Переходы по таймеру читаются почти как описание из даташита:

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/