C# 的 SerialPort API 很容易打开端口,却不容易写出可停止、可重连、不会丢边界的长期运行通信模块。本篇以 .NET 10 和 System.IO.Ports 包为基线,重点讨论端口所有权、单一接收循环、发送串行化与取消操作。
1. 串口对象不是协议客户端
建议把通信栈拆成四层:
1
| 业务命令 → 请求/响应协调器 → 编解码器 → 串口传输
|
SerialPort 只负责传输字节。不要让 UI 直接调用 ReadLine,也不要在 DataReceived 里解析业务、更新控件和重试命令。否则端口生命周期、线程上下文与协议状态会纠缠在一起。把厂商差异挡在业务之外的做法见《设备软件架构与控制模型(二):硬件抽象层与设备适配器》。
项目需显式引用包:
1
| dotnet add package System.IO.Ports
|
包版本应与项目目标框架和组织依赖策略匹配,不要从文章复制一个未来会过期的固定版本号。
2. 配置必须来自设备契约
1
2
3
4
5
6
7
8
9
10
11
12
| using System.IO.Ports;
var port = new SerialPort("COM3")
{
BaudRate = 115200,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Handshake = Handshake.None,
ReadTimeout = 1000,
WriteTimeout = 1000
};
|
这些值不是通用默认答案。尤其不要在没有接线依据时随意启用 RtsEnable、DtrEnable 或硬件流控。
端口名也不应永久写死。Windows 上同一 USB 设备换插口后可能获得不同 COM 号,生产软件可同时记录 VID/PID、序列号和用户确认结果。
3. 选择一种接收所有权模型
官方文档明确说明:DataReceived 不保证每收到一个字节都触发,事件可能延迟,并在辅助线程上执行。因此事件只能表示“现在可能有数据可读”,不能表示“一帧到达”。
对新代码,更容易推理的模型是:打开端口后由一个后台任务持续读取 BaseStream,所有收到的字节都交给同一个解码器。不要同时使用 SerialPort.Read*、DataReceived 和 BaseStream.ReadAsync 争抢同一输入流。
4. 一个可控的传输骨架
下面示例只负责字节传输,不假装解决协议边界:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
| using System.IO.Ports;
public sealed class SerialTransport : IAsyncDisposable
{
private readonly SerialPort _port;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private CancellationTokenSource? _lifetime;
private Task? _receiveTask;
public SerialTransport(string portName, int baudRate)
{
_port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None
};
}
public void Open(Func<ReadOnlyMemory<byte>, ValueTask> onBytes)
{
if (_lifetime is not null)
throw new InvalidOperationException("Transport is already open.");
_port.Open();
_lifetime = new CancellationTokenSource();
_receiveTask = ReceiveLoopAsync(onBytes, _lifetime.Token);
}
private async Task ReceiveLoopAsync(
Func<ReadOnlyMemory<byte>, ValueTask> onBytes,
CancellationToken cancellationToken)
{
var buffer = new byte[4096];
while (!cancellationToken.IsCancellationRequested)
{
int count;
try
{
count = await _port.BaseStream.ReadAsync(buffer, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
if (count == 0)
throw new EndOfStreamException("Serial stream ended.");
// onBytes 必须在返回前消费或复制数据,不能保存这段可复用缓冲区。
await onBytes(buffer.AsMemory(0, count));
}
}
public async ValueTask WriteAsync(
ReadOnlyMemory<byte> bytes,
CancellationToken cancellationToken)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
await _port.BaseStream.WriteAsync(bytes, cancellationToken);
await _port.BaseStream.FlushAsync(cancellationToken);
}
finally
{
_writeLock.Release();
}
}
public async ValueTask DisposeAsync()
{
var lifetime = Interlocked.Exchange(ref _lifetime, null);
if (lifetime is null)
return;
await lifetime.CancelAsync();
_port.Close(); // 同时解除部分平台上无法及时响应取消的阻塞 I/O。
// 注意:§2 的 ReadTimeout 只约束同步 Read*(超时抛 TimeoutException);
// Windows 实现上 BaseStream.ReadAsync 不按 ReadTimeout 超时,
// 接收循环的退出依赖取消令牌与这里的 Close()。
if (_receiveTask is not null)
{
try { await _receiveTask; }
catch (Exception) when (lifetime.IsCancellationRequested) { }
}
lifetime.Dispose();
_writeLock.Dispose();
_port.Dispose();
}
}
|
这个骨架仍需由调用方定义:异常如何上报、断线后是否重建对象、关闭超时多久,以及回调过慢时采用有界队列还是背压。关闭时若仍有写入在途,直接释放 _writeLock 会让其 Release 抛出 ObjectDisposedException——生产实现应先等待写入静默、设置关闭宽限,或让锁的生命周期跟随最后一个写入者。
5. 缓冲区和消息边界
一次 ReadAsync 可能返回半帧、一帧或多帧。正确的数据流是:
1
| 任意字节块 → 累积缓冲区 → 帧解码器 → 完整消息
|
解码器必须保留未完成尾部,并限制最大帧长。否则错误长度字段可能让程序无限等待或持续扩容。若回调把 ReadOnlyMemory<byte> 放入队列,必须先复制,因为读取循环下一次会覆盖底层数组。
6. 请求/响应不能靠多个线程同时读
串口协议常规定“发一条命令,等一条应答”。不要让每个请求各自读取端口;应该只有一个接收循环,再由协调器按序列号、命令字或当前在途请求分发响应。
没有事务 ID 的半双工协议通常只能保留一个在途请求。超时后迟到的旧响应可能被误认作下一请求的响应,因此恢复策略往往需要清空输入、等待静默窗口或重新建立会话,而不只是立即重发。
7. 关闭、拔插与重连
把以下状态区分开:
- 主动停止:取消任务,不应记为通信故障;
- 端口被拔出:读取或写入可能抛出
IOException、UnauthorizedAccessException 等; - 端口被占用:
Open 失败,应提示占用而不是无限重试; - 协议超时:物理端口仍可能正常,不能直接等同于“串口断开”。
重连应创建新的传输会话并重新执行设备握手。指数退避要设置上限和抖动,同时允许用户立即停止;不要在异常回调里递归调用 Open。
8. 如何验证
没有实机时,可用成对虚拟串口或 USB 转串口回环测试:
- 分别测试 1 字节、半帧、多帧合并和超长垃圾输入;
- 在读取期间拔出设备,确认任务能退出且资源释放;
- 并发调用
WriteAsync,确认帧不会交错; - 让消费回调故意变慢,观察内存是否无界增长;
- 重连后验证旧请求不会收到新会话的响应。
9. 小结
稳定串口模块的核心不是 Open(),而是明确所有权:一个端口对象、一个接收循环、一个解码器、串行化写入,以及可取消的生命周期。下一篇将在这条字节流之上实现真正的二进制帧边界。
参考资料