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

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

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

有点危险的是,串口通信可以只用一个 COM 端口一组 Read / Write 就开始写了。通信测试很快就能跑通,但一旦上线,往往会出现下面这些症状:

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

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

1. 先说结论

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

  • 串口通信是有序的字节流,消息边界不会自动附加
  • 调用 Read(100) 并不代表就一定能拿到刚好 100 字节
  • .NETDataReceived 不保证每个字节都触发一次,而且也不在 UI 线程上
  • ReadLine() / WriteLine() 只有在对方真正是按行文本协议时才好用
  • 一个超时是不够的。把 openinter-byteresponsereconnect 等含义区分开会更稳定
  • 与其允许在任何地方都能 Write,不如统一收敛到single writer,这样更不容易崩坏
  • 对 USB-串口来说,最好从一开始就假定会发生拔插、重新枚举、COM 编号变化、重连失败

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

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

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

己方一次 Write 的内容,在对方那里可能会呈现为以下几种情况:

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

如果忽略这个前提,应用侧就会开始假设”这次 Read 应该就是这次的应答”。这种假设,很容易成为串口通信应用最早踩上的地雷。

常见的错误假设 实际情况
Read(16) 就能刚好返回 16 字节 根据到达情况和超时,有时只能拿到一部分
DataReceived = 收到一条消息 事件并不保证按字节触发,也不在 UI 线程上
Write 返回了 = 对方已经处理完成 多数情况下,这只是发送方把数据放进了缓冲区
COM 列表 = 当前连接的真实状态 枚举顺序不固定,枚举结果也可能是过期(stale)的

正因为如此,串口通信需要把消息边界作为协议由自己来定义。固定长度帧、分隔符方式、长度 + payload + checksum,形式可以随意,但如果含糊不清地进入实现阶段,之后几乎一定会吃苦头。

3. 应该最先决定的事

在动手写串口通信应用之前,至少应该先决定下面这些内容。

3.1 帧边界

要决定把哪一段字节序列视为一条消息。是固定长度,还是按换行分隔,还是带长度前缀,是否有 checksum / CRC。这里如果含糊,接收方就无法判断是”还没收全”还是”已经损坏”。

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

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

3.3 超时的含义

超时最好不要只设一个,而是按含义拆分开来考虑更安全:

  • open timeout:打开端口所需的时间
  • inter-byte timeout:帧中途字节不来的时间
  • response timeout:从发出命令到应答完成的时间
  • reconnect backoff:重连的等待间隔

超时不应被当作”慢的时候的保险”,而应作为推动状态迁移的规则来持有,这样更稳定。

3.4 流控与线路状态

应该明确设置的项目大致如下:

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

如果用”大概 8N1 就差不多了”来敷衍,遇到某些设备时会直接卡死。

3.5 职责分离

要划分清楚谁负责什么。

  • 谁来读
  • 谁来写
  • 谁来解析
  • 谁来把结果反映到业务状态

串口通信中,UI 和通信混在一起的程度越高,就越容易崩坏。

3.6 启动、停止、重连的状态迁移

至少应该在设计中纳入 ClosedOpeningReadyWaitingResponseFaultReconnecting 这类状态。拔插之后,对方可能还在启动过程中,也不应该继续拖着上一次的挂起请求。

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
  • 一次收到两帧,只处理了第一个就把剩下的丢掉了

对策很简单,就是先把接收蓄积起来,再由 parser 从中切出帧

4.2 把 DataReceived 直接当作业务事件

.NETSerialPort.DataReceived 看起来很方便,但如果把它当作”收到一条消息的通知”就很危险。实务上应该把 DataReceived 当作”好像收到了什么”这种程度的通知,处理程序中不做繁重处理,UI 更新务必切回 UI 线程。

4.3 认为随时随地都可以 Write

UI 按钮、监控定时器、重连处理、keepalive 各自直接 Write 的结构很容易崩坏。因为串口是字节流,根据设计不同,可能会发生命令被打断,或在等待应答期间又追加发送的情况。尤其是请求-应答型协议或 RS-485 系列,收敛到single writer 会相当稳定。

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,以及重新执行设备初始化,这样处理才更安全。

4.8 把 COM 端口枚举结果当作真实状态

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

4.9 收发日志过于薄弱

只有 TimeoutExceptionIOExceptionPort closed 这类信息,几乎什么都分析不出来。如果能记录收发时刻、port profile、收发的 hex dump、parser 错误、对应哪个 request 的 response、以及触发 reconnect 的原因,排查会顺利得多。

5. 最佳实践

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

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

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

超时也不要只用一个数字应付,而应按 open、inter-byte、response、reconnect 的含义拆分,这样更容易定位问题。端口设置最好作为 profile 持有,而不是散落在代码中的字面值,并在 startup 时输出到日志,这样现场排查会轻松很多。

重连不应只是简单的 reopen,而应当作session 重新生成来看待,这样更稳定。把接收缓冲区、parser 状态、挂起请求、初始化序列、readiness 判断都一并重新构建,可以减少”偶尔才发生”的重连故障。

最后,建议同时保留原始日志和摘要日志。原始 hex dump 与 open / close 的历史记录在排查时很有力,request id 和重试次数的摘要则对运维很有帮助。

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 的日志,能让事后排查轻松很多

也就是说,串口通信应用中,比打开端口更重要的,是如何解释字节序列、如何控制时间和状态。只要在最初把这两点分开设计,”偶尔才崩坏”这类通信故障就能大幅减少。

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 失效化、让挂起请求 fail、停止 reader 和 writer、backoff 后 reopen,以及重新执行设备初始化序列的「session 重新生成」,可以减少那种偶尔才发生的重连故障。

作者简介

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

Go Komura

小村软件有限公司 代表

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

返回博客列表