Подводные камни приложений последовательной связи: переподключение и логи

· Обновлено: · · Последовательная связь, RS-232, C#, .NET, Разработка Windows, Интеграция с оборудованием

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619754)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Подводные камни приложений последовательной связи: переподключение и логи. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619754 https://comcomponent.com/ru/blog/2026/03/19/001-serial-communication-app-pitfalls/

DOI (последняя версия)
10.5281/zenodo.21619754
DOI (эта версия)
10.5281/zenodo.21619755

Интеграция с оборудованием, измерительные приборы, ПЛК, сканеры штрихкодов, USB-serial преобразователи. Последовательная связь выглядит устаревшей, но в разработке Windows-приложений ею до сих пор пользуются вполне обычно.

Опасный момент в том, что последовательную связь можно начать с одного COM-порта и пары Read / Write. Проверка связи проходит сразу, а в production нередко всплывает следующее.

  • Иногда команда и ответ расходятся
  • Раз в день зависает — и только раз
  • После отключения USB не восстанавливается
  • UI иногда подтормаживает
  • В логах остаётся только “Timeout”

В приложении с последовательной связью по-настоящему сложно не API отправки и приёма, а границы сообщений, тайм-ауты, переходы состояний, переподключение и наблюдаемость.

Связь есть, в production ломаетсяПоследовательную связь начинают с одного COM-порта и пары Read/Write, проверка связи проходит сразу, а в production появляются сдвиг ответа, зависание и отказ восстановиться — настоящая сложность в границах, тайм-аутах, состояниях, переподключении и наблюдаемости.Проверка связи проходит сразуВ production ломается «иногда»Сдвиг ответа, зависание, нет восстановленияСложность не в API отправки и приёмаГраницы, тайм-ауты, состояния, переподключение, наблюдаемость

Рис. 1: Настоящие трудности приложения с последовательной связью — за проверкой связи.

Для кого статья и какие предпосылки

Пункт Содержание
Читатель Тот, кто пишет Windows-приложение, связанное по последовательному порту с устройством или измерительным прибором. Рассчитываем на человека, у которого проверка связи уже проходит, а в production хочется убрать поломки «иногда»
Предполагаемые знания Умеете писать приложения на C#. Опыт именно последовательной связи не обязателен
Предполагаемая среда Текст опирается на System.IO.Ports.SerialPort в .NET, но сами идеи про границы, тайм-ауты и переходы состояний от языка не зависят
Чего нет Электрическая разводка и спецификация протокола конкретного устройства

Термины в этой статье

Термин Смысл в одной строке
ПЛК Programmable Logic Controller. Промышленный контроллер для управления производственным оборудованием
RS-232 / RS-485 Электрические стандарты последовательной связи. RS-232 — точка-точка, RS-485 позволяет повесить несколько устройств на одну линию. На RS-485 без договорённости, кто и когда передаёт, будут коллизии
8N1 Сокращение настроек порта: 8 бит данных, без чётности (None), 1 стоп-бит
DTR / RTS Управляющие линии. Изначально сообщали о готовности к связи и запросе на передачу, но на реальном оборудовании смена этих линий нередко служит сигналом запуска или переключения режима
Управление потоком Механизм, который не даёт отправлять слишком быстро. RTS/CTS — через управляющие линии, XON/XOFF — через спецсимволы в потоке данных
keepalive Лёгкая команда, которую периодически шлют, чтобы проверить, жив ли собеседник
Кадр (frame) Последовательность байт на одно сообщение. Где начинается и где кончается кадр, задаёт протокол
single writer Архитектура, в которой отправку сводят к одному worker. Смысл в том, чтобы Write нельзя было вызвать откуда угодно

1. Сначала вывод

Сформулируем заранее, ближе к практике.

  • Последовательная связь — это упорядоченный поток байт (byte stream), и границы сообщений сами собой не появляются
  • Вызов Read(100) не гарантирует, что вернётся ровно 100 байт
  • DataReceived в .NET не обязан срабатывать на каждый принятый байт и, кроме того, выполняется не в UI-потоке
  • ReadLine() / WriteLine() предсказуемы только тогда, когда собеседник действительно использует построчный текстовый протокол
  • Одного тайм-аута мало. Стабильнее разделить их по смыслу: open, inter-byte, response, reconnect
  • Надёжнее не разрешать Write откуда угодно, а свести отправку к single writer
  • Для USB-serial спокойнее сразу закладываться на отключение, повторное перечисление, смену номера COM и неудачные попытки переподключения

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

Карта знаний этой статьи

Статья — руководство по проектированию C# приложений serial communication для связи с оборудованием, чтобы не допустить сбои, которые ломаются только иногда. Serial communication — всего лишь упорядоченный byte stream без границы сообщения, поэтому событие DataReceived не следует считать извещением о прибытии одного сообщения; рекомендуется сначала накапливать приём, а затем нарезать его frame parser. Отправку сводят к одному worker по схеме single writer, а timeout проектируют отдельно по смыслу: open, inter-byte, response, reconnect backoff. В протоколе без request ID в кадре после истечения timeout ответа возможно неверное сопоставление, поэтому нужна не простая переустановка соединения, а пересоздание сессии, включая приёмный буфер и состояние parser.

Карта знаний: ловушки в приложении serial communicationРисунок, который исходит из того, что serial communication — это byte stream без границы сообщения, и показывает, как frame parser и схема single writer обрабатывают эту границу и порядок отправки, и как разделение видов timeout и наличие или отсутствие request ID ведут к неверному сопоставлению ответа и к пересозданию сессиииспользуеттребуетреализуетне рекомендуетсярекомендуется длярекомендуется дляиспользуетиспользуетиспользуетиспользуетиспользуетможет вызватьпредотвращаетрекомендуется дляиспользуеттребуетрекомендуется длятребуетпоследовательная связьпарсер кадров (накопить и вырезать)single writer (одна точка записи)byte stream (упорядоченная последовательность байт)граница кадра (сообщения)событие SerialPort.DataReceivedпроверка кадра по CRCinter-byte timeoutразделение видов тайм-аутовтаймаут ответа (response timeout)переподключение с backoffриск перепутать ответыRequest IDпересоздание session при переподключенииуправление потоком (DTR/RTS)журнал приёма-передачи с hex dump

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 18, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Последовательная связь — это не «сообщения», а «упорядоченный поток байт»

С точки зрения приложения последовательная связь выглядит так: «отправили одну команду — получили один ответ». На нижележащем уровне течёт лишь упорядоченная последовательность байт.

То, что вы отправили одним Write, на другой стороне может выглядеть так:

  • приходит за один Read
  • приходит, разделившись на два приёма
  • приходит, склеившись с другими данными

Если упустить эту предпосылку, приложение начинает считать, что «этот Read наверняка соответствует этому ответу». Такое допущение часто становится первой ловушкой в приложениях с последовательной связью.

Один Write может прийти тремя способамиТо, что отправили одним Write, на другой стороне не обязательно приходит за один Read: может разделиться на два приёма или склеиться с другими данными.Один WriteПриходит за один ReadПриходит за два приёмаПриходит, склеившись с другими даннымиЭтот Read не обязан быть этим ответом

Рис. 2: Как один Write выглядит на другой стороне, заранее неизвестно — видно только по факту прихода.

Распространённое заблуждение Как обстоит дело
Read(16) вернёт ровно 16 байт В зависимости от того, как данные приходят, и от тайм-аута можно получить лишь часть
DataReceived = пришло одно сообщение Событие не гарантирует срабатывание на каждый байт и выполняется не в UI-потоке
Write вернулся = собеседник обработал данные Чаще это ближе к тому, что отправитель смог положить данные в буфер
Список COM = истинное текущее состояние подключений Порядок перечисления не определён, а сам список бывает устаревшим (stale)

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

Границы задаёте вы самиВ последовательной связи границы сообщений нужно определить самим как протокол: кадры фиксированной длины, разделители, длина плюс payload плюс контрольная сумма — формат любой, но входить в реализацию вразмытую тяжело.Границы сообщений задаёте самиКадры фиксированной длиныРазделителиДлина + payload + checksumВразмытую будет тяжело

Рис. 3: Нижележащий уровень границ не ставит — их заранее фиксируют как протокол.

3. Что решить в первую очередь

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

3.1 Границы кадра

Определите, какая последовательность байт считается одним сообщением. Фиксированная длина? Разделение по переводу строки? Длина в начале? Есть ли checksum / CRC? Если это размыто, принимающая сторона не отличит «ещё не хватает» от «данные повреждены».

3.2 Текст, двоичные данные или смесь

Заранее решите: это построчный протокол на ASCII / UTF-8, чистые двоичные данные или смесь. Особенно в смешанных случаях вроде «команда — строка, payload — двоичные данные, перевод строки только в конце» граница быстро разъезжается, если явно не указать, что декодируется, а что остаётся сырыми байтами.

3.3 Смысл каждого тайм-аута

Безопаснее не держать один общий тайм-аут, а разделить их по смыслу.

  • open timeout: до открытия порта
  • inter-byte timeout: сколько нет байт внутри кадра
  • response timeout: от выдачи команды до завершения ответа
  • reconnect backoff: пауза перед повторным подключением

Тайм-ауты стабильнее работают не как «страховка на случай задержки», а как правила, по которым автомат состояний идёт дальше.

Тайм-ауты делят по смыслуТайм-ауты разделяют на open до открытия порта, inter-byte при тишине внутри кадра, response до завершения ответа и reconnect backoff между попытками переподключения и держат как правила автомата состояний.Одного тайм-аута малоopen: до открытияinter-byte: тишинаresponse: ответ завершёнreconnect backoffПравила автомата состояний

Рис. 4: Четыре вида тайм-аута можно держать как правила автомата состояний.

3.4 Управление потоком и состояние линий

Вот настройки, которые стоит задавать явно.

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR / RTS

Если ограничиться «примерно 8N1 — и сойдёт», на части устройств связь просто останавливается.

3.5 Разделение ответственности

Разделите, кто за что отвечает.

  • кто читает
  • кто пишет
  • кто разбирает (парсит)
  • кто переносит данные в прикладное состояние

Чем сильнее в последовательной связи смешаны UI и обмен, тем она хрупче.

Ответственность разделяют, UI и обмен не смешиваютРазделяют, кто читает, кто пишет, кто парсит и кто переносит данные в прикладное состояние; чем сильнее смешаны UI и обмен, тем связь хрупче.Разделение ответственностиКто читает и кто пишетКто парситКто переносит в прикладное состояниеЧем сильнее смешаны UI и обмен, тем хрупче

Рис. 5: Чтение, запись, разбор и перенос в состояние разделяют; UI и обмен не смешивают.

3.6 Переходы состояний при запуске, остановке и переподключении

В проект стоит заложить как минимум состояния Closed, Opening, Ready, WaitingResponse, Fault, Reconnecting. Сразу после отключения собеседник может ещё загружаться, и предыдущий pending request тащить за собой нельзя.

запрос Openopen успешен + инициализация завершенаopen не удался / ошибка прав / тайм-аут инициализацииотправка командыпринят соответствующий кадр ответаresponse timeoutошибка I/O / обрыв линииpending request в fail и старт backoffbackoff истёкдостигнут предел / ручная остановказапрос CloseClosedOpeningReadyFaultWaitingResponseReconnecting

Рис. 6: Переходы состояний сессии. Прямой стрелки из Fault в Ready нет.

В этой схеме важно, что из Fault в Ready прямой стрелки нет. После сбоя путь всегда идёт через Reconnecting и Opening: буфер приёма, состояние parser, pending request и последовательность инициализации создают заново, и только потом возвращаются в Ready. Если срезать этот путь, попадаете в 4.7: «повторил Open() — значит, переподключился».

3.7 Логи и возможность расследования

Позже больнее всего обычно именно здесь. Как минимум стоит оставлять: время open / close / reopen, использованные настройки порта, hex-дамп отправленных и принятых кадров, ошибки checksum / CRC, frame timeout / response timeout, причину каждого переподключения.

4. Типичные ловушки

4.1 Считать, что «один Read = одно сообщение»

Это самая частая ошибка. Допустим, собеседник возвращает кадр из заголовка, длины, payload и CRC. Если один раз вызвать Read(buffer, 0, expectedLength) и принять возвращённое значение за целый кадр, при частичном приёме всё легко ломается.

Три типичных сценария.

  • считана только длина, а payload ещё не пришёл
  • пришло полтора кадра, и хвост уходит на следующий Read
  • два кадра пришли вместе, обработан только первый, остальное отброшено

На схеме это всего лишь то, что порядок, который отправило устройство, не совпадает с тем, что возвращает Read.

Что отправило устройство
    [--- кадр 1 ---][--- кадр 2 ---]

Вариант 1: пришла только часть
    1-й Read -> [ STX ][ LEN ]                     <- payload ещё не пришёл
    2-й Read -> [ payload ][ CRC ][--- кадр 2 ---]

Вариант 2: пришло полтора кадра
    1-й Read -> [--- кадр 1 ---][ начало кадра 2 ]
    2-й Read -> [ хвост кадра 2 ]

Вариант 3: два кадра пришли вместе
    1-й Read -> [--- кадр 1 ---][--- кадр 2 ---]   <- легко обработать только первый и выбросить остаток

Во всех трёх вариантах данные не «повреждены»: просто границы не совпадают с границами вызовов Read. Если перепутать это и считать ошибкой любое несовпадение числа байт с ожиданием, начнёте учитывать нормальный обмен как ошибки.

Приём простой: сначала копить приём, а кадры из буфера выделяет parser. Каркас кода — в 5.1.

Сначала копить, потом выделятьЕсли принимать возврат Read за один кадр, частичный приём легко ломает разбор, поэтому сначала копят байты в буфере, а кадры из него выделяет parser.вместо этогоВозврат Read = один кадрЧастичный приём легко ломает разборСначала копить в буферParser выделяет кадры

Рис. 7: Единицу возврата Read и кадр разводят: сначала копят, потом выделяют.

4.2 Делать из DataReceived прикладное событие

SerialPort.DataReceived в .NET выглядит удобным, но опасно считать его «уведомлением, что пришло одно сообщение». На практике DataReceived стоит воспринимать как «похоже, что-то пришло», внутри обработчика тяжёлую работу не делать и обновление UI всегда возвращать в UI-поток.

4.3 Считать, что Write можно вызывать откуда угодно

Схема, в которой кнопка UI, таймер мониторинга, логика переподключения и keepalive каждый сам вызывает Write, легко разъезжается. Последовательная связь — поток байт, поэтому в зависимости от архитектуры команда может вклиниться или уйти следом, пока ещё ждём ответ. Особенно для request-response и шин вроде RS-485 гораздо стабильнее свести отправку к single writer.

Отправку сводят к single writerЕсли кнопка UI, таймер мониторинга, keepalive и переподключение каждый сам вызывает Write, команды вклиниваются и уходят следом, пока ещё ждут ответ; single writer это стабилизирует.вместо этогоКнопка UIКаждый сам вызывает WriteТаймер мониторингаkeepalive и переподключениеВклинивание и отправка поверх ответаСвести к single writer

Рис. 8: Мест прямого Write не размножают: отправку сводят к одному worker.

4.4 Пропускать всё через ReadLine() / WriteLine()

Для построчного текстового протокола ReadLine() / WriteLine() удобны. Но удобны они только тогда, когда протокол действительно построчный. Несовпадение NewLine, перевод строки внутри payload, разница кодировок и смесь с двоичными данными быстро разрушают границы.

4.5 Не проектировать тайм-ауты и оставить значения по умолчанию

Небрежно вставленный синхронный read обычно превращается в бесконечное ожидание. Хуже того, заданный timeout не обязан действовать на все способы чтения. Реализации, где синхронный read идёт в UI-потоке, где всё пытаются выразить одним timeout или где просто наращивают retry, склонны застревать.

4.6 Недооценивать RTS/CTS, XON/XOFF и DTR/RTS

Handshake и управляющие линии на реальном оборудовании заметно влияют. При несовпадении настроек типичны такие симптомы: передача периодически останавливается, после определённого объёма данные теряются, поведение другое только сразу после открытия порта. Некоторые устройства смотрят на смену DTR/RTS как на сигнал запуска или переключения режима.

4.7 Считать, что повторный Open() уже есть переподключение

Особенно для USB-serial нормально, что порт на время исчезает, прежний handle становится недействительным, а предыдущий pending request теряет смысл. Переподключение безопаснее обрабатывать как единый блок: аннулирование сессии, ошибка для pending request, остановка reader / writer, reopen после backoff, повтор последовательности инициализации устройства.

Переподключение — это не повторный OpenДля USB-serial порт может исчезнуть, а прежний handle стать недействительным, поэтому переподключение обрабатывают вместе: аннулирование сессии, ошибка для pending request, остановка reader и writer, reopen после backoff и повтор инициализации устройства.Аннулировать сессиюЗавершить pending request ошибкойОстановить reader / writerReopen после backoffПовторить инициализацию устройстваОдного повторного Open мало

Рис. 9: Переподключение — это пересоздание сессии: эту цепочку делают целиком.

4.8 Считать перечисление COM-портов истиной

GetPortNames() удобен, но появление в списке и возможность открыть порт — не одно и то же. Реализации, которые слепо верят прошлому COM7, автоматически берут первый пункт списка или считают порт действительным просто потому, что он есть в списке, в эксплуатации часто подводят.

4.9 Скудные логи отправки и приёма

Одних TimeoutException, IOException, Port closed почти ни о чём не говорят. Если фиксировать время отправки и приёма, профиль порта, hex-дампы, ошибки parser, к какому request относится response и что стало поводом для reconnect, разбор сильно продвигается.

Если заранее зафиксировать формат строки, потом можно и grep, и сравнение diff. Например, такой однострочный формат.

2026-03-19T10:23:41.512+09:00  COM3  TX  req=00A7  len=5   02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00  COM3  RX  req=00A7  len=3   02 01
2026-03-19T10:23:41.531+09:00  COM3  RX  req=00A7  len=6   10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=00A7  frame=02 01 10 00 4B  result=OK
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=-     frame=02 01 11        result=INCOMPLETE  need=2
2026-03-19T10:23:43.540+09:00  COM3  ERR req=00A8  reason=response-timeout  elapsed=2008ms
2026-03-19T10:23:43.541+09:00  COM3  STATE Ready -> Fault  reason=response-timeout

Здесь три цели.

  • Строки RX и PARSE держать отдельно. RX — «сколько байт пришло», PARSE — «сколько кадров удалось выделить». В примере выше один кадр растянулся на второй и третий RX, а остаток стал началом следующего кадра. Если смешать эти два вида записей, потом не отличить, был ли сдвиг границ из 4.1
  • По req= можно сопоставить отправку и приём. Какой ответ к какой команде относится, из логов задним числом не восстановить
  • Переход состояния — одной строкой. Если остаются переходы вроде Ready -> Fault и причина, повод переподключения читается сразу

Hex-дамп ест место, поэтому на практике разумно держать сырой лог кольцевым буфером ограниченного размера, а сводный — долго.

Три цели логов отправки и приёмаСтроки RX и PARSE разделяют, отправку и приём сопоставляют по req, переход состояния пишут одной строкой; сырой лог держат кольцевым буфером, сводный сохраняют надолго.Разделить строки RX и PARSEЛог, по которому потом разбираютСопоставить отправку и приём по reqПисать переход состояния одной строкойСырой лог — кольцо, сводный — надолго

Рис. 10: Если отдельно оставлять число пришедших байт и выделенные кадры, сдвиг потом можно проследить.

5. Проверенные приёмы

Больше всего помогает разделение ответственности.

  • reader: только читает байты из порта
  • writer: только последовательно пишет из исходящей очереди
  • parser: только выделяет кадры из потока байт
  • protocol: соответствие request и response, контрольные суммы
  • app state: только обновляет прикладное состояние

Для приёма устойчивее не делать из возврата Read прикладную единицу, а сначала копить в буфер, из которого parser выделяет кадры. Отправку сводят к одному worker, фактический Write оставляют single writer — так меньше сдвигов порядка.

Тайм-ауты тоже лучше не сводить к одному числу, а делить по смыслу — open, inter-byte, response, reconnect: так проще искать причину. Настройки порта держат профилем, а не значениями в коде на месте, и выводят в лог при старте — расследование на площадке становится заметно проще.

Переподключение стабильнее мыслить не как простой reopen, а как пересоздание сессии. Если заново собирать буфер приёма, состояние parser, pending request, последовательность инициализации и проверку готовности, меньше сбоев переподключения, которые «ломаются лишь изредка».

Наконец, полезно вести и сырой, и сводный лог. Raw hex-дамп и история open / close сильны для расследования, сводки по request id и числу retry — для эксплуатации.

Конвейер по ролямReader читает байты из порта, parser выделяет кадры из накопленного буфера, protocol сопоставляет и проверяет checksum, app state обновляет прикладное состояние, а снаружи только кладут в очередь, пишет один writer.Портreader: только читаетparser: выделяет кадрыprotocol: сопоставление и checksumapp state: обновление прикладного состоянияСнаружи только кладут в очередьwriter: пишет по порядку

Рис. 11: Конвейер приёма и отправки. У каждой роли одна работа.

Дальше — каркас только для двух мест, которые дают больше всего. Предполагаем .NET 8 / C# 12 и пакет System.IO.Ports.

5.1 Приём: сначала копить, потом выделять

В качестве примера берём кадр STX(0x02), LEN(1 byte), payload(LEN byte), CRC16(2 byte, little-endian). Формат может быть любым: важно резать кадры не по возврату Read, а по этому определению.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;

public static class Crc16Modbus
{
    // CRC-16/MODBUS: начальное значение 0xFFFF, полином 0xA001, сдвиг вправо
    public static ushort Compute(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;
        foreach (var b in data)
        {
            crc ^= b;
            for (var i = 0; i < 8; i++)
            {
                crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
        }

        return crc;
    }
}

public static class Frame
{
    public const byte Stx = 0x02;
    public const int HeaderLength = 2;   // STX + LEN
    public const int CrcLength = 2;

    public static byte[] Build(ReadOnlySpan<byte> payload)
    {
        // LEN — 1 байт: при 256 байтах и больше приведение обрежет значение по кругу.
        // payload при этом копируется целиком, поэтому получатель режет кадр
        // по усечённой длине и читает середину payload как CRC. Дальше
        // границы кадров тоже разъезжаются. Делить на части или расширять LEN
        // до 2 байт — решение протокола; здесь мы просто отвергаем такое
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"payload одного кадра не больше {byte.MaxValue} байт (LEN занимает 1 байт).");
        }

        var frame = new byte[HeaderLength + payload.Length + CrcLength];
        frame[0] = Stx;
        frame[1] = (byte)payload.Length;
        payload.CopyTo(frame.AsSpan(HeaderLength));

        var body = frame.AsSpan(0, frame.Length - CrcLength);
        BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
        return frame;
    }
}

public sealed class FrameParser
{
    private readonly List<byte> _buffer = new();

    /// <summary>inter-byte timeout из 3.3. Сколько ждать, прежде чем бросить собираемый кадр.</summary>
    private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);

    /// <summary>С какого момента (монотонные часы) текущий кандидат в ожидании.</summary>
    private long _pendingSince;

    /// <summary>Сигнал об отброшенном кадре из-за CRC. Подписывайтесь всегда, чтобы писать это в лог.</summary>
    public event Action<byte[]>? FrameDiscarded;

    /// <summary>Сигнал, что сборку бросили и пошли в ресинхронизацию. Если срабатывает снова и снова — смотрите кабель и настройки.</summary>
    public event Action<int>? Resynchronized;

    /// <summary>Копит принятые байты и возвращает только те кадры, которые удалось выделить.</summary>
    public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
    {
        foreach (var b in received)
        {
            _buffer.Add(b);
        }

        var frames = new List<byte[]>();

        while (true)
        {
            // 1. Отбрасываем всё до первого STX. Здесь поглощаются шум и хвост предыдущего кадра
            var stxIndex = _buffer.IndexOf(Frame.Stx);
            if (stxIndex < 0)
            {
                _buffer.Clear();
                _pendingSince = 0;   // кандидата нет — измерение ожидания тоже останавливаем
                break;
            }

            if (stxIndex > 0)
            {
                // Сменилось начало кандидата — начинаем собирать другой кадр
                _buffer.RemoveRange(0, stxIndex);
                _pendingSince = 0;
            }

            // 2. Хватает ли байт, чтобы прочитать длину
            if (_buffer.Count < Frame.HeaderLength)
            {
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // не «повреждено», а «ещё не хватает»
            }

            int payloadLength = _buffer[1];
            int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;

            // 3. Собрался ли кадр целиком
            if (_buffer.Count < frameLength)
            {
                // На этом шаге не отличить «ещё не хватает» от «LEN испорчен шумом».
                // Если из-за шума или ложного STX LEN стал 255, парсер дальше
                // заглатывает и правильные кадры как payload и молчит, пока
                // не наберётся 259 байт и CRC не отвергнет кадр.
                // На устройствах с редким трафиком это выглядит как минуты тишины.
                // Ставим потолок ожидания: превышен — бросаем кандидата и ищем STX заново
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // выходим и ждём следующий приём
            }

            var frame = _buffer.GetRange(0, frameLength).ToArray();
            _buffer.RemoveRange(0, frameLength);
            _pendingSince = 0;

            // 4. Кадр с неверным CRC отбрасываем. Об отбрасывании обязательно сообщаем наружу
            var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
            if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
            {
                frames.Add(frame);
            }
            else
            {
                // Выбросить кадр целиком или только 1 байт STX и читать дальше — решение проектирования.
                // Первое проще, второе устойчивее, когда сам LEN — шум. Выберите один вариант и зафиксируйте его.
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// Если кандидат в сборке старше AssemblyTimeout, выбрасывает только первый байт STX.
    /// Возвращает true — вызывающий код начинает поиск со следующего STX.
    /// Кадр целиком не выбрасываем: внутри кандидата может оказаться настоящий STX.
    /// </summary>
    private bool GiveUpOnStaleCandidate()
    {
        if (_pendingSince == 0)
        {
            // Момент, когда начали ждать. Настенные часы могут скакнуть из-за NTP, поэтому меряем монотонным счётчиком
            _pendingSince = Stopwatch.GetTimestamp();
            return false;
        }

        if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
        {
            return false;
        }

        _buffer.RemoveAt(0);
        _pendingSince = 0;
        Resynchronized?.Invoke(_buffer.Count);
        return true;
    }
}

GiveUpOnStaleCandidate — это как раз inter-byte timeout из 3.3. Без него, если из-за шума или ложного STX поле LEN превратится в большое значение (например, 255), parser будет считать, что «ещё не хватает». Последующие правильные кадры он заглатывает как часть испорченного payload и ничего не отдаёт, пока не наберётся 259 байт и CRC не отвергнет кадр. На устройстве с редким трафиком это выглядит как минуты тишины. Выбрасываем только 1 байт STX, потому что настоящий STX может сидеть внутри кандидата.

Две предпосылки. Этот тайм-аут проверяется только когда вызвали Append. Если линия полностью затихла, со стороны parser ничего не произойдёт — это подхватывает response timeout вызывающего кода (5.2). И ещё: значение AssemblyTimeout считайте от скорости порта и длины кадра. Нижняя граница — время передачи 1 байта × ожидаемая максимальная длина кадра плюс запас; короче — начнёте отбрасывать нормальные кадры на середине. Число срабатываний Resynchronized стоит писать в лог: это материал, чтобы заподозрить кабель или скорость порта.

Испорченный LEN заглатывает правильные кадрыЕсли из-за шума или ложного STX LEN стал большим, parser продолжает ждать, заглатывает последующие правильные кадры как payload и молчит до отказа CRC, поэтому по тайм-ауту отбрасывают 1 байт STX и ищут заново.приёмШум или ложный STX портит LENParser ждёт: «ещё не хватает»Заглатывает и правильные кадрыДо отказа CRC выглядит как тишинаПо тайм-ауту отбросить 1 байт STX и ресинхронизироваться

Рис. 12: Без inter-byte timeout испорченный LEN продолжает заглатывать последующие кадры.

Сторона чтения только читает из порта и отдаёт parser. Если здесь начать писать прикладную обработку, единица возврата Read превратится в прикладную единицу.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public sealed class SerialReader
{
    private readonly SerialPort _port;
    private readonly FrameParser _parser;
    private readonly byte[] _readBuffer = new byte[4096];

    public SerialReader(SerialPort port, FrameParser parser)
    {
        _port = port;
        _parser = parser;
    }

    public event Action<byte[]>? FrameReceived;

    public async Task RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            int count;
            try
            {
                count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            if (count <= 0)
            {
                continue;
            }

            foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
            {
                FrameReceived?.Invoke(frame);
            }
        }
    }
}

DataReceived не используем намеренно. Как в 4.2, у него нет смысла сверх «похоже, что-то пришло», поэтому свой цикл чтения удобнее для управления состоянием и timeout.

5.2 Отправка: свести к single writer

На отправке главное — не создавать состояние, в котором Write можно вызвать откуда угодно. Класть в очередь можно из любого места; фактический Write делает только один worker.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public sealed class SingleWriter
{
    private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);

    /// <summary>Потолок очереди отправки. Берётся как время одного обмена с устройством × допустимая длина очереди.</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // Без потолка не оставляем. Если UI, таймер и worker кладут быстрее, чем
    // устройство успевает ответить, кадры и TaskCompletionSource копятся без
    // предела: устройство отвечает, растёт только память. Фиксируем потолок
    // и при переполнении возвращаем ошибку отправителю
    private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
        new BoundedChannelOptions(QueueCapacity)
        {
            // При заполнении TryWrite возвращает false. Вызывающий код сразу
            // знает, что «сейчас затор». DropOldest не используем — сторона,
            // которая положила кадр, ждёт Task; молча выбросить — значит,
            // Task никогда не вернётся
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,
        });

    private Outbound? _inFlight;

    public SingleWriter(SerialPort port, TimeSpan responseTimeout)
    {
        _port = port;
        _responseTimeout = responseTimeout;
    }

    /// <summary>Можно вызывать и из UI, и из таймера. Реальный Write выполняет только один worker.</summary>
    public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
    {
        var item = new Outbound(
            Frame.Build(payload),
            new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));

        if (!_queue.Writer.TryWrite(item))
        {
            // Очередь полна или worker уже остановлен. В обоих случаях
            // сообщаем вызывающему коду, что положить не удалось. Молча
            // выбросить — ждущий Task никогда не вернётся
            item.Completion.TrySetException(new InvalidOperationException(
                $"Не удалось поставить в очередь отправки (лимит {QueueCapacity} или worker уже остановлен)."));
        }

        return item.Completion.Task;
    }

    /// <summary>Вызывать, когда parser выделил кадр. Привязывает его к одному ожидающему ответу.</summary>
    public void OnFrameReceived(byte[] frame)
    {
        var pending = Interlocked.Exchange(ref _inFlight, null);
        pending?.Completion.TrySetResult(frame);
    }

    public async Task RunAsync(CancellationToken token)
    {
        try
        {
            await foreach (var item in _queue.Reader.ReadAllAsync(token))
            {
                Interlocked.Exchange(ref _inFlight, item);
                try
                {
                    await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
                }
                catch (Exception ex)
                {
                    // Сюда приходят обрыв линии, закрытие порта, отмена.
                    // Если выйти, не завершив эту заявку, вызывающий код,
                    // который await-ит SendAsync, будет ждать вечно
                    Interlocked.Exchange(ref _inFlight, null);
                    item.Completion.TrySetException(ex);
                    throw;
                }

                // Ждём ответ здесь — следующая команда не вклинивается
                var timeout = Task.Delay(_responseTimeout, token);
                var finished = await Task.WhenAny(item.Completion.Task, timeout);

                if (finished != item.Completion.Task)
                {
                    // При запросе остановки Task.Delay тоже отменяется и
                    // заканчивается раньше. Если это не проверить, штатное
                    // завершение станет тайм-аутом, и вызывающий код получит
                    // TimeoutException
                    token.ThrowIfCancellationRequested();

                    // После того как WhenAny выбрал timeout, ответ ещё может
                    // успеть прийти. Тогда OnFrameReceived уже забрал
                    // _inFlight и завершил эту заявку успехом. Если забрать
                    // не удалось — победил ответ, и TrySetException здесь
                    // холостой. Не заметив этого и дойдя до throw, вызывающий
                    // код уже получил результат, а падает только worker.
                    // Гонку решает CompareExchange
                    if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
                    {
                        // Победил ответ. Завершение ставит OnFrameReceived (только что)
                        await item.Completion.Task;
                        continue;
                    }

                    item.Completion.TrySetException(new TimeoutException("Ответа не было."));

                    // После тайм-аута этому соединению больше нельзя верить.
                    // Почему нельзя «сдаться и послать следующее» — ниже:
                    // в этом протоколе нет request ID, и запоздавший ответ на A
                    // привяжется как ответ на следующую B. По схеме состояний
                    // из 3.6 уходим в Fault и создаём сессию заново
                    throw new TimeoutException("Ответа нет, сессию нужно создать заново.");
                }
            }
        }
        finally
        {
            // По какой бы причине worker ни остановился, ждущие заявки
            // обязательно завершаем. И ту, что ждёт ответ, и те, что так
            // и остались в очереди неотправленными
            var stopped = new OperationCanceledException("Worker отправки остановлен.");
            Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
            _queue.Writer.TryComplete();
            while (_queue.Reader.TryRead(out var pending))
            {
                pending.Completion.TrySetException(stopped);
            }
        }
    }
}

Останавливать весь worker по тайм-ауту выглядит грубо, но это нужная мера. В этом кадре нет request ID. Поэтому принимающая сторона не может понять, на какую команду пришёл кадр, и OnFrameReceived механически привязывает его к одной ожидающей заявке.

Если после тайм-аута просто послать следующее, получается так.

  1. Отправляем команду A. За отведённое время ответа нет — фиксируем тайм-аут
  2. Отправляем следующую команду B
  3. Запоздавший ответ на A уходит вызывающему коду как ответ на B

Со стороны вызывающего кода отправили B, а вернулось значение A. Формат значения правильный, проверки тоже проходят — это самая трудная для обнаружения поломка. Прямую стрелку из Fault в Ready на схеме 3.6 не рисовали именно из-за этого пути. Тайм-аут — не «один сбой», а суждение «этому соединению больше нельзя верить». Вернуться из состояния, в котором неизвестно, что лежит в буфере приёма, можно только пересозданием сессии: закрыть порт и открыть заново.

Если протокол можно менять, существенное решение — дать кадру request ID и сопоставлять ответ с ним. Тогда запоздавший ответ отбрасывают как «незнакомый ID», и соединение не приходится создавать заново на каждый тайм-аут.

Путаница из-за запоздавшего ответаВ протоколе без request ID после тайм-аута команды A отправка команды B приводит к тому, что запоздавший ответ на A уходит как ответ на B, поэтому после тайм-аута уходят в Fault и создают сессию заново.УстройствоWorkerВызывающий кодУстройствоWorkerВызывающий кодЗа время ответа нет — тайм-аутПоэтому после тайм-аута уходим в FaultОтправить команду AПередать AОтправить команду BПередать BЗапоздавший ответ на AОтдают как ответ на B

Рис. 13: Без request ID запоздавший ответ привязывается к следующей команде.

Наконец, собираем вместе. Настройки порта держат одним профилем в одном месте и при старте пишут в лог — это как раз 3.4.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

// Настройки из 3.4 держим вместе, а не размазываем значениями на месте
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
    Handshake = Handshake.None,
    DtrEnable = true,
    RtsEnable = true,
    ReadTimeout = 500,
    WriteTimeout = 500,
};

Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
                  $"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();

var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);

// На определённые события подписываемся обязательно. Иначе отброшенные кадры и ответы наружу не выйдут
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;

using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);

var request = new byte[] { 0x10, 0x00 };
try
{
    var response = await writer.SendAsync(request);
    Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
    // Даже если отправка не удалась, worker останавливаем до выхода. Если это
    // пропустить, using уничтожит SerialPort, а reader/writer продолжат
    // трогать уже закрытый поток, и эти исключения никто не увидит
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // Завершение по запросу остановки. Это штатный путь
    }
    catch (Exception ex)
    {
        // Сбой на стороне worker. Если пробросить его отсюда, он закроет
        // исходную причину (исключение SendAsync), поэтому только записываем
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

cts.Cancel() и Task.WhenAll стоят в finally не из вкуса к записи. В последовательной связи отказ SendAsync — не исключение, а повседневность: устройство не отвечает, кабель вынули, запись упёрлась в timeout. Если просто выйти наверх, дойдёте до уничтожения в using, не остановив worker. После закрытия SerialPort reader / writer всё равно полезут в этот поток, и исключения оттуда никто не увидит. В долгоживущем приложении это выглядит так: после каждой неудачной операции worker остаётся жить. Исключение из finally не пробрасываем, чтобы не закрыть исходную причину (исключение SendAsync).

Даже при сбое worker обязательно останавливаютОтказ SendAsync в последовательной связи — повседневность; если просто выйти наверх, SerialPort уничтожат, не остановив worker, и reader с writer продолжат трогать закрытый поток без наблюдателя, поэтому cancel и ожидание делают в finally.SendAsync не удался (повседневность)В finally — cancel и ожиданиеСначала остановить worker, потом уничтожитьЕсли пропустить — продолжат трогать закрытый потокВ долгоживущем приложении после каждого сбоя остаётся worker

Рис. 14: Как раз когда отправка не удалась, из finally останавливают worker и только потом выходят.

В такой схеме то, что потом захочется добавить — retry, keepalive, reconnect, — укладывается либо на сторону очереди, либо на сторону worker. Мест прямого Write не прибывает, и причин сдвига порядка тоже.

6. Чек-лист для первой проверки

  • Зафиксированы ли границы сообщений явно
  • Идёт ли приём по схеме «накопление байт → выделение кадра»
  • Не считают ли DataReceived приходом сообщения
  • Нет ли синхронного I/O в UI-потоке
  • Сведена ли отправка к single writer
  • Разделены ли timeout по смыслу, а не сведены к одному значению
  • Заданы ли явно Handshake / DTR / RTS
  • Пересоздаётся ли сессия при reconnect
  • Остаётся ли raw hex-дамп
  • Проверены ли реальное отключение устройства и обрыв связи на середине работы

Если по нескольким пунктам есть сомнения, перед production стоит остановиться и разобраться.

7. Итог

В конце ещё раз только главное.

  • Последовательная связь — поток байт, а не сообщения
  • Единица Read и единица сообщения не совпадают
  • Границы нужно определить как протокол
  • DataReceived как прикладное событие легко разъезжается
  • Приём и отправку разделяют по ролям, отправку сводят к single writer
  • timeout делят по смыслу, переподключение проектируют на уровне сессии
  • Лог с raw hex-дампом сильно упрощает последующее расследование

Иначе говоря, в приложении с последовательной связью гораздо важнее, как вы разбираете поток байт и как управляете временем и состоянием, чем сам факт открытия порта. Уже одно разделение этих вещей в проектировании заметно сокращает сбои связи, которые «ломаются лишь изредка».

Важнее не открыть, а разобрать и управлятьВ приложении с последовательной связью важнее не открытие порта, а то, как разбирают поток байт и как управляют временем и состоянием; если это разделить в проектировании с самого начала, реже бывают сбои, которые ломаются лишь изредка.Открыть портЭто не главная трудностьРазбор потока байтРазделить в проектировании с самого началаУправление временем и состояниемРеже сбои, которые «ломаются лишь изредка»

Рис. 15: Важно не открытие порта, а проектирование разбора и управления.

8. Справочные материалы

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Если вызвать Read(16) по последовательному порту, придёт ровно 16 байт?
Не обязательно. Последовательная связь — это упорядоченный поток байт (byte stream), и границы сообщений сами собой не появляются. То, что отправили одним Write, на другой стороне может прийти за два приёма или склеиться с другими данными. Типичные поломки: считана только длина, а payload ещё не пришёл; пришло полтора кадра; два кадра пришли вместе. Надёжный приём — сначала копить байты в буфере, а кадры из него выделяет parser.
На что обратить внимание при использовании события SerialPort.DataReceived в .NET?
DataReceived не обязан срабатывать на каждый принятый байт и выполняется не в UI-потоке. Считать его «уведомлением, что пришло одно сообщение» опасно. На практике это не больше чем «похоже, что-то пришло»: внутри обработчика тяжёлую работу не делают, обновление UI всегда возвращают в UI-поток. Устойчивее сначала копить принятые байты, а кадры из них выделять parser'ом.
Как проектировать тайм-ауты в последовательной связи?
Одного тайм-аута мало: стабильнее разделить их по смыслу. open timeout — до открытия порта, inter-byte timeout — пауза без байт внутри кадра, response timeout — от выдачи команды до завершения ответа, reconnect backoff — пауза перед повторным подключением. Тайм-аут — не страховка «на случай медленной линии», а правило, по которому автомат состояний идёт дальше. Если оставить синхронный read с настройками по умолчанию, легко получить бесконечное ожидание.
Почему после отключения USB-serial преобразователя приложение не восстанавливается?
Для USB-serial нормально, что порт на время исчезает, прежний handle становится недействительным, меняется номер COM, а предыдущий pending request теряет смысл. Повторить Open() как переподключение недостаточно. Надёжнее проектировать пересоздание сессии целиком: аннулировать сессию, завершить pending request ошибкой, остановить reader и writer, после backoff снова открыть порт и заново выполнить последовательность инициализации устройства. Так меньше сбоев переподключения, которые проявляются лишь изредка.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог