简介:面向工业自动化上位机开发者的 C# 与 MCGS(昆仑通态)TCP 通信范例,基于 VS2013 构建,重点演示 C# 软件如何连接 MCGS 并读取其中数据,适合已有一定 WinForms 与网络通信基础、希望打通触摸屏、组态软件与上位机数据交互的工程师参考。压缩包共 36 个文件,约 92KB,包含 9 个 C# 源码文件,以及解决方案、资源配置、配置文件、可执行程序和 MCGS 工程等内容,项目结构与调试入口相对完整,便于快速定位通信逻辑和界面代码。已有 2507 人学习,说明该范例在设备联调与数据采集场景中具有一定参考价值。读者可据此梳理 TCP 连接建立、数据读取与界面展示的实现脉络,对照源码理解通信参数配置、异常处理和工程组织方式,也可将其作为课程设计、小型监控看板或上位机采集项目的起步模板,减少从零搭建环境的时间。
1. 上位机和组态屏之间那条由 TCP 通信打通的链路,最容易被想复杂,也最容易被做糙
一台设备上,C# 写的上位机跑在工控机上,组态软件(HMI 组态屏)跑在触摸屏里,两边各自都能干活,但数据要对得上:上位机算出结果要下发给屏,屏上按的按钮要回传给上位机。这时候绕不开的就是一条自己动手搭的 TCP 通信链路。很多人第一反应是上 Modbus TCP,但现场往往没有现成的从站可挂,或者寄存器语义跟业务对不上,最后还是得自己定义帧、自己写收发。这份 C# 与组态软件 TCP 通信范例代码解决的就是这一段:一边是 C# 的 Socket 服务端与客户端,一边是组态侧的设备窗口配置和变量绑定。适合刚接手上位机项目、手里有屏但没有现成通信中间件的开发者,也适合想把通信层从窗体代码里剥离出来做组件化的人。
2. 通信拓扑与技术选型:谁来当 Server,帧怎么定,工程怎么起
动手写代码之前,有三个决定必须先落地,否则后面每改一次都要返工。第一个是谁当 Server,第二个是字节怎么组织,第三个是代码放哪儿。这三件事定完,剩下的就是照图施工。
现场最常见的组合是 C# 上位机做服务端、组态屏做客户端。原因是工控机的 IP 相对固定,端口也方便在防火墙里放行;屏那边只需要在设备窗口里填一个远端 IP 和一个端口,配完就能连。反过来让屏做服务端也不是不行,但很多型号的组态软件对“监听”的支持不一致,一旦遇到固件版本差异,排查成本会翻倍。
2.1 两种拓扑的取舍:C# 做 Server 还是做 Client
把 C# 端放在服务端,最大好处是连接状态掌握在自己手里。上位机随时知道有几个客户端在线、谁掉线了、最后一条报文是什么时候来的。这在需要“屏掉线就报警”的场景里几乎是刚需,因为服务端的 Accept 和断开事件天然就是连接状态的信号源。
反过来,如果屏做服务端、C# 做客户端,你只能靠心跳超时去猜对端还在不在。TCP 的 KeepAlive 默认要两个小时才开始探测,指望它做实时掉线检测不现实。血泪经验是:只要现场不止一台屏,就让 C# 做服务端,把所有连接收拢到一个地方统一管理,日志也好打。
| 拓扑 | 连接管理 | 组态侧配置量 | 适用场景 |
|---|---|---|---|
| C# 做 Server,屏做 Client | 强,可精确感知上下线 | 只填远端 IP + 端口 | 一台上位机对接多台屏 |
| C# 做 Client,屏做 Server | 弱,靠心跳超时判断 | 需要配监听端口与白名单 | 单屏单机、屏侧已有服务能力 |
| 双向互连(两端都做 Client) | 复杂,需自建中继 | 两端都要配 | 不建议,除非跨网段有中继需求 |
第二种情况里的“双向互连”之所以不推荐,是因为两端都主动连对方时,容易出现两条链路同时存在、数据重复下发的问题,最后还得靠会话 ID 去重,得不偿失。
2.2 帧格式:定长字节帧、字符串帧与 Modbus TCP 的取舍
组态软件的通用 TCP 设备通常只提供“收字节、发字节”的能力,它不理解你的业务语义,所以帧格式必须自己定。常见的三种做法各有代价:
纯字符串帧(比如weight=12.35\n)调试最舒服,串口助手就能看,但解析要靠分隔符,一旦对端发出的数据里混入分隔符就崩,而且浮点数精度、负号、单位都得靠约定。适合数据量小、只做展示的场合。
定长字节帧最省解析成本,但字段一多就不够灵活,加一个字段就要改两端的代码,版本一升级现场就得同时刷屏和刷上位机。
变长帧(帧头 + 长度 + 命令字 + 载荷 + 校验)是工业现场最通用的写法,扩展性最好,代价是解析代码要多写一段。这份范例用的是这一种,结构如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | 帧头 | 固定0xAA 0x55,用于从噪声数据里重新同步 |
| 2 | 2 | 长度 | 载荷字节数,小端,不含帧头与校验 |
| 4 | 1 | 命令字 | 0x01 心跳、0x02 数据上报、0x03 参数下发 |
| 5 | 1 | 序号 | 请求应答配对用,0~255 循环 |
| 6 | N | 载荷 | UTF-8 JSON 或紧凑二进制 |
| 6+N | 2 | CRC16 | 覆盖帧头到载荷末尾,小端 |
提示:长度字段只算载荷,不要把帧头、命令字、校验一起算进去。这一点在两端各写一遍代码时最容易出现理解偏差,联调时表现为“能收到数据但一直解不出帧”。
2.3 工程目录:把通信层从窗体里剥出来
新手最容易翻车的地方是把 Socket 代码直接写在Form1_Load里,业务一多,窗体文件两千行,改一个帧字段要在里面翻半天。我一般从第一天就分三层:通信层只负责收发字节,协议层只负责编解码,业务层只负责“收到 0x02 命令该干什么”。
TcpDemo/ ├── TcpDemo.Protocol/ # 协议层:帧定义、编解码、CRC │ ├── Frame.cs │ ├── FrameCodec.cs │ └── Crc16.cs ├── TcpDemo.Transport/ # 通信层:监听、会话、收发队列 │ ├── TcpServer.cs │ ├── ClientSession.cs │ └── IFrameHandler.cs ├── TcpDemo.App/ # 业务层:WinForms 界面 + 业务回调 │ ├── MainForm.cs │ └── Handlers/WeightHandler.cs └── TcpDemo.Tests/ # 单元测试:编解码用例 └── FrameCodecTests.cs搭工程用命令行最快,三条命令搞定一个解决方案加三个项目:
dotnet new sln -n TcpDemo dotnet new classlib -n TcpDemo.Protocol -o TcpDemo.Protocol dotnet new classlib -n TcpDemo.Transport -o TcpDemo.Transport dotnet new winforms -n TcpDemo.App -o TcpDemo.App dotnet sln add TcpDemo.Protocol TcpDemo.Transport TcpDemo.App dotnet add TcpDemo.App reference TcpDemo.Transportdotnet new winforms需要 .NET 5 及以上;如果现场工控机还锁在 .NET Framework 4.7.2,就把最后一条换成在 Visual Studio 里新建 Windows 窗体应用,代码本身不用改,只是Channel<T>、Span<byte>这些 API 要换成BlockingCollection和数组切片,具体替换点在 3.4 节会标出来。
3. 服务端核心链路拆解:从 TcpListener 到一帧数据落地
这一章是按“一个客户端连上来,发一帧数据,服务端解析出来,再回一帧”的顺序走一遍。代码是完整的,可以直接抄进TcpDemo.Transport里跑。
3.1 监听与接入:TcpListener 的生命周期
先看启动部分。核心是把监听、接入、会话运行三段拆开,AcceptTcpClientAsync只负责接人,接进来立刻丢给ClientSession去跑,主循环继续等下一个。
// TcpDemo.Transport/TcpServer.cs public sealed class TcpServer { private readonly ConcurrentDictionary<string, ClientSession> _sessions = new(); private readonly IFrameHandler _handler; private TcpListener? _listener; private CancellationTokenSource? _cts; public TcpServer(IFrameHandler handler) => _handler = handler; public async Task StartAsync(int port, CancellationToken token) { _cts = CancellationTokenSource.CreateLinkedTokenSource(token); _listener = new TcpListener(IPAddress.Any, port); // 服务重启时端口还在 TIME_WAIT,不设这个会报“地址已在使用” _listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Start(16); // backlog:允许排队等待接入的连接数 while (!_cts.IsCancellationRequested) { TcpClient client; try { client = await _listener.AcceptTcpClientAsync(); } catch (ObjectDisposedException) { break; } // Stop() 主动关监听 client.NoDelay = true; // 关掉 Nagle,短帧不会被攒着发 client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); var session = new ClientSession(client, _handler); _sessions[session.Key] = session; _ = Task.Run(() => session.RunAsync(_cts.Token)); } } public void Stop() { _cts?.Cancel(); _listener?.Stop(); foreach (var s in _sessions.Values) s.Dispose(); _sessions.Clear(); } }ReuseAddress解决的是服务重启后端口占用的问题,backlog给 16 足够应付一台上位机对十几台屏的场景,NoDelay是必须开的——默认的 Nagle 算法会把小报文攒到 200ms 再发,组态屏上的数据看起来就是“一顿一顿”的,很多人误以为是屏刷新慢,其实是发送端在攒包。
每个客户端一个Task而不是一个Thread,是因为这类会话绝大部分时间都阻塞在ReadAsync上,用线程会白白吃掉几十 MB 栈空间。会话的 Key 我用的是远端IP:端口拼接,方便日志里直接定位是哪台屏。
3.2 收包的缓冲区:粘包与半包到底怎么处理
TCP 是字节流,没有消息边界。对端一次Send出去的一帧,到你这边可能是分两次Read读到,也可能是两帧粘在一次Read里。所以每次Read返回后不能直接当成一帧解析,必须先进缓冲区,再循环尝试解码。
// TcpDemo.Transport/ClientSession.cs(节选) private readonly byte[] _rx = new byte[4096]; private int _rxLen; // 缓冲区里已有的有效字节数 private readonly NetworkStream _stream; public async Task RunAsync(CancellationToken token) { try { while (!token.IsCancellationRequested) { // 从 _rxLen 开始写,保证上一轮残留的半包不被覆盖 int n = await _stream.ReadAsync(_rx.AsMemory(_rxLen), token); if (n == 0) break; // 对端 FIN,正常关闭 _rxLen += n; int consumed; while ((consumed = FrameCodec.TryDecode(_rx, _rxLen, out var frame)) > 0) { Buffer.BlockCopy(_rx, consumed, _rx, 0, _rxLen - consumed); _rxLen -= consumed; // 把已消费的字节从缓冲区头部挪走 await _handler.HandleAsync(this, frame, token); } if (_rxLen == _rx.Length) throw new InvalidOperationException("缓冲区满仍未解出完整帧,检查长度字段"); } } catch (IOException) { /* 对端异常断开,走 finally 清理 */ } finally { Close(); } }TryDecode的返回值有三种情况:大于 0 表示解出了一帧并告诉调用方消费了多少字节;等于 0 表示数据不够,等下一次Read;返回负数表示数据流已经失步,需要跳到下一个帧头。缓冲区用固定 4KB 数组加一个_rxLen游标,比每次new byte[]省掉大量 GC,也避免了MemoryStream反复扩容的拷贝开销。
HandleAsync是在接收循环里await的,这意味着业务处理慢会直接拖住收包。所以业务回调里只做三件事:写日志、更新状态、把数据丢进 UI 的同步上下文,千万别在里面做数据库写入或者调用外部 HTTP 接口。
3.3 组帧与解析:命令字、序号和 CRC 校验
协议层的代码是无状态的静态方法,方便写单元测试。解码的核心是三步:校帧头、判长度、验 CRC,任何一步不过就往前找一个字节重新同步。
// TcpDemo.Protocol/FrameCodec.cs public static class FrameCodec { public const byte Head0 = 0xAA, Head1 = 0x55; private const int Overhead = 8; // 头2 + 长度2 + cmd1 + seq1 + crc2 public static byte[] Encode(byte cmd, byte seq, ReadOnlySpan<byte> payload) { var buf = new byte[Overhead + payload.Length]; buf[0] = Head0; buf[1] = Head1; buf[2] = (byte)(payload.Length & 0xFF); // 长度低字节 buf[3] = (byte)((payload.Length >> 8) & 0xFF); // 长度高字节,小端 buf[4] = cmd; buf[5] = seq; payload.CopyTo(buf.AsSpan(6)); ushort crc = Crc16.Compute(buf, 0, buf.Length - 2); buf[^2] = (byte)(crc & 0xFF); buf[^1] = (byte)(crc >> 8); return buf; } /// <returns>>0 消费的字节数;0 数据不足;<0 失步需重同步</returns> public static int TryDecode(byte[] buf, int len, out Frame frame) { frame = default; if (len < Overhead) return 0; if (buf[0] != Head0 || buf[1] != Head1) return Resync(buf, len); int bodyLen = buf[2] | (buf[3] << 8); if (bodyLen > 1024) return Resync(buf, len); // 长度离谱,八成是错位 int total = Overhead + bodyLen; if (len < total) return 0; // 半包,等下次 Read ushort crc = (ushort)(buf[total - 2] | (buf[total - 1] << 8)); if (crc != Crc16.Compute(buf, 0, total - 2)) return Resync(buf, len); frame = new Frame { Cmd = buf[4], Seq = buf[5], Payload = buf.AsSpan(6, bodyLen).ToArray() }; return total; } private static int Resync(byte[] buf, int len) { for (int i = 1; i < len - 1; i++) if (buf[i] == Head0 && buf[i + 1] == Head1) return i; // 丢弃 i 个脏字节 return len - 1; // 整段都没头,全部丢掉,只留最后一个字节防跨包 } }Resync是这个协议层最容易被忽略、但现场最救命的一段。现场干扰、串口转网口的转换器抽风、对端程序重启,都会在流里混进垃圾字节。没有重同步逻辑的解析器一旦错位,后面所有帧都会错,现象就是“通信彻底不通,但重启两边又好了”。
bodyLen上限定在 1024 是因为业务里最大的报文就是一串称重记录,撑死几百字节。这个上限的意义在于防攻击和防错位:长度字段被干扰成 0xFFFF 时,不做限制就会一直等一个 64KB 的“半包”,把缓冲区撑满。
注意:CRC16 初值、多项式、是否反转,两端必须逐位对齐。Modbus 用的是初值 0xFFFF、多项式 0xA001 的标准 CRC16,很多现成代码片段都能直接拿来用,但要确认组态侧脚本里算的是同一套参数。
3.4 发送侧:跨线程发送队列与 UI 不卡死
发送是另一个坑。组态屏的按钮事件、上位机的定时器、心跳线程,三个地方都可能同时往同一个NetworkStream里写,不加锁就会出现半帧交错,对端收到的是两帧的碎片拼在一起。
// TcpDemo.Transport/ClientSession.cs(发送部分) private readonly Channel<byte[]> _tx = Channel.CreateBounded<byte[]>( new BoundedChannelOptions(1024) { FullMode = BoundedChannelFullMode.DropOldest, // 满了丢最旧,绝不阻塞调用方 SingleReader = true }); public bool Send(byte cmd, byte seq, ReadOnlySpan<byte> payload) => _tx.Writer.TryWrite(FrameCodec.Encode(cmd, seq, payload)); private async Task SendLoopAsync(CancellationToken token) { await foreach (var frame in _tx.Reader.ReadAllAsync(token)) await _stream.WriteAsync(frame, token); // 唯一的写入口 }所有发送都变成往Channel里塞一个字节数组,由单独一个SendLoopAsync顺序写出去。这样既保证帧不交错,又保证 UI 线程不会被网络阻塞卡住——WriteAsync在对端不收数据时会一直挂着,直接在按钮事件里await它,界面就假死了。
DropOldest是刻意的选择:这类业务里实时数据比历史数据重要,队列积压到 1024 帧说明对端已经不正常了,丢最旧的实时值比把上位机内存撑爆更合理。如果是参数下发这种不能丢的帧,就单独开一个容量小、FullMode = Wait的通道,或者干脆同步返回失败让用户重试。
.NET Framework 4.x 上没有System.Threading.Channels,替换成BlockingCollection<byte[]>加一个后台线程foreach (var f in _tx.GetConsumingEnumerable(token))即可,语义一致。
4. 组态侧配置:变量、设备窗口和脚本怎么对上 C# 的帧
C# 端跑通了只是半条链路,另一半在屏里。组态软件这边的配置项不多,但每一项填错都表现为“连不上”或“数不对”,而它本身又不给什么有用的报错。
4.1 设备窗口与变量绑定:把通道地址换成 TCP 连接
在组态工程的设备窗口里新增一个通用 TCP/IP 设备(有的版本叫“TCP 数据转发”或“自由协议”),通信方向选客户端,填入上位机的 IP 和监听端口。然后在这个设备下挂变量,每个变量对应一个通道地址,通道号的排布规则是“区号 + 偏移”,收发各占一段连续地址。
| 组态变量名 | 通道地址 | 类型 | 对应 C# 侧字段 |
|---|---|---|---|
Weight | 4 区 0 | 浮点 | 命令 0x02 载荷里的weight |
Result | 4 区 2 | 整型 | 命令 0x02 载荷里的code |
CmdStart | 3 区 0 | 布尔 | 命令 0x03 下发的启动位 |
CmdReset | 3 区 1 | 布尔 | 命令 0x03 下发的复位位 |
写区(3 区)是屏往 C# 发的,读区(4 区)是 C# 往屏里写的。绑完之后要给每个变量设好采集周期,一般 200ms 就够,太快会把串口或网口打满,屏的刷新也跟不上。
配置完建议先在设备窗口的“通道调试”里手动发一组数据,确认 C# 端的日志里能打出来。这一步能省掉后面 80% 的扯皮——先证明链路通,再查业务。
4.2 组态脚本:定时上报与按钮事件
屏上的逻辑用组态自带的脚本写,语法接近 Basic,习惯 C# 的人上手要适应一下,但写起来很短。下面这段是定时脚本,挂在 200ms 的循环里,把当前配方参数打包往 3 区里写:
' 组态定时脚本:200ms 触发一次 ' 把三个参数按字节写进 3 区,C# 侧按同顺序解析 IF ComAlive = 1 THEN WriteByte(3, 0, 1) ' 帧头 0xAA WriteByte(3, 1, 85) ' 帧头 0x55 WriteByte(3, 2, 6) ' 载荷长度低字节 WriteByte(3, 3, 0) ' 载荷长度高字节 WriteByte(3, 4, &H03) ' 命令字:参数下发 WriteByte(3, 5, SeqNo) WriteFloat(3, 6, SetTemp) ' 温度设定值 WriteFloat(3, 10, SetSpeed) ' 速度设定值 WriteInt(3, 14, Mode) ' 运行模式 ' CRC 在组态里算比较费劲,常见做法是让 C# 侧对该命令字跳过校验 SeqNo = (SeqNo + 1) MOD 256 END IF组态脚本里逐字节拼帧是比较笨但最稳的做法。CRC 在脚本里算要用循环移位,写起来长、执行也慢,所以我在实际项目里通常约定:命令字 0x03 的参数下发帧不做 CRC 校验,只校验帧头和长度。这条约定要写进两端的协议文档里,不然换个人接手就会以为是漏了。
SeqNo是全局变量,用来让 C# 侧能识别重复帧。组态的定时脚本偶尔会因为界面卡顿漏掉一次触发,序号不连续就是最好的证据。
4.3 联调:先用 C# 模拟客户端打通,再上真屏
拿着屏调是最低效的方式,因为屏幕不会告诉你它发了什么。我一般先写个几十行的控制台客户端,模拟屏的行为,把链路和解析全部验证完,再换真设备。
// 模拟组态屏:连上后每 500ms 发一帧数据上报 using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 9000); var ns = client.GetStream(); byte seq = 0; while (true) { var json = $"{{\"weight\":{12.35 + seq * 0.01:0.00},\"code\":1}}"; var frame = FrameCodec.Encode(0x02, seq++, Encoding.UTF8.GetBytes(json)); await ns.WriteAsync(frame); Console.WriteLine($"TX {frame.Length}B seq={seq - 1}"); await Task.Delay(500); }跑起来之后对照服务端日志看三件事:帧长度是不是和预期一致、seq有没有跳号、解析出的 JSON 反序列化后字段对不对。三件事都对,再换成真屏,剩下的差异基本只在字节序和变量地址上。
提示:抓包用 Wireshark 过滤
tcp.port == 9000即可,但要注意如果两端都在本机,Windows 的环回接口抓包需要装额外的驱动,不如直接在Encode前后打日志来得快。
5. 排查手册:这套链路在现场最容易翻车的五个地方
下面五条都是实际调过的,按“现象 → 原因 → 解决”写,遇到问题可以先按顺序对一遍。
5.1 现象:屏刚连上三五秒就断开,日志里是 0 字节的 Read
原因通常是心跳机制没设计。很多型号的通用 TCP 设备在空闲超过一定时间后会主动断开,认为链路已死。C# 端如果不发任何数据,两边就这么干等着,最后被对端单方面断掉。
解决是加应用层心跳:C# 端每 3 秒发一帧命令字 0x01、载荷为空的帧,同时记录最后收包时间,超过 10 秒没收到任何数据就主动Dispose会话并等重连。心跳帧不要用 TCP 的 KeepAlive 代替,那个时间尺度是小时级的。
5.2 现象:中文变量名或字符串在屏上显示成问号、方块
原因有两层。一层是编码不一致,C# 用Encoding.UTF8编码,组态侧按 GBK 解析,出来的就是乱码。另一层是组态软件的字符串变量有固定长度限制,超长部分被截断,截断点落在多字节字符中间就会变成半个汉字。
解决是协议里明确约定字符串一律 UTF-8,并且在Encode前先做长度校验,超过字段上限直接抛异常而不是静默截断。字符串字段我一般习惯在载荷里用“长度 + 内容”的形式,而不是靠结束符。
5.3 现象:上位机界面卡死,或者报“跨线程操作控件”
原因是在HandleAsync里直接改了TextBox.Text。接收循环跑在线程池线程上,WinForms 控件只能在 UI 线程访问,运气好抛异常,运气不好直接死锁。
解决是把业务回调拆成两段:网络线程里只做解析和状态更新,需要刷界面的部分用Control.BeginInvoke投递。别用Invoke,那个是同步等待,界面一忙就把接收线程堵住了。
5.4 现象:重量明明 12.35,屏上显示成 0 或者一个巨大的数
原因是字节序和数据类型长度没对齐。C# 的float是 4 字节 IEEE754 小端,组态侧的“浮点”变量如果按字(2 字节)为单位高低字交换,就会出现典型的“字序颠倒”,12.35 变成一个天文数字。
解决是先在协议文档里把每个字段的字节数和端序写死,再用BitConverter.GetBytes加Array.Reverse做显式转换,别依赖隐式行为。调的时候拿 0x41480000(对应 12.5)这种好认的值试,一眼就能看出是不是高低字反了。
5.5 现象:设备断电重启后上位机连不上,提示端口被占用
原因有两个。一是服务端异常退出时 socket 没正常关闭,端口留在 TIME_WAIT 状态,靠ReuseAddress能绕过大部分情况。二是客户端侧没做重连,屏重启后 C# 端还持有一个已经失效的 session,新的连接被当成重复连接拒绝了。
解决是给客户端加指数退避重连(1s、2s、4s、8s 封顶 30s),服务端在Accept时按远端IP:端口判断是否已有同 Key 会话,有就先把旧的关掉再接受新的。断电重启这种事在产线上一天可能发生好几次,不处理就是每天被叫去现场。
6. 二次开发改造:把这条链路做成可复用的通信组件
范例代码能跑,但直接拿去接第二个项目就得复制粘贴一遍,改到第三个项目时你已经分不清哪份是最新的了。我的做法是把通信层彻底参数化,让它只认接口不认业务。
第一个改造点是把协议层抽象成接口。不同项目的帧结构往往不同,但“收字节 → 解帧 → 回调”这套流程是一样的:
public interface IFrameCodec { byte[] Encode(byte cmd, byte seq, ReadOnlySpan<byte> payload); int TryDecode(byte[] buffer, int length, out Frame frame); } public interface IFrameHandler { Task HandleAsync(ClientSession session, Frame frame, CancellationToken token); }TcpServer只依赖这两个接口,换项目时换一个IFrameCodec实现就行,ClientSession里的缓冲区管理、重同步、发送队列全都不用动。我一般还会再加一层DeviceRegistry,把每个会话和业务侧的设备 ID 关联起来,这样界面上的“1 号工位在线”就有数据来源了。
第二个改造点是配置外置。端口、心跳间隔、超时时间、重连退避曲线全部从appsettings.json读,现场调参数不用重新编译——工控机上装个 .NET SDK 是件很麻烦的事。
| 改造点 | 改造前 | 改造后 | 收益 |
|---|---|---|---|
| 协议实现 | 写死在会话里 | IFrameCodec接口 | 换协议不动通信层 |
| 业务处理 | 窗体事件里写 | IFrameHandler实现类 | 可单元测试 |
| 参数配置 | 硬编码常量 | 配置文件 + 默认值 | 现场可调 |
| 日志 | Console.WriteLine | 结构化日志 + 报文十六进制 | 事后可追溯 |
| 连接管理 | 单一 session | 会话字典 + 心跳巡检 | 多设备统一视图 |
第三个改造点是补测试。协议层是最值得写单元测试的地方,因为它纯函数、无 IO。我最少会写四个用例:完整帧能解出、半包返回 0、帧头前有脏字节能重同步、CRC 错误能被丢弃。这四个用例覆盖了现场 90% 的通信故障,改协议时跑一遍,比到现场拿屏试快得多。
改造过程中有个细节我踩过:TryDecode的Resync在缓冲区只剩一个字节时返回len - 1,如果这个字节恰好是0xAA,下一个包来了之后帧头就能接上;但如果返回len,那个字节就被吞掉了,表现是“偶发丢一帧”,而且极难复现。这种边界我现在会专门写一个buffer = new byte[]{0xAA}的测试用例盯着。
从那以后我每次动协议层,都强制先把四个测试用例跑一遍再打包发到现场,宁可多花五分钟,也不愿意半夜接到电话说屏上不动了。希望帮到你。
本文还有配套的精品资源,点击获取