设备通信与协议编程(五):TCP 设备协议与请求响应模型

把设备从串口换成 TCP,并不会自动获得消息边界、请求关联、心跳或重连语义。TCP 提供可靠、有序的双向字节流;设备应用协议仍要定义一条消息如何编码,以及连接中断后业务处于什么状态。

1. TCP 保证什么,不保证什么

RFC 9293 定义的 TCP 关键性质包括可靠、有序、字节流传输。它不保证:

  • 一次 Send 对应对端一次 Receive
  • 应用命令执行且持久化;
  • 连接空闲时设备一定仍在线;
  • 自动区分请求、响应和设备事件;
  • 断线重连后延续旧会话状态。

“没有丢字节”与“业务操作成功”是两个层次。

2. 在字节流上建立帧

TCP 接收端必须复用本系列《设备通信与协议编程(三):二进制协议帧、消息边界与版本兼容》建立的增量解析模型。长度前缀是常见选择:先读固定头、验证长度上限,再累积载荷。不要用 NetworkStream.DataAvailable 判断消息结束;它只表示此刻是否有可立即读取的数据。

同样不要假设 ReadAsync 会填满缓冲区。需要固定长度时应循环读取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static async ValueTask ReadExactlyAsync(
    Stream stream,
    Memory<byte> buffer,
    CancellationToken cancellationToken)
{
    int offset = 0;
    while (offset < buffer.Length)
    {
        int count = await stream.ReadAsync(buffer[offset..], cancellationToken);
        if (count == 0)
            throw new EndOfStreamException("Peer closed the connection.");

        offset += count;
    }
}

.NET 的 Stream.ReadExactlyAsync 已提供类似能力;手写版本用于说明循环读取的不变量。

3. 连接生命周期

 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
using System.Net.Sockets;

public static async Task RunSessionAsync(
    string host,
    int port,
    CancellationToken cancellationToken)
{
    using var client = new TcpClient();
    await client.ConnectAsync(host, port, cancellationToken);
    await using NetworkStream stream = client.GetStream();

    // 在这里完成协议握手,再启动唯一的接收循环。
    await ReceiveFramesAsync(stream, cancellationToken);
}

static async Task ReceiveFramesAsync(
    Stream stream,
    CancellationToken cancellationToken)
{
    var buffer = new byte[4096];
    while (true)
    {
        int count = await stream.ReadAsync(buffer, cancellationToken);
        if (count == 0)
            return;

        // 把 buffer[0..count] 交给增量帧解码器。
    }
}

TcpClient.Connected 只反映最近一次 I/O 已知的状态,不能作为当前在线证明。真正发现失效通常依赖读写结果、协议心跳或业务超时。

4. 请求/响应协调器

若协议支持并发请求,每条请求应携带事务 ID(例如第 3 篇帧结构中的 Sequence 字段)。客户端维护:

1
transactionId → TaskCompletionSource<Response>

接收循环解析完整响应后完成对应任务;设备事件则进入独立事件通道。实现时要保证:

  • 注册等待项发生在发送前,避免响应过快造成竞态;
  • 超时、取消、断线都会从表中移除等待项;
  • 事务 ID 有界且可回绕,不与在途请求重复;
  • 未知或重复响应会记录并丢弃,不完成错误请求;
  • 断线时一次性失败当前连接世代的全部等待项。

不支持事务 ID 的协议通常只能串行执行请求。

5. 发送也需要单一所有者

多个任务同时向同一 NetworkStream 写入时,即使每次写都成功,两个应用帧仍可能在调用层交错。用发送队列或锁保证“一帧编码完成后整体写入”。大帧要考虑背压,不要让无限队列吞掉内存。

高频小消息是否启用 Nagle 算法要通过协议时延和吞吐测试决定。NoDelay = true 不是设备通信的通用必选项。Nagle 与 TCP_NODELAY 的机制与参数详见《TCP/IP(九):TCP 进阶——SACK、Nagle、keepalive 与端口复用》。

6. 超时分层

至少区分:

超时含义
连接超时TCP 建连在预算内未完成
写入超时数据未能在预算内交给传输栈
首字节/完整帧超时对端未及时开始或完成响应
业务超时设备操作本身未在预期时间完成
空闲心跳超时长期无业务时检测会话可用性

一个总的 Timeout = 3000 无法告诉运维究竟慢在哪里。超时预算还应由上层截止时间向下传递,避免每层各等三秒。

7. TCP keepalive 与应用心跳

TCP keepalive 是实现可选的连接探测机制,RFC 9293 要求默认关闭且默认探测间隔不得短于两小时。操作系统允许调整时,它仍主要判断传输连接,不了解设备业务线程是否卡死。keepalive 机制与参数调优同样见《TCP/IP(九)》。

应用心跳可以携带协议版本、设备状态和会话世代,但会增加负载。若正常业务本就持续往返,可直接以业务响应作为活性证据,避免重复心跳。

8. 安全边界

原始 TCP 不提供机密性和对端身份。跨越不可信网络时应使用 TLS、VPN 或协议规定的安全层,并验证证书和设备身份。不要自创“异或加密”。

即使在隔离网络,解析器仍应限制帧长、并发请求数、发送队列和日志载荷,防止故障设备拖垮上位机。

9. 小结

TCP 解决可靠有序字节流,设备协议负责消息、事务和业务结果。稳定客户端需要唯一接收循环、明确帧边界、可关联的请求响应、有界发送队列和分层超时。下一篇将在此基础上处理断线、迟到响应和“操作到底执行了没有”的灰色状态。

参考资料

Licensed under CC BY-NC-SA 4.0