串口通信应用的陷阱 - 涵盖重连与日志设计

· 更新日期: · · 串口通信, RS-232, C#, .NET, Windows开发, 设备联动

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276891)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615513)
首次发布
引用本文(DOI: 10.5281/zenodo.21615512)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《串口通信应用的陷阱 - 涵盖重连与日志设计》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615512 https://comcomponent.com/zh-CN/blog/2026/03/19/001-serial-communication-app-pitfalls/

DOI(最新版本)
10.5281/zenodo.21615512
DOI(此版本)
10.5281/zenodo.22282035

设备联动、仪器仪表、PLC、条码扫描枪、USB-串口转换器。 串口通信看起来是老技术,但在 Windows 应用的现场仍然用得相当普遍。

稍微危险的是,串口通信只要一个 COM 端口和一组 Read / Write 就能动手写起来。连通性确认很快就能跑通,可一旦上线,往往会出现下面这些症状。

  • 偶尔命令和应答会错位
  • 一天只卡死一次
  • 只有在 USB 拔插之后无法恢复
  • UI 时不时会停住
  • 翻日志只剩下一句 “Timeout”

串口通信应用真正难的地方不是收发 API 本身,而是边界、超时、状态迁移、重连、可观测性。

连通性确认能过,上线却会坏串口通信只要一个COM端口和Read与Write就能开始写,连通性确认很快通过,但上线后会出现应答错位、卡死、无法恢复等症状,真正的难关不是收发API本身,而是边界、超时、状态迁移、重连、可观测性的图。连通性确认很快通过上线后“偶尔”会坏应答错位、卡死、无法恢复难关不是收发API本身边界、超时、状态迁移、重连、可观测性

图1:连通性确认之后才出现的、串口通信应用真正的难关。

本文的目标读者与前提

项目 内容
目标读者 开发通过串口与设备或仪器相连的 Windows 应用的人。面向已经跑通连通性确认、却想减少上线后“偶尔”出故障的读者
预设的知识 能用 C# 写应用。不预设读者有串口通信本身的经验
预设的环境 正文以 .NET 的 System.IO.Ports.SerialPort 为前提,但边界、超时、状态迁移的思路与语言无关
不涉及的内容 电气接线的话题,以及特定设备的协议规格

本文使用的术语

术语 一句话说明
PLC Programmable Logic Controller。用于控制生产设备的工业控制器
RS-232 / RS-485 串口通信的电气规格。RS-232 是一对一,RS-485 可以在同一条线上挂多台设备。用 RS-485 时如果不规定谁在什么时候发送,就会发生冲突
8N1 端口设置的简写。指数据位 8、无校验(None)、停止位 1 的组合
DTR / RTS 控制线。本来是用来传达通信准备就绪或发送请求的线,但有些实际设备会把这些线的电平变化当作启动或切换模式的信号
流控 防止发送过快的机制。RTS/CTS 用控制线传达停止与恢复,XON/XOFF 则用数据中的特殊字符传达
keepalive 为确认通信对端是否还活着而定期发送的轻量命令
帧 一条消息对应的字节序列。从哪里到哪里算一帧,由协议一侧规定
single writer 把发送集中到一个 worker 的设计。意思是不让代码在任何地方都能直接 Write

1. 先说结论

先用偏实务的说法总结一下。

  • 串口通信是有序的字节流,消息边界不会自动附加上去
  • 调用了 Read(100),并不代表就一定返回刚好 100 字节
  • .NET 的 DataReceived 不保证每收到一个字节就触发一次,而且也不在 UI 线程上
  • ReadLine() / WriteLine() 只有在对端确实是按行的文本协议时才顺手
  • 只设一个超时是不够的。把 open、inter-byte、response、reconnect 等含义拆开会更稳定
  • 与其让代码在任何地方都能 Write,不如收敛到single writer,这样更不容易乱
  • 对 USB-串口,最好从一开始就把拔插、重新枚举、COM 编号变化、重连失败都当作前提

归根结底,串口通信应用的难关不在“端口能不能打开”,而在于如何把字节序列转换成有意义的消息,以及如何管理围绕它的时间和状态。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 18 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 串口通信不是“消息”,而是“有序的字节流”

从应用一侧看,串口通信像是“发一条命令,收一条应答”。但在下层,实际流动的只是有序的字节序列。

己方一次 Write 出去的内容,在对方那里可能呈现为下面几种样子。

  • 一次 Read 就收全了
  • 分成两次到达
  • 与其他数据连在一起到达

一旦丢掉这个前提,应用一侧就会开始想当然地认为“这次 Read 拿到的就是这次的应答”。这种想当然,很容易成为串口通信应用踩到的第一颗地雷。

一次Write的到达方式有三种己方一次Write出去的内容,在对方那里未必一次Read就收全,也可能分成两次到达,或者与其他数据连在一起到达的图。一次Write一次Read就收全分成两次到达与其他数据连在一起到达“这次Read就是这次的应答”并不成立

图2:一次 Write 在对方那里长成什么样,不到收下来的那一刻无从得知。

常见的想当然 实际情况
Read(16) 就会返回刚好 16 字节 视到达情况和超时而定,有时只能取到一部分
DataReceived = 一条消息到达 事件不保证按字节触发,也不在 UI 线程上
Write 返回了 = 对方处理完成了 多数情况下,它更接近于发送方把数据放进了缓冲区
COM 列表 = 当前连接的真实情况 枚举顺序不固定,枚举结果也可能已经过期

正因为如此,串口通信必须把消息边界作为协议由自己定义出来。固定长度帧、按分隔符切、长度 + payload + checksum,形式怎样都行,但含糊着就进入实现阶段,后面几乎一定会吃苦头。

边界要由自己定义串口通信必须把消息边界作为协议由自己定义,固定长度帧、按分隔符切、长度加payload加checksum等形式都可以,但含糊着进入实现阶段就会吃苦头的图。消息边界由自己定义固定长度帧按分隔符切长度+payload+checksum含糊着就会吃苦头

图3:消息边界在下层不会自动出现,所以要先作为协议定下来。

3. 应该最先定下来的事

动手写串口通信应用之前,至少要把这里列出的内容先定下来。

3.1 帧边界

先定下把哪一段字节序列视为一条消息。是固定长度,是按换行切,还是带长度字段,有没有 checksum / CRC。这里含糊,接收方就无法判断当前是“还没收全”还是“已经损坏”。

3.2 是文本、二进制,还是两者混合

先定下是 ASCII / UTF-8 的行协议,是纯二进制,还是两者混在一起。尤其是“命令部分是字符串,payload 是二进制,只有末尾有换行”这类混合形式,如果不明确到哪里为止要 decode、从哪里开始按原始字节处理,边界很快就会崩。

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 这类状态纳入设计。刚拔插完,对端可能还在启动过程中;有时也不能继续拖着上一次的挂起请求。

Open 请求open 成功 + 初始化序列完成open 失败 / 权限错误 / 初始化超时发送命令收到对应的应答帧response timeoutI/O 错误 / 检测到断线让挂起请求 fail 并开始 backoffbackoff 结束达到上限 / 手动停止Close 请求ClosedOpeningReadyFaultWaitingResponseReconnecting

图6:连接 session 的状态迁移。没有从 Fault 直接回到 Ready 的线。

这张图里重要的是,没有一条线从 Fault 直接回到 Ready。 出异常之后一定要经过 Reconnecting 和 Opening,把接收缓冲区、parser 状态、挂起请求、初始化序列都重建一遍,才能回到 Ready。在这里抄近路,就会掉进 4.7 说的“只是重新调用 Open() 就以为完成了重连”。

3.7 日志与可调查性

事后最让人头疼的,基本就是这一点。至少要保留 open / close / reopen 的时刻、所用的端口设置、收发帧的 hex dump、checksum / CRC 错误、frame timeout / response timeout,以及重连的原因。

4. 常见的陷阱

4.1 认为 一次 Read = 一条消息

最常见的就是这个。比如对端返回的帧由头部、长度、payload、CRC 组成。这时如果只调用一次 Read(buffer, 0, expectedLength),把返回值直接当成一整帧,遇到只收到一半的情况就很容易坏。

常见的坏法有下面 3 种。

  • 只读到长度,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 ---]   <- 容易只处理一帧就把剩下的丢掉

这 3 种模式都不是“数据损坏”,只是“分帧位置与 Read 的次数对不上”。如果在这里搞错,写成“到达的字节数与预期不同就按错误处理”,就会开始把正常通信记成错误。

对策很简单,就是把流程拆成接收先蓄积,再由 parser 从中切出帧。骨架代码放在 5.1。

先蓄积再切出把Read的返回单位直接当成一帧,遇到只收到一半就会坏,因此接收要先蓄积到缓冲区,再由parser从中切出帧的图。改为认为Read的返回=一帧只收到一半就很容易坏接收先蓄积到缓冲区parser从中切出帧

图7:把 Read 的返回单位和帧分开,先蓄积再切出。

4.2 把 DataReceived 直接当作业务事件

.NET 的 SerialPort.DataReceived 看起来很方便,但把它当成“一条消息到达的通知”就很危险。实务上应把 DataReceived 当成“好像来了点什么”这种程度的通知,处理程序里不做繁重处理,UI 更新务必切回 UI 线程。

4.3 认为在任何地方都可以 Write

UI 按钮、监控定时器、重连处理、keepalive 各自直接 Write 的结构很容易乱。串口是字节流,视设计不同,会出现命令插队、或者在等待应答期间又追加发送的情况。尤其是请求-应答型协议或 RS-485 这类场景,收敛到single writer 会稳定得多。

发送收敛到single writerUI按钮、监控定时器、keepalive和重连处理各自直接Write的结构,会出现命令插队和等待应答期间追加发送而容易乱,收敛到single writer会更稳定的图。改为UI按钮各自直接Write监控定时器keepalive、重连出现插队和追加发送集中到single writer

图8:不要增加直接 Write 的地方,把发送集中到一个 worker。

4.4 用 ReadLine() / WriteLine() 应付所有情况

如果是按行的文本协议,ReadLine() / WriteLine() 确实方便。但方便只限于确实是行协议的时候。一旦出现 NewLine 不一致、payload 中含有换行、字符编码差异、混入二进制,边界很快就会崩。

4.5 不设计超时,直接保留默认值

不假思索地放一个同步 read,很容易变成无限等待。更麻烦的是,设置好的 timeout 未必对所有读取方式都生效。在 UI 线程上做同步 read、只用一个 timeout 表达全部含义、只增加 retry 次数,这类实现都容易卡住。

4.6 轻视 RTS/CTS、XON/XOFF、DTR/RTS

握手和控制线,面对实际设备时相当关键。设置不一致时,往往表现为发送偶尔停住、超过一定量就丢数据、刚打开时行为不一样。有些实际设备还会把 DTR/RTS 的变化理解成启动或切换模式的信号。

4.7 只是重新调用 Open() 就以为完成了重连

尤其在 USB-串口场景里,端口暂时消失、旧句柄失效、上一次的挂起请求失去意义,这些都很常见。重连至少要把使 session 失效、让挂起请求 fail、停止 reader / writer、backoff 之后 reopen、重新执行设备初始化这一整套一起处理,才比较安全。

重连不是重新调用OpenUSB-串口场景中端口会消失、旧句柄会失效,因此重连要把使session失效、让挂起请求fail、停止reader和writer、backoff之后reopen、重新执行设备初始化一起处理的图。使session失效让挂起请求fail停止reader / writerbackoff之后reopen重新执行设备初始化只重新调用Open并不够

图9:把重连当作 session 的重建,这一整套要一起做。

4.8 把 COM 端口枚举结果当作真实情况

GetPortNames() 很方便,但出现在列表里和能成功 opening 并不是一回事。盲目相信上一次的 COM7、自动选择枚举结果的第一项、认为出现在列表里就算有效,这类实现在运维中很容易出问题。

4.9 收发日志太单薄

只有 TimeoutException、IOException、Port closed,基本什么都看不出来。把收发时刻、port profile、收发的 hex dump、parser error、这条 response 对应哪个 request、reconnect 的触发原因都记下来,排查就能推进不少。

先把格式定下来,之后既能 grep 也能做差分比较。比如定成这样的单行格式。

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

这里的意图有 3 个。

  • 把 RX 的行和 PARSE 的行分开。 RX 记的是“收到了多少字节”,PARSE 记的是“切出了几帧”。上面的例子里,一帧跨了第 2 次和第 3 次 RX 才到齐,多出来的部分成了下一帧的开头。把这两类混在一起记,事后就无法判断是不是发生了 4.1 说的分帧错位
  • 用 req= 让收发能够对上。 哪条应答对应哪条命令,事后单靠日志是还原不出来的
  • 用一行记下状态迁移。 只要留下 Ready -> Fault 这样的迁移和原因,重连的触发点就能直接追下去

hex dump 很占空间,所以现实的做法是两层:原始日志用环形缓冲区只留固定量,摘要日志长期保存。

收发日志的三个意图把RX的行和PARSE的行分开、用req让收发对上、用一行记下状态迁移这三个意图,以及原始日志用环形缓冲区留固定量、摘要日志长期保存的两层做法的图。把RX和PARSE的行分开事后能排查的日志用req让收发对上用一行记下状态迁移原始日志用环形缓冲区,摘要日志长期保存

图10:把收到的字节数和切出的帧分开记录,错位就能在事后追出来。

5. 最佳实践

最有效的做法,是把职责分开。

  • reader:只从端口读取字节序列
  • writer:只按顺序从发送队列写出
  • parser:只从字节序列中切出 frame
  • protocol:处理 request 与 response 的对应关系以及 checksum
  • app state:只更新业务状态

接收处理不要把 Read 的返回单位直接当成业务单位,而是先蓄积到缓冲区,再由 parser 切出 frame,这样的结构更稳定。发送集中到一个 worker,把实际的 Write 收敛到 single writer,可以减少顺序错乱。

超时也一样,与其用一个数字应付,不如按 open、inter-byte、response、reconnect 的含义拆开,这样更容易定位原因。端口设置与其散落成代码里的字面值,不如作为 profile 持有,并在 startup 时输出到日志,现场排查会轻松很多。

重连与其看作单纯的 reopen,不如当成session 重新生成,这样更稳定。把接收缓冲区、parser 状态、挂起请求、初始化序列、readiness 判定都一并重建,就更容易减少那种“偶尔才坏”的重连缺陷。

最后,建议原始日志和摘要日志都留。raw hex dump 和 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 字节)、CRC16(2 byte,小端序)。 形式怎样都行,关键在于按这个定义切帧,而不是按 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 byte,所以 256 字节以上会在强制转换时回绕。
        // 而 payload 仍然被整块复制,于是接收方按被截断的长度切帧,
        // 把 payload 中间的某处读成 CRC。之后的帧边界也会全线崩溃。
        // 是拆分发送还是把 LEN 改成 2 byte,属于协议的约定,
        // 这里只负责拦下来
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"一帧的 payload 最多 {byte.MaxValue} 字节(因为 LEN 只有 1 byte)。");
        }

        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>3.3 说的 inter-byte timeout。放弃正在组装的帧之前等待的时间。</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 byte 的 STX 重新读,属于设计判断。
                // 前者简单,后者在 LEN 本身就是噪声时更稳。选哪个要定下来并写进文档。
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// 如果正在组装的候选超过了 AssemblyTimeout,就只丢掉开头 1 byte 的 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 就是 3.3 提到的 inter-byte timeout 的实体。没有它,噪声或者假的 STX 把 LEN 变成一个很大的值(比如 255)时,parser 就会一直把它当作“还没收全”。连之后到达的正确帧也会被当成那个被打乱的 payload 的一部分吸进来,直到凑满 259 字节被 CRC 判掉之前,什么都不会上报。在通信量小的设备上,这看起来就是几分钟没有反应。之所以只丢掉 1 byte 的 STX,是因为真正的 STX 有可能就埋在候选里面。

这里写两点前提。这个超时只在 Append 被调用时才会判定。如果线路完全变得没有声音,parser 一侧什么都不会发生,那部分要靠调用方的 response timeout(5.2)来兜住。另一点是,AssemblyTimeout 的值请根据波特率和帧长来定。1 字节的传输时间 × 预计的最大帧长再加上余量,是它的下限,比这更短就会把正常的帧半路丢掉。把 Resynchronized 的触发次数写进日志,可以作为怀疑接线或波特率设置的依据。

被打乱的LEN会吸走正确的帧噪声或假的STX让LEN变成很大的值时,parser会一直当作还没收全,把之后到达的正确帧也当作被打乱的payload吸进来,直到CRC判掉之前看起来毫无反应,因此要靠超时丢掉1字节STX来重新同步的图。对策噪声或假的STX让LEN被打乱parser一直当作“还没收全”而等待把正确的帧也吸进来直到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,
            // 默默丢掉就会永远等不回来
            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("没有收到应答。"));

                    // 一旦超时,这条连接就已经不可信了。
                    // 不能用“放弃这条,接着发下一条”来收场,理由写在下面:
                    // 这个协议里没有请求 ID,所以迟到的 A 的应答
                    // 会被关联成下一条 B 的应答。按 3.6 的状态迁移图
                    // 落到 Fault,重新生成 session
                    throw new TimeoutException("因为没有应答,将重新生成 session。");
                }
            }
        }
        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 一起停掉,看着粗暴,却是必要的处置。这个帧里没有放请求 ID。因此接收方无法判别“刚到的这一帧是哪条命令的应答”,OnFrameReceived 只能机械地把它关联到正在等待应答的那一条上。

如果超时之后照样接着发下一条,就会变成这样。

  1. 发送命令 A。规定时间内没有应答,判为超时
  2. 发送下一条命令 B
  3. 迟到的 A 的应答,被当作 B 的应答交给调用方

从调用方看,明明发的是 B,回来的却是 A 的值。由于值的格式是正确的,校验也会通过,这是最难被发现的坏法。3.6 的状态迁移图里之所以没有画从 Fault 直接回到 Ready 的线,就是因为存在这条路径。超时不是“一条请求失败了”,而是“这条连接已经不可信了”这一判断;在不知道接收缓冲区里还剩什么的状态下,只有关闭端口再重新打开的 session 重新生成才能保证恢复。

如果能改动协议一侧,给帧加上请求 ID,用它来对上应答才是更根本的解决。这样一来,迟到的应答只要按“ID 不认识,丢掉”处理就行,也就不必每超时一次都重建连接。

迟到应答被张冠李戴在没有请求ID的协议里,命令A超时之后发送命令B,迟到的A的应答会被当作B的应答交给调用方,因此超时之后要落到Fault并重新生成session的图。设备worker调用方设备worker调用方规定时间内没有应答,超时所以超时之后要落到Fault发送命令A发出A发送命令B发出B迟到的A的应答被当作B的应答交了出去

图13:没有请求 ID 时,迟到的应答会被关联到下一条命令上。

最后把它们组装起来。端口设置作为 profile 放在一处,并在启动时输出到日志,这一段属于 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 失败不是例外而是日常——设备不应答、电缆被拔掉、写入超时。这时如果就那样往上层抛出去,就会在没停掉 worker 的情况下走到 using 的释放。SerialPort 被关闭之后,reader / writer 仍会去碰那个流,那里抛出的异常没有任何人观测得到。如果是常驻应用,就会以只有失败操作的 worker 留了下来的形式一点点堆积。finally 一侧的异常没有重新抛出,是为了不掩盖真正的失败原因(SendAsync 的异常)。

失败时更要停掉workerSendAsync的失败在串口通信中是日常,就那样往上抛会在没停掉worker的情况下释放SerialPort,reader和writer继续去碰已关闭的流而异常无人观测,因此要在finally里取消并等待汇合的图。SendAsync失败(日常)在finally里cancel并等待汇合先停掉worker再释放跳过就会继续碰已关闭的流常驻应用里每失败一次就残留一个worker

图14:正因为发送失败了,才更要在 finally 里停掉 worker 再退出。

做成这个形式之后,以后想加的东西——比如 retry、keepalive、reconnect——都会落到往队列里放的一侧或者worker 一侧之一。直接调用 Write 的地方不会变多,顺序错乱的原因也就不会变多。

6. 首先要看的检查清单

  • 消息边界是否已经写明
  • 接收是否做成了字节蓄积 → 切出 frame
  • 有没有把 DataReceived 当成消息到达
  • 有没有在 UI 线程上做同步 I/O
  • 发送是否已经做成 single writer
  • timeout 是否按含义拆开,而不是只有一个
  • Handshake / DTR / RTS 是否已经明确写出
  • 重连时是否重新生成了 session
  • 有没有保留 raw hex dump
  • 有没有做过实际设备的拔插和中途断开测试

如果这里面有好几项都让人心里没底,那么在正式上线之前值得先停下来看一看。

7. 总结

最后再把要点排一遍。

  • 串口通信不是消息,而是字节流
  • Read 的单位和消息的单位并不一致
  • 边界需要作为协议定义出来
  • 把 DataReceived 直接当作业务事件很容易坏
  • 收发要分离职责,发送收敛到 single writer
  • timeout 按含义拆开,重连按 session 来设计
  • 包含 raw hex dump 的日志,能让事后调查轻松很多

也就是说,在串口通信应用里,比能不能打开端口重要得多的,是如何解释字节序列、如何控制时间和状态。只要一开始就把这两件事分开设计,“偶尔才坏”这类通信缺陷就能减少不少。

比打开端口更重要的是解释与控制在串口通信应用里,比打开端口更重要的是如何解释字节序列以及如何控制时间和状态,一开始就分开设计能减少偶尔才坏的通信缺陷的图。打开端口这里不是难关字节序列的解释一开始就分开设计时间和状态的控制“偶尔才坏”的缺陷会减少

图15:重要的不是打开端口,而是解释与控制的设计。

8. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

在串口通信中调用 Read(16) 就一定能收到刚好 16 字节吗?
不一定。因为串口通信是有序的字节流,消息边界不会自动附加上去。己方一次 Write 出去的内容,在对方那里可能分成两次到达,也可能与其他数据连在一起到达。只读到长度而 payload 还没到、只到了一帧半、两帧一起到达,都是典型的出错方式。对策是把接收先蓄积到缓冲区,再由 parser 从缓冲区中切出帧。
使用 .NET 的 SerialPort.DataReceived 事件时要注意什么?
DataReceived 不保证每收到一个字节就触发一次,也不在 UI 线程上执行。把它当成“一条消息到达的通知”很危险。实务上应把它当成“好像来了点什么”这种程度的通知,处理程序里不做繁重处理,UI 更新务必切回 UI 线程。收到的字节序列先蓄积,再由 parser 切出帧,这样的结构更稳定。
串口通信的超时应该如何设计?
只设一个超时是不够的,按含义拆分会更稳定:打开端口所需的 open timeout、帧中途字节不来的 inter-byte timeout、从发出命令到应答完成的 response timeout,以及重连等待间隔的 reconnect backoff。超时不是“慢的时候的保险”,而应作为推进状态迁移的规则来持有,这样才稳定。另外还要注意,不假思索地保留默认设置去做同步 read,很容易变成无限等待。
USB-串口转换在拔插电缆之后为什么无法恢复?
因为在 USB-串口场景里,端口暂时消失、旧句柄失效、COM 编号变化、上一次挂起的请求失去意义,这些都很常见。只是重新调用 Open() 并不足以算作重连。把重连设计成一次“session 重新生成”,包含使 session 失效、让挂起请求 fail、停止 reader 和 writer、backoff 之后 reopen、重新执行设备初始化序列,可以减少那种偶尔才出现的重连故障。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表