ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#与MCGS TCP通信实战:从链路配置到报文解析

C#与MCGS TCP通信实战:从链路配置到报文解析

简介:面向工业自动化上位机开发者的C#与MCGS(昆仑通态)TCP通信实战例程,用Visual Studio 2013编写,重点解决上位机软件读取人机界面数据的通信问题,适合已具备C#基础、希望在工控项目中落地Socket通信的初中级开发者参考。压缩包共36个文件、约92KB,以9个cs源码文件为主,辅以txt说明、config配置、exe可执行程序,并附带sln解决方案、csproj工程、resx资源及MCE看板工程,构成一套可直接运行的完整工程。已有2507人学习下载。读者可从中获得连接建立、数据帧收发与解析的代码骨架,借助解决方案文件快速打开调试,对照看板工程理解画面变量与通信数据的映射关系,再按需改造到自己的设备采集场景中。

1. C# 与 MCGS 走 TCP:为什么很多上位机项目最后都绕不开这条线

车间里那台触摸屏要往上位机报数,中间隔了十几米线槽和一台交换机,串口拉不过去,最省事的办法就是两边都挂到以太网上。这份 C# 与 MCGS(昆仑通态)TCP 通信范例代码解决的就是这件事:一份能在上位机端直接跑的 C# 通信代码,配一套 MCGS 侧的设备与变量配置说明,把屏里的温度、产量、报警位读出来,也能把上位机算好的参数写回去。适合手里有 MCGS 触摸屏或通用版组态、需要自己写上位机采集程序的工控开发人员,也适合平时只用过串口 Modbus、第一次碰网口通信的同行——串口那套“打开就能收”的直觉,在 TCP 上有一半是错的。

2. 链路先立住:MCGS 侧的设备配置与 C# 侧的选型

很多人一上来就写 Socket,写着写着发现屏那头根本没在听,回头又去改代码,来回折腾两天。稳妥的顺序是反过来的:先把 MCGS 侧的通信角色定死,再决定 C# 端该监听还是该主动连。这一章解决的就是“谁连谁”和“用什么连”,两个决定做错,后面所有代码都是白写。

2.1 MCGS 在 TCP 通信里的两种角色:客户端还是服务端

MCGS 在设备窗口里挂 TCP 通信,本质是给它配一个“通用 TCP/IP 父设备”,再在下面挂子设备或者通道。父设备里能设本地 IP、本地端口、远程 IP、远程端口,还有一项很关键:通信方向。这一项决定了是屏主动往外连,还是屏在本地端口上等别人连进来。

角色分配谁主动发起连接典型场景现场要盯住的东西
MCGS 做客户端触摸屏主动连上位机多台屏汇总到一台服务器,服务器 IP 固定上位机必须是固定 IP,屏里远程 IP 别填错网段
MCGS 做服务端上位机主动连触摸屏单机设备、上位机就近部署、一台机器一个屏屏的本地 IP 要固定,端口别和屏上其他服务撞车

我的习惯是:设备数量大于三台,一律让屏做客户端、上位机做服务端。理由很实际——服务端只需要在一个地方开端口,防火墙策略、端口映射、后续加屏都只动上位机;反过来的话,每加一台屏就要在上位机配置里加一条目标 IP,维护量是线性涨的。

需要注意的是,不是所有型号的屏内固件都开放“通信方向”这个选项。有些版本只支持主动连接,也就是固定做客户端。配之前先在设备窗口里点开父设备属性看一眼,没有方向选项的,就别按服务端方案设计,否则联调时会一直卡在“对面没反应”。

变量这一侧,MCGS 里被通信读写的变量要单独确认连接属性:变量挂在哪个父设备下、对应哪个通道、数据类型是什么。这一步最容易漏,很多人把变量建好了,但没挂到 TCP 父设备下,屏上数值在动,网络上却一个字节都不发。

2.2 C# 侧选 TcpClient、Socket 还是第三方库

上位机端的选择不多,实际项目里就三种走法。

方案上手成本可控性适合什么情况
TcpClient / TcpListener低中单条链路、报文简单、团队里没人想碰底层
裸 Socket中高高需要精细控制超时、KeepAlive、多路复用
第三方通信库低低协议已经标准化、不需要自己定帧格式

跟 MCGS 对接这种场景,报文是自己定的,链路数量通常个位数,TcpClient 完全够用,它底层就是 Socket 的封装,该有的 NoDelay、超时、异步方法都在。裸 Socket 的优势主要在细粒度参数上,比如自己调 SO_RCVBUF、自己设 KeepAlive 的探测间隔,这些在长连接跑几天就断的现场确实有用,但如果链路本身有应用层心跳,这些优势就没那么关键了。

第三方库我不太推荐用在这类项目上。自定义报文的拆包逻辑本来就要自己写,库帮你解决的只是连接管理,反而多一层黑匣子,出问题时排查路径变长。

框架版本上,.NET Framework 4.6.2 以上和 .NET 6/8 都能用 TcpClient 的异步方法,区别在于ConnectAsync(host, port, cancellationToken)这个带取消令牌的重载是 .NET 5 之后才有的。老框架项目里要自己拿Task.WhenAny加超时,写法不一样,别直接抄。

2.3 最小回环验证:先让两个进程说上话再谈业务

代码一行不写,先验证链路通不通。这一步花十分钟,能省掉后面半天的怀疑人生。

先在 MCGS 侧把父设备配好、下载到屏、让屏跑起来,然后在上位机这台机器上做端口探测:

# 把 192.168.1.50 和 6000 换成屏的实际 IP 和端口 Test-NetConnection -ComputerName 192.168.1.50 -Port 6000 -InformationLevel Detailed

TcpTestSucceeded为 True 只说明三次握手通了,不代表屏那边的协议在正常工作,但 False 就一定是链路层的问题,往下写代码没有意义。返回 False 时按这个顺序查:网段能不能 ping 通、屏的本地 IP 是不是配成了 0.0.0.0、端口有没有被屏上别的服务占用、上位机这台的防火墙有没有拦出站。

如果 MCGS 做服务端、C# 做客户端,反向也要验一次:先让 C# 端跑起来监听,再用同样的命令从另一台机器探。同一台机器上自己连自己是最容易骗人的,127.0.0.1通了不代表局域网通。

回环验证通过之后,再写第一条业务报文。先用一个“心跳命令”当探针:命令字固定、数据区为空、对面原样回一个。这条报文跑通了,说明帧头、长度、校验三件事双方理解一致,后面加读写指令只是往数据区里塞东西。

3. C# 通信层实现:连接管理、收包循环与分帧

链路通了之后,真正的坑才刚开始。TCP 是字节流,不是消息流,这个区别决定了你不能“发一次就读一次”。这一章按连接、收包、分帧、异步四层往下拆,每层给一段能直接抄的代码。

3.1 连接管理:断线重连的最小骨架

现场网络不会一直好,交换机重启、网线被叉车压断、屏重启,都是常态。连接管理的核心不是“怎么连上”,是“断了之后怎么干净地重来”。

using System; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; // 一条 TcpClient 只对应一条链路,断开后整体重建,不要试图复用 public sealed class TcpLink { private readonly string _host; private readonly int _port; private TcpClient? _client; private NetworkStream? _stream; public TcpLink(string host, int port) => (_host, _port) = (host, port); public NetworkStream Stream => _stream ?? throw new InvalidOperationException("未连接"); public async Task<bool> ConnectAsync(int timeoutMs = 3000) { _client = new TcpClient { NoDelay = true // 小报文场景关掉 Nagle,避免几十毫秒的合并延迟拖慢轮询 }; using var cts = new CancellationTokenSource(timeoutMs); try { await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false); _stream = _client.GetStream(); return true; } catch (OperationCanceledException) { /* 连接超时,目标没响应 */ } catch (SocketException) { /* 端口拒绝、目标不可达 */ } _client.Dispose(); _client = null; _stream = null; return false; } public void Close() { _stream?.Dispose(); _client?.Dispose(); _stream = null; _client = null; } }

逻辑上就三件事:建对象、设参数、拿流。NoDelay = true是这段里最容易被忽略又最影响体感的一行,Nagle 算法会把小包攒着一起发,轮询周期设成 100ms 的时候,你能明显感觉到响应忽快忽慢。

超时参数 3000ms 是经验值。局域网内正常握手在 10ms 量级,给到 3 秒已经很宽容;如果现场跨了网段或者走了无线网桥,可以放到 5000ms,但别再往上加了——超时设太长,断线检测会变得很迟钝,界面上还在显示旧数据,操作工却以为设备正常。

Close里先关流再关客户端,顺序别反。反过来的话,底层连接先断,流对象上的 Dispose 会抛一次异常,虽然不影响功能,但日志里会多一行莫名其妙的报错,排查时干扰判断。

调用方的写法是:连失败就等一会儿再试,别原地死循环。

// 外层调度:失败后退避 2 秒重试,成功则进入收包循环 while (!stoppingToken.IsCancellationRequested) { if (await link.ConnectAsync()) { try { await ReceiveLoopAsync(link.Stream, stoppingToken); } catch (Exception ex) { /* 记日志,落到这里说明链路异常 */ } finally { link.Close(); } } await Task.Delay(2000, stoppingToken); }

退避时间别设太短。断线重连设成 100ms 一次,屏重启的十几秒里会疯狂发起连接,有些固件的 TCP 栈在短时间内大量 SYN 之后会进入异常状态,反而更难恢复。2 秒是个稳妥值。

3.2 收包循环:一次 Read 拿到的不是一条完整报文

这是从串口转过来的人最容易翻车的地方。串口的一次 Read 通常就是发的那一帧,TCP 不是。你发 10 个字节,对面可能一次读到 3 个、下次读到 7 个;也可能连发两条,对面一次读到 20 个字节。这两种情况分别叫半包和粘包,必须在应用层自己处理。

// 累积缓冲 + 循环切帧,一次 Read 可能切出 0 到多条完整报文 private readonly byte[] _buf = new byte[8192]; public async Task ReceiveLoopAsync(NetworkStream stream, CancellationToken ct) { var acc = new List<byte>(4096); while (!ct.IsCancellationRequested) { int n; try { n = await stream.ReadAsync(_buf, ct).ConfigureAwait(false); } catch (System.IO.IOException) { break; } // 对端复位,当成断线 if (n <= 0) break; // 对端正常关闭 acc.AddRange(new ReadOnlySpan<byte>(_buf, 0, n).ToArray()); // 从累积区里反复取帧,取不出来就是半包,留着等下一次 while (TryTakeFrame(acc, out var frame)) Dispatch(frame); // 防御:协议对不上时缓冲会无限涨,超限直接报错比吃光内存好 if (acc.Count > 64 * 1024) throw new InvalidOperationException("接收缓冲超限,检查帧头或长度字段"); } }

_buf开 8192 是够用的,单条业务报文一般不超过 256 字节,一次 Read 最多也就把几条粘在一起。真正决定性能的是acc这个累积列表,注意别每次 Read 都新建,循环外new一次就行。

n <= 0这个判断必须留。ReadAsync 返回 0 表示对端正常关闭,不是“暂时没数据”;如果一个字节都没读到就往下走,下一次循环会拿到同样的 0,变成死循环空转,CPU 直接拉满。

64KB 的上限是防御性的。正常业务跑一万年也到不了这个量,一旦触到,基本可以确定是对面的帧格式和你理解的不一样——比如长度字段的字节序读反了,本来 12 字节的帧被判成 3072 字节,缓冲就会一直膨胀。

3.3 分帧三种拆法:定长、分隔符、长度前缀

拆包策略没有最优解,只有跟协议匹配的解法。

拆法判断依据优点坑
定长每条报文长度固定实现最简单数据区一变长就废
分隔符找特定结束符可读性好,方便调试数据区出现同样的字节就误判
长度前缀头部带长度字段最通用,二进制协议首选长度字段本身要能自恢复

跟 MCGS 对接建议一律用长度前缀,二进制数据区里什么字节都可能出现,分隔符方案早晚会撞上。前面那个TryTakeFrame按长度字段切帧,核心是两件事:帧头对不上就滑动一个字节,长度非法就丢掉当前帧头重新对齐。

// 帧格式: A5 5A | len(2, 大端, 含命令到校验) | cmd(1) | seq(1) | addr(2) | type(1) | data(n) | xor(1) private static bool TryTakeFrame(List<byte> acc, out byte[] frame) { frame = Array.Empty<byte>(); if (acc.Count < 4) return false; // 头部都不全,等 if (acc[0] != 0xA5 || acc[1] != 0x5A) // 错位,滑一个字节重新找头 { acc.RemoveRange(0, 1); return false; } int len = (acc[2] << 8) | acc[3]; // 大端还原 if (len < 6 || len > 1024) // 长度非法,丢头重来 { acc.RemoveRange(0, 2); return false; } if (acc.Count < 4 + len) return false; // 半包,等下一次 Read frame = acc.GetRange(0, 4 + len).ToArray(); acc.RemoveRange(0, 4 + len); return true; }

长度下限 6 是按协议算出来的:命令 1 + 序号 1 + 地址 2 + 类型 1 + 校验 1,数据区为空时也逃不掉这 6 个字节。写这个下界时一定要跟协议表对一遍,填小了会把噪声当合法帧,填大了会把心跳包丢掉。

长度非法时只丢两个字节的头,而不是整帧,这个细节是为了快速重新对齐。如果对面发的是半截乱码,你丢多了会错过下一帧的真帧头。

3.4 异步化改造:别再让收包线程碰 UI

界面程序里最常见的写法是把 Read 放在后台线程,读完之后直接改控件属性。WinForm 下会抛跨线程异常,WPF 下更隐蔽——不一定抛,但界面会偶发卡顿或者数据不刷新。

改造原则很简单:收包线程只做拆包和解析,解析结果丢进队列,界面用定时器去取。所以Dispatch(frame)里不要碰任何控件,把解析出来的值塞进ConcurrentQueue或者一个带锁的缓冲区,界面每 200ms 拉一次。

另外ConfigureAwait(false)在这个场景里要加。收包循环里没有“回到 UI 上下文”的需求,加上之后能少一次线程调度,长跑的时候线程池压力小很多。

4. 报文设计与数据映射:让 C# 结构体和 MCGS 变量对得上

链路跑通之后,问题会集中爆发在数据上:读回来是 0、数值离谱、报警位错位。十个现场问题里有六个出在这一层。这一章把报文结构、字节序、变量映射三件事定下来。

4.1 报文结构:从一条读写指令倒推设计

先定报文,再写代码。我一般按这个表来:

偏移长度字段说明
02帧头固定 0xA5 0x5A
22长度大端,命令到校验的总字节数,最小 6
41命令字0x01 读请求 / 0x81 读响应 / 0x02 写请求 / 0x82 写响应
51序号1~255 循环,用来对应请求和响应
62变量地址大端,对应 MCGS 侧的通道编号
81数据类型0x01 UInt16 / 0x02 UInt32 / 0x03 Float32
9n数据区读请求为空,响应带值;写请求带值
9+n1校验命令到数据区逐字节异或

序号这个字段很多人省掉,觉得单条链路用不上。但只要你做了写操作,就必须有——写请求发出去,对面回一个“写成功”,你怎么知道它对应的是刚才那次写,还是三秒前超时重发的那次?没有序号,重复写会把参数写乱。

读请求的组包代码:

// 组一条读请求:帧头 + 长度 + 命令 + 序号 + 地址 + 类型 + 校验 public static byte[] BuildReadRequest(byte seq, ushort addr, byte dataType) { var body = new byte[6]; body[0] = 0x01; // 读请求 body[1] = seq; // 序号,调用方维护 body[2] = (byte)(addr >> 8); // 地址高字节 body[3] = (byte)(addr & 0xFF); // 地址低字节 body[4] = dataType; body[5] = Xor(body, 0, 5); // 校验覆盖命令到类型 var frame = new byte[4 + body.Length]; frame[0] = 0xA5; frame[1] = 0x5A; frame[2] = 0; // 长度高字节,本例固定为 0 frame[3] = (byte)body.Length; Buffer.BlockCopy(body, 0, frame, 4, body.Length); return frame; } private static byte Xor(byte[] buf, int start, int count) { byte x = 0; for (int i = start; i < start + count; i++) x ^= buf[i]; return x; }

Xor覆盖范围从命令字到数据区末尾,不含帧头和长度字段。这个约定必须在协议文档里白纸黑字写清楚,不然两边各按各的理解算校验,联调时会看到“帧头对、长度对、内容对、就是校验错”的诡异现象。

响应处理反过来:先校验帧头,再按偏移取字段,最后才做校验和比对。校验放在最后是为了能先判断帧是否完整,避免对半包算校验。

4.2 字节序与数据类型:浮点数最容易对不上

大端小端这件事,理论上一句话,现场能耗掉半天。C# 的BitConverter在 x86 上是小端,网络传输习惯用大端,这一翻一转必须显式写出来,靠运气是不行的。

// 统一出口:所有上行下行数据都从这里过一遍 public static class NetByte { // 16 位整数 → 大端两字节 public static byte[] PackUInt16(ushort v) => new[] { (byte)(v >> 8), (byte)(v & 0xFF) }; // 32 位整数 → 大端四字节 public static byte[] PackUInt32(uint v) => new[] { (byte)(v >> 24), (byte)(v >> 16), (byte)(v >> 8), (byte)v }; // 浮点:先按本机字节序取字节,再判断是否需要翻转 public static byte[] PackFloat(float v) { var b = BitConverter.GetBytes(v); if (BitConverter.IsLittleEndian) Array.Reverse(b); return b; } public static float ReadFloat(byte[] buf, int offset) { var tmp = new byte[4]; Buffer.BlockCopy(buf, offset, tmp, 0, 4); if (BitConverter.IsLittleEndian) Array.Reverse(tmp); return BitConverter.ToSingle(tmp, 0); } }

BitConverter.IsLittleEndian这个判断别写死成 true。虽然 x86 和小端 ARM 占绝大多数,但万一哪天程序被挪到别的架构上跑,写死就是一颗定时炸弹,多写一行判断不费事。

浮点数还有一个更隐蔽的坑:字序和字节序是两回事。有些组态软件把 32 位浮点按两个 16 位寄存器存,寄存器内部的字节序对了,但两个寄存器的先后顺序反了,算出来的值会非常离谱,比如 25.5 变成 -1056964608 这种量级的怪数。遇到数值明显不在量程内、又不是 0,先怀疑这个。

调试这一段最有效的办法是准备几个“特征值”:25.5、-1.0、0.0、100.0。这几个数的十六进制表示好认,拿计算器一比对就知道是字节序反了还是字序反了。

4.3 变量映射表:把偏移量从代码里赶出去

代码里直接写0x0010这种魔数,三个月后自己都看不懂,加一个变量要改三处。做法是把映射关系提到数据里:

public enum TagType : byte { UInt16 = 0x01, UInt32 = 0x02, Float32 = 0x03 } public readonly record struct TagDef(string Name, ushort Addr, TagType Type); public static class TagTable { // 与 MCGS 侧通道编号严格一一对应,改动必须两边同步 public static readonly TagDef[] All = { new("炉温", 0x0001, TagType.Float32), new("产量计数", 0x0010, TagType.UInt32), new("报警字", 0x0020, TagType.UInt16), new("设定值", 0x0030, TagType.Float32), }; public static TagDef? Find(string name) => Array.Find(All, t => t.Name == name) is var t && t.Name == name ? t : null; }

record struct需要 C# 10,老项目改成普通类加只读字段就行。这张表的价值在于:报文组包、解析、界面绑定、日志输出全部从这一处取,地址改了只动一行。

和 MCGS 侧的对应关系要靠人工维护,我的做法是在表的注释里把 MCGS 里的通道名原样抄一遍,改的时候先在屏上改,改完立刻回来更新这张表,两边不一致时以屏为准——因为现场改屏的人可能不是你。

5. 避坑与排查:联调阶段最容易翻车的五件事

下面这五条都是从实际联调里抠出来的,按链路层、数据层、运行期分开。每条按“现象 → 原因 → 解决”写,遇到对应症状直接翻。

5.1 链路类问题:连不上、连上就断

坑一:上位机监听已启动,屏侧一直显示未连接。

现象是上位机日志里能看到监听端口打开了,用netstat -an | findstr 6000也能看到 LISTENING,但屏那头就是连不上,抓包看不到 SYN 包。

原因通常是两个:一是绑定地址写成了127.0.0.1,这个地址只接受本机连接,外部流量根本进不来;二是上位机这台机器的防火墙把入站端口拦了,SYN 包被丢弃,屏侧表现为“连接超时”。

解决是把监听地址改成IPAddress.Any,然后在防火墙里给这个端口加一条入站放行规则。改完之后一定要从另一台机器上测,同一台机器上自己连自己验不出这两个问题。

// 监听绑定所有网卡,而不是只绑回环 var listener = new TcpListener(IPAddress.Any, 6000); listener.Start();

坑二:连上之后十几秒到几十秒就自己断开,重连又能连上。

现象是链路反复建立又断开,周期很规律,业务报文其实已经收发正常了。

原因一般是链路上没有任何数据流动,中间的交换机或者屏的 TCP 栈把空闲连接回收了。有些现场的设备会主动清理超过 30 秒没有流量的连接。

解决是加应用层心跳,间隔设在 5 到 10 秒,比链路空闲超时短就行。心跳包用最简单的读请求,读一个固定的“在线标志”变量,既保活又顺便验证了对端还活着。

5.2 数据类问题:读回来是 0 或者数值离谱

坑三:链路完全正常,读回来的值全是 0。

现象很迷惑:报文收发都能在日志里看到,长度、校验都对,就是数据区全是 0。

原因在 MCGS 侧——变量建好了,但没挂到 TCP 父设备下面,或者通道的连接属性没勾上通信。屏上本地显示正常,网络上就是不发。

解决是回到设备窗口,逐个变量检查它挂在哪个父设备下,把通道的连接属性重新确认一遍。检查完记得重新下载工程到屏,改完不下载等于没改。

坑四:整数读对了,浮点数读出来是个天文数字或者负数。

现象是 UInt16 和 UInt32 类型的变量都正常,唯独 Float32 的变量值完全不在量程内。

原因就是前文说的字节序或字序问题。要么是打包时没做大端转换,要么是 MCGS 那边把 32 位浮点拆成两个 16 位寄存器时高低位顺序和你的理解相反。

解决是拿 25.5 这个值做基准,25.5f的 IEEE754 表示是0x41CC0000,把它按大端写成41 CC 00 00发过去,看屏上显示对不对。如果显示的是0x0000CC41对应的那个怪数,就是整体字节序反了;如果显示的是0xCC410000对应的值,就是两个 16 位寄存器顺序反了,把高低半字对调即可。

5.3 运行期问题:跑几小时卡死、内存缓慢上涨

坑五:程序跑几个小时后界面无响应,重启就好。

现象是刚启动一切正常,跑几小时之后界面越来越卡,最后完全无响应,任务管理器里内存占用持续上涨。

原因通常有三个叠加:收包循环里acc列表没在上限处截断,遇到对端发来的半截乱码就无限膨胀;界面更新直接写在收包线程里,跨线程访问控件导致消息队列堆积;异步任务没有传取消令牌,断线重连之后旧任务还在跑,越积越多。

解决分三步:给累积缓冲加上 64KB 上限,超了直接抛异常断开重连;把界面更新改成队列加定时器拉取;所有Task.Run和ReadAsync都传同一个CancellationToken,Close的时候先Cancel再关流。

改完之后做个挂机测试,让它跑满 24 小时,中间人为拔一次网线再插回去,看内存曲线是否平稳、断线重连是否自动恢复。这一步别省,现场跑几天才暴露的问题,成本比在这里多花一天高得多。

6. 进阶:把这条链路做成可复用的通信组件

链路稳定之后,代码会被复制到第二个、第三个项目上,这时候就该把散落各处的逻辑收成一个组件。我的做法是抽三个东西出来:一个连接管理器管重连和状态、一个心跳定时器管保活、一个请求响应匹配器管序号。

心跳最简单,用PeriodicTimer起一个循环就行:

// 每 5 秒发一次读请求,读在线标志,既能保活又能测对端存活 var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); while (await timer.WaitForNextTickAsync(ct)) { if (!link.IsConnected) continue; var frame = Protocol.BuildReadRequest(NextSeq(), 0x00FF, 0x01); await link.Stream.WriteAsync(frame, ct).ConfigureAwait(false); _lastSendAt = DateTime.UtcNow; // 供超时检测用 }

PeriodicTimer的好处是不会像Task.Delay那样在长跑之后累积漂移,也不会因为某次回调耗时长而挤在一起触发。_lastSendAt这个时间戳是给超时检测用的,超过三个心跳周期没收到任何数据,就判定链路已死,主动 Close 触发重连,别等 TCP 自己发现——TCP 的默认重传可能要几分钟,界面上那几分钟就是“数据不动但程序没报错”。

请求响应匹配用ConcurrentDictionary<byte, TaskCompletionSource<byte[]>>,发请求前把序号和等待句柄登记进去,收包时按序号取出并TrySetResult,超时就TrySetCanceled。这个模式写一次能用很久,后面再加什么命令都只是往里塞不同的组包函数。

验证方法我一般用两个:一个是用Tcpserver类的模拟器在本地起一个假对端,按协议脚本回包,用来跑回归;另一个是直接拿屏做压测,读请求间隔从 1 秒压到 100ms,看两边会不会丢帧。压测跑通再上现场,心里才有底。

说个自己的教训。早些年做第一条 TCP 链路,回环验证过了就直接上现场,结果跑了两天开始断,日志里什么线索都没有。后来把收发流水全打到文件里才发现,是对方在空闲 30 秒后回收连接,而我没做心跳。从那以后我每次新接一条 TCP 链路,都强制先跑一遍回环加 24 小时挂机,中间人为拔插一次网线,再谈业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进