串口通信应用的陷阱 - 直到重连与日志设计
· 小村 豪 · 串口通信, RS-232, C#, .NET, Windows开发, 设备联动
设备联动、仪器、PLC、条码扫描仪、USB-串口转换。 串口通信看起来是老技术,但在 Windows 应用的现场中仍然相当常见。
有点危险的是,串口通信可以只用一个 COM 端口和一组 Read / Write 就开始写了。通信测试很快就能跑通,但一旦上线,往往会出现下面这些症状:
- 偶尔命令和应答会错位
- 一天里只卡死一次
- USB 拔插之后无法恢复
- UI 时不时会卡住
- 日志里只留下一句 “Timeout”
串口通信应用真正难的地方,并不是收发 API 本身,而是边界、超时、状态迁移、重连、可观测性这些方面。
1. 先说结论
先用偏实务的说法总结一下。
- 串口通信是有序的字节流,消息边界不会自动附加
- 调用
Read(100)并不代表就一定能拿到刚好 100 字节 .NET的DataReceived不保证每个字节都触发一次,而且也不在 UI 线程上ReadLine()/WriteLine()只有在对方真正是按行文本协议时才好用- 一个超时是不够的。把
open、inter-byte、response、reconnect等含义区分开会更稳定 - 与其允许在任何地方都能
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 流控与线路状态
应该明确设置的项目大致如下:
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
如果用”大概 8N1 就差不多了”来敷衍,遇到某些设备时会直接卡死。
3.5 职责分离
要划分清楚谁负责什么。
- 谁来读
- 谁来写
- 谁来解析
- 谁来把结果反映到业务状态
串口通信中,UI 和通信混在一起的程度越高,就越容易崩坏。
3.6 启动、停止、重连的状态迁移
至少应该在设计中纳入 Closed、Opening、Ready、WaitingResponse、Fault、Reconnecting 这类状态。拔插之后,对方可能还在启动过程中,也不应该继续拖着上一次的挂起请求。
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 直接当作业务事件
.NET 的 SerialPort.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 收发日志过于薄弱
只有 TimeoutException、IOException、Port closed 这类信息,几乎什么都分析不出来。如果能记录收发时刻、port profile、收发的 hex dump、parser 错误、对应哪个 request 的 response、以及触发 reconnect 的原因,排查会顺利得多。
5. 最佳实践
最有效的做法,是把职责分开。
reader:只负责从端口读取字节序列writer:只负责按顺序从发送队列写出parser:只负责从字节序列中切出 frameprotocol:处理请求与应答的对应关系以及 checksumapp 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. 参考资料
- Microsoft Learn,
SerialPort.DataReceivedEvent - Microsoft Learn,
SerialPort.ReadMethod - Microsoft Learn,
SerialPort.ReadTimeoutProperty - Microsoft Learn,
SerialPort.BaseStreamProperty - Microsoft Learn,
SerialPort.NewLineProperty - Microsoft Learn,
HandshakeEnum - Microsoft Learn,
SerialPort.DtrEnableProperty - Microsoft Learn,
SerialPort.RtsEnableProperty - Microsoft Learn,
SerialPort.GetPortNamesMethod - Microsoft Learn,
SerialPortClass - Microsoft Learn,
COMMTIMEOUTSstructure - Microsoft Learn,
DCBstructure - Microsoft Learn,
CreateFilefunction - pySerial API, Serial API Reference
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)
本文整理在 C# 中通过 P/Invoke 调用 Win32 API 或原生 DLL 时的实务要点:DllImport 与 LibraryImport 的区别、用 CsWin32 自动生成签名、字符串封送的陷阱、用 SafeHandle 管理句柄、SetLastError ...
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
如何从 C/C++ 调用 C# Native AOT DLL
本文整理如何把 C# 类库通过 Native AOT 发布为原生 DLL,并通过 UnmanagedCallersOnly 的入口点让 C/C++ 调用它,从适用场景、实现模式到注意事项逐一梳理。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
FileSystemWatcher 实务指南:应对遗漏通知与重复通知
本文从遗漏通知、重复通知、完成判定的陷阱、重新扫描、原子式 claim、idempotency(幂等性)等角度,整理 FileSystemWatcher 的使用方法与注意事项。
常见问题
汇总了咨询这一主题时常见的问题。
- 在串口通信中调用 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。