设备通信与协议编程(二):C# 串口生命周期、缓冲区与异步接收

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
};

这些值不是通用默认答案。尤其不要在没有接线依据时随意启用 RtsEnableDtrEnable 或硬件流控。

端口名也不应永久写死。Windows 上同一 USB 设备换插口后可能获得不同 COM 号,生产软件可同时记录 VID/PID、序列号和用户确认结果。

3. 选择一种接收所有权模型

官方文档明确说明:DataReceived 不保证每收到一个字节都触发,事件可能延迟,并在辅助线程上执行。因此事件只能表示“现在可能有数据可读”,不能表示“一帧到达”。

对新代码,更容易推理的模型是:打开端口后由一个后台任务持续读取 BaseStream,所有收到的字节都交给同一个解码器。不要同时使用 SerialPort.Read*DataReceivedBaseStream.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. 关闭、拔插与重连

把以下状态区分开:

  • 主动停止:取消任务,不应记为通信故障;
  • 端口被拔出:读取或写入可能抛出 IOExceptionUnauthorizedAccessException 等;
  • 端口被占用:Open 失败,应提示占用而不是无限重试;
  • 协议超时:物理端口仍可能正常,不能直接等同于“串口断开”。

重连应创建新的传输会话并重新执行设备握手。指数退避要设置上限和抖动,同时允许用户立即停止;不要在异常回调里递归调用 Open

8. 如何验证

没有实机时,可用成对虚拟串口或 USB 转串口回环测试:

  1. 分别测试 1 字节、半帧、多帧合并和超长垃圾输入;
  2. 在读取期间拔出设备,确认任务能退出且资源释放;
  3. 并发调用 WriteAsync,确认帧不会交错;
  4. 让消费回调故意变慢,观察内存是否无界增长;
  5. 重连后验证旧请求不会收到新会话的响应。

9. 小结

稳定串口模块的核心不是 Open(),而是明确所有权:一个端口对象、一个接收循环、一个解码器、串行化写入,以及可取消的生命周期。下一篇将在这条字节流之上实现真正的二进制帧边界。

参考资料

Licensed under CC BY-NC-SA 4.0