
简介这份范例围绕 C# 与 MCGS昆仑通态之间的 TCP 通信展开面向正在做工控上位机开发、希望让自写软件读取 MCGS 数据的初、中级开发者可用于通信联调、数据采集入门和项目原型验证。压缩包共 36 个文件约 92KB包含 9 个 cs 源码、4 个 txt 说明、3 个 config 配置、3 个 exe 可执行文件以及 resx 资源、sln/csproj 工程等目录较小但工程要素齐全便于直接打开对照工程体量轻量适合逐行阅读与拆解。现有 2507 人学习下载说明该主题在工控通信实践中较受关注。读者可从中获得基于 VS2013 的 WinForms 示例工程、TCP 连接与收发代码、MCGS 看板人机工程示例及配套说明理解从建立连接到读取数据的完整实现思路对于需要把 MCGS 数据接入自研软件、报表或监控界面的场景也可节省搭建基础通信框架的时间并作为二次开发、联调与排错参考。1. 从一台工控机和一块组态屏说起C# 与 MCGS 的 TCP 直连能省掉哪些中间件联调现场最典型的画面是这样的一台工控机上跑着 C# 写的上位机负责入库、出报表、对接 MES旁边的触摸屏跑 MCGS 组态负责画面、报警和现场 IO。两边要互相递数据。也有人试过 OPC客户端、服务端、DCOM 配置一圈下来账号权限还没理清也试过 DDE那套东西现在连调试工具都不好找。最后八成还是回到最朴素的一条路C# 和 MCGS 之间直接开一条 TCP 通信。这份范例代码的价值就在这儿它把两端怎么建连、报文怎么拼、粘包怎么切、断了怎么重连这几件事用可运行的代码固定下来你拿到手改 IP、改端口、改几个字段就能跑。适合正在做上位机与组态对接的工控软件开发者也适合刚开始接触 Socket 编程、需要一个能跑通完整闭环的样板而不是对着 API 文档干瞪眼的人。2. 链路先定型谁当服务端、端口怎么占、超时怎么设动手写第一行代码之前有个更关键的问题要先定下来这条 TCP 连接里两边分别当服务端还是客户端。这个决定一旦定错后面代码写得再漂亮也是白费功夫因为组态软件那侧的设备驱动是跟着角色走的改起来比改 C# 麻烦得多。2.1 三种角色组合先选对再写代码MCGS 的设备窗口里常见做法是先放一个“通用 TCP/IP 父设备”再由它往下挂子设备。父设备负责这一层连接本身对端 IP、端口、重连间隔、通信超时子设备负责报文长什么样是 ModbusTCP 还是自定义的字符串收发。所以角色的选择本质上是在选父设备的工作模式。组合组态侧角色C# 侧要写的典型场景主要代价A父设备设为客户端主动连上位机TcpListener 服务端一台上位机对着多块屏屏主动上报上位机换 IP每块屏都要改一遍B父设备设为服务端挂 ModbusTCP 从站TcpClient 主动轮询上位机只读不写定时采集地址偏移和功能码得对着手册抠C两边都当客户端中间加网关无跨网段、跨车间多一跳出问题定位成本翻倍单机一对一、数据双向流动的场景我一般直接选 A。原因很实际服务端只有一个IP 和端口集中在一台上位机上现场换机器只改一处而且屏作为客户端断线重连的逻辑是驱动内置的不用自己写。反过来选 B 的话C# 这边得自己维护轮询节奏、自己判超时MQTT 那套重连机制在这里一点都用不上。组合 B 也不是不能用。如果上位机只是每分钟读一次产量和几个温度值写完就不管了那让 MCGS 当 ModbusTCP 从站反而更省事——报文格式是标准的抓包工具直接能解析出问题一眼就能看明白。2.2 C# 服务端骨架Accept 循环里别干业务活选定组合 A 之后C# 这侧就是一个标准的 TCP 服务端。新手最容易犯的错是在 Accept 之后直接就地读数据结果第二块屏连进来的时候第一块屏的会话还堵在那里。正确做法是 Accept 到就丢给独立任务。using System.Net; using System.Net.Sockets; // 监听所有网卡端口要和组态侧父设备里填的对端端口完全一致 var listener new TcpListener(IPAddress.Any, 8899); listener.Start(); Console.WriteLine(服务端已启动等待组态屏接入…); while (true) { // Accept 只负责收连接任何解析、入库都不要写在这个循环里 TcpClient client await listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClientAsync(client)); }IPAddress.Any表示绑定本机所有网卡工控机常常有双网卡一个接办公网、一个接设备网写死某个具体 IP 的话换网口就起不来。端口选 8899 只是习惯1024 以下需要管理员权限现场尽量避开。AcceptTcpClientAsync是异步的不会卡住主线程但真正的坑在后面那个Task.Run——不这么写的话第二个客户端连上来时会一直排队。连接建立之后服务端这边第一件事是把 Nagle 算法关掉static async Task HandleClientAsync(TcpClient client) { client.NoDelay true; // 关掉小报文攒包组态侧 100ms 级的数据不要等 string remote client.Client.RemoteEndPoint?.ToString() ?? unknown; Console.WriteLine($客户端接入{remote}); var buffer new byte[4096]; var acc new Listbyte(8192); // 跨多次 Read 保留的粘包缓冲 try { using var stream client.GetStream(); while (true) { int n await stream.ReadAsync(buffer, 0, buffer.Length); if (n 0) break; // 对端正常关闭返回 0 for (int i 0; i n; i) acc.Add(buffer[i]); while (TryTakeFrame(acc, out byte[] frame)) { Dispatch(frame, remote); // 真正处理一帧完整报文 } } } catch (Exception ex) { Console.WriteLine(${remote} 异常断开{ex.Message}); } finally { client.Close(); Console.WriteLine(${remote} 会话结束); } }NoDelay true这一行看着不起眼但在组态屏上很关键默认 Nagle 算法会把小的数据包攒到一定大小才发屏上按一下按钮上位机可能过两百毫秒才收到。ReadAsync返回 0 是 TCP 的正常关闭语义不是错误别把它当异常记到日志里。acc这个列表是整套代码的基础后面第 4 章处理粘包全靠它。2.3 设备通道参数五个数字填错就白干组态侧父设备的参数面板看着简单但每个字段都有实际含义填错一个就是连不上或者连上就断。参数填什么填错的后果对端 IP上位机在设备网那张网卡上的地址填成办公网地址C# 监听在 Any 也连不上对端端口与 TcpListener 构造参数一致端口对不上连接直接被拒通信超时30005000ms太小网络抖动就判定断线频繁重连重连间隔20005000ms太短会在服务端产生大量半开连接采集周期100500ms 起步设成 10ms报文量超出现场交换机承载超时这一项血泪经验最多。有人把通信超时设成 500ms想“快一点发现问题”结果现场一个广播风暴就全线重连屏上的数据闪成一片。我的习惯是超时给到 3 秒以上重连间隔给 2 秒宁可晚两秒恢复也别让连接状态反复抖动。3. 报文对齐字节序、数据类型和变量映射表怎么一次定死链路通了只是第一步真正耗时间的是报文里那几十个字节。这一层如果一开始没约定清楚后面每加一个变量都要重新对一次改一次错一次。3.1 自定义文本协议和 ModbusTCP 的取舍组态侧子设备选什么直接决定报文长什么样。两条路差别很大对比项自定义文本/二进制协议ModbusTCP 标准从站组态侧配置子设备用字符串收发逐条配收发规则挂 ModbusTCP 子设备填寄存器地址调试难度需要自己写解析器但抓包能直接看懂结构标准抓包工具自带解码数据量几百字节一次传完按寄存器批量读写受单次长度限制扩展性加字段改两端解析加字段要重新分地址老地址不能动适合谁报文里带字符串、状态字、多类型混合纯数值、周期性采集我的判断标准很简单报文里只要有中文、有变长字符串、有需要一次性传完的配方数据就走自定义协议纯数值轮询就走 ModbusTCP。很多现场其实是前者因为组态屏上要显示报警文本、批次号这些东西。3.2 字节序和数据类型别让 float 变成天文数字这是最容易出错、也最容易被忽视的地方。C# 的BitConverter在 x86 机器上是小端而 Modbus 和大多数组态软件的寄存器解析按大端来。一个 25.6 的 float字节序反了之后解析出来能是个负十几亿的数第一次遇上会以为是量程算错了查半天才发现是字节序。// float 转大端四字节组态侧按两个连续寄存器解析 static byte[] FloatToBigEndian(float value) { byte[] b BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) Array.Reverse(b); // 小端机器必须反转 return b; } // 大端四字节还原成 floatoffset 指向高字节 static float BigEndianToFloat(byte[] buffer, int offset) { byte[] t new byte[4]; Array.Copy(buffer, offset, t, 0, 4); if (BitConverter.IsLittleEndian) Array.Reverse(t); return BitConverter.ToSingle(t, 0); } // short 同样要注意Modbus 寄存器是 16 位大端 static byte[] Int16ToBigEndian(short value) { byte[] b BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) Array.Reverse(b); return b; }BitConverter.IsLittleEndian这个判断不能省虽然绝大多数工控机是 x86但代码一旦在 ARM 工控机上跑现在低功耗盒子很多是 ARM写死反转就会出错。浮点数建议在协议里明确约定“四字节大端 IEEE754”别用组态侧那种“高低字交换”的含糊说法那个配置项不同驱动叫法还不一样。3.3 变量映射表怎么定一次定死不返工报文里每个字段的位置得有一张表这张表要跟着代码一起进版本库不能只存在某个人的脑子里。偏移长度类型含义组态侧对应备注02byte帧头 0xAA55固定用于帧同步22ushort负载长度大端固定不含帧头和 CRC42ushort站号设备编号多屏时区分来源64float温度1数据对象1大端 IEEE754104float压力1数据对象2大端 IEEE754142short运行状态数据对象30 停 1 运行 2 故障1616string批次号字符对象GBK 编码右补 0x00322ushortCRC16——覆盖长度到负载末偏移一旦定死就不要动。要加字段只能往后追加老字段位置保持不变。这条规矩听着啰嗦但现场升级的时候你就知道值了新版本上位机接老屏老屏不认新字段直接忽略尾部就行不用两边同时停机升级。字符串那栏写 GBK 是有原因的见第 5 章。4. 收发流水线粘包、心跳、重连三件事怎么写才不翻车到这一章链路和报文都定好了剩下的活是把收发做成一条不会断的流水线。这三件事教科书上一句话带过现场却能折腾掉一整晚。4.1 粘包半包长度前缀加缓冲区别用“一次 Read 一帧”的假设TCP 是字节流没有消息边界。组态屏 100ms 发一帧网络好的时候一次 Read 正好一帧测试阶段看起来一切正常等现场数据量上来或者交换机上多了几台设备立刻开始出现“数据错位”“数值跳变”。解决办法只有一个在缓冲区里自己找帧边界。协议里那个两字节长度字段就是为此准备的。// 帧格式AA 55 | len(2字节大端,仅负载) | payload | CRC16(2字节) static bool TryTakeFrame(Listbyte acc, out byte[] frame) { frame Array.Emptybyte(); if (acc.Count 6) return false; // 头2 长度2 CRC2不够就等 // 帧头不对说明前面有脏数据或半帧残留逐字节前移重新同步 if (acc[0] ! 0xAA || acc[1] ! 0x55) { acc.RemoveAt(0); return false; } int len (acc[2] 8) | acc[3]; // 大端还原负载长度 if (len 0 || len 2048) // 长度异常视为伪帧头 { acc.RemoveRange(0, 2); return false; } int total 4 len 2; if (acc.Count total) return false; // 半包等下一次 Read 补齐 frame acc.GetRange(0, total).ToArray(); acc.RemoveRange(0, total); // 消费掉这一帧 return true; }三个关键点。第一长度上限必须设否则一个错误的长度字段会让你一直等一个永远不来的 64KB 包。第二帧头不符时只删一个字节而不是两个因为可能真的是0xAA打头但后面被污染了删两个会跳过真正的帧头。第三acc要在整个连接生命周期里复用不能每次 Read 都 new 一个那样半包永远拼不起来。这套逻辑跑通的标准不是“能收到数据”而是拿组态侧把采集周期调到 10ms、连续发一小时不丢帧不报错。没过这一关别急着上现场。4.2 心跳与假死判定连着但没数据比断开更难查TCP 连接还在但对端进程挂了、网线松了、交换机端口进入某种半通状态——这时候ReadAsync既不返回数据也不报错就那么挂着。屏上画面还亮着数据早就定格在十分钟前。判定方式得自己写记录每个连接最后一次收到数据的时间超时就主动关掉。private static readonly ConcurrentDictionarystring, DateTime LastSeen new(); // 心跳巡检建议 1 秒跑一次 static void CheckHeartbeat() { DateTime now DateTime.UtcNow; foreach (var kv in LastSeen) { // 10 秒没收到任何报文判定假死主动关闭触发重连 if ((now - kv.Value).TotalSeconds 10) { Console.WriteLine(${kv.Key} 心跳超时主动断开); CloseClient(kv.Key); LastSeen.TryRemove(kv.Key, out _); } } }超时阈值要跟组态侧采集周期对齐。采集周期 500ms 的话10 秒相当于容忍 20 个周期丢失够用了。设成 2 秒就太激进现场一次 GC 停顿或者交换机生成树收敛都能触发误判。另外注意心跳不一定要专门发心跳包。组合 A 里屏是主动上报的只要在每次Dispatch成功解析一帧后更新LastSeen就行零成本。4.3 断线重连客户端那侧的重连别自己写C# 当服务端的时候组合 A重连这件事其实是组态屏在干父设备里的重连间隔参数就是干这个的。我们要做的只有两件事一是连接断开后把资源彻底清理干净二是别让旧连接的残留数据污染新连接。清理要彻底client.Close()之后acc缓冲区必须丢弃重建。曾经遇到过一个诡异的 bug重连之后解析出来的第一批数据全是乱的查了两小时才发现是上一次连接残留的半帧数据还在列表里跟新连接的帧头拼在一起了。C# 当客户端的时候组合 B重连得自己写static async Task ConnectLoopAsync(string ip, int port, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { using var client new TcpClient(); client.NoDelay true; await client.ConnectAsync(ip, port, ct); Console.WriteLine(连接成功); await PumpAsync(client, ct); // 正常收发直到异常或取消 } catch (Exception ex) { Console.WriteLine($连接失败{ex.Message}); } // 固定间隔重连别用指数退避工控现场要的是稳定可预期 await Task.Delay(2000, ct); } }重连间隔用固定 2 秒不用指数退避。指数退避适合公网服务现场局域网里网络恢复通常是瞬时的退避到几十秒反而拖慢恢复。而且固定间隔的心跳式重连从日志上一眼就能看出断了几次、断了多久。4.4 多站号并发连接字典加一把写锁一台上位机对多块屏时每条连接都要能被找到。用ConcurrentDictionary存连接键用“IP:端口”或者协议里的站号。写入这侧要注意多个线程可能同时往同一条NetworkStream写。TCP 本身不保证多线程写的原子性两条报文交错写进去就是一段废数据。简单做法是每条连接配一个SemaphoreSlim(1,1)写之前拿锁。private static readonly ConcurrentDictionarystring, SemaphoreSlim WriteLocks new(); static async Task SendAsync(string key, NetworkStream stream, byte[] payload) { var gate WriteLocks.GetOrAdd(key, _ new SemaphoreSlim(1, 1)); await gate.WaitAsync(); try { await stream.WriteAsync(payload, 0, payload.Length); await stream.FlushAsync(); } finally { gate.Release(); } }SemaphoreSlim比lock好在这里是异步等待不会阻塞线程池线程。字典的键记得在连接断开时清掉不然跑几天之后字典里全是死连接的锁对象。5. 避坑与排查五个把联调拖到后半夜的具体故障5.1 连上了但一个字节都收不到现象C# 日志里显示客户端接入IP 端口都对组态屏上却一直提示通信失败服务端ReadAsync永远不返回数据。原因通常有三个一是上位机开了 Windows 防火墙入站规则没放行那个端口连接能建立是因为防火墙对已建立连接的处理方式比较宽松但后面数据被拦二是组态侧子设备的采集周期配成了 0 或者通道没启用三是 C# 监听绑在了IPAddress.Loopback上只接了本机回环。解决先用netstat -ano | findstr 8899确认监听地址是0.0.0.0而不是127.0.0.1。再临时关掉防火墙测一次通了就加一条入站规则TCP指定端口允许。这三步能解决九成的“连上没数据”。5.2 中文字符串过去变成问号或者乱码现象数字全对批次号过来是“???”或者一堆方块。原因编码不一致。C# 默认Encoding.UTF8而组态侧字符串对象在很多配置下按 GBK 处理。两边一个 UTF-8 一个 GBKASCII 字符正常中文全废。解决协议里明确写死编码C# 侧统一用Encoding.GetEncoding(GBK)并且在项目文件里加CodePagesEncodingProvider注册因为 .NET Core 之后 GBK 不在默认编码表里。或者在协议层干脆传拼音或者纯数字批次号从源头绕开编码问题——我倾向后者省事。5.3 数值偶发跳变重启就好现象温度值大部分时间正常偶尔蹦出一个 -1.7e38 或者 6553.5刷新一下又好了。原因粘包导致解析偏移错位。比如某一帧少了两个字节后面所有字段的位置全往前挪float 就从错误的位置读出来。这种错误在低数据量下不出现压力一上来就冒头。解决别在ReadAsync的返回缓冲区里直接解析一定先塞进acc列表走TryTakeFrame。另外加一个 CRC 校验校验失败的帧直接丢别硬解。丢一帧远好过解出一堆假数据。5.4 跑几个小时必断一次时间还不固定现象刚启动稳定两三个小时后连接莫名其妙断开重连后又能跑几个小时。原因多数是交换机或者中间设备的会话老化。空闲连接长时间没有数据流交换机的 MAC 表项或者 NAT 表项被回收。也有可能是上位机 Windows 的电源管理把网卡休眠了。解决保持心跳流量哪怕没有业务数据也每 5 秒发一个空帧或者在 C# 里开启 TCP KeepAliveclient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); // Windows 上还需要改注册表的 KeepAliveTime默认两小时太长同时去设备管理器里把网卡的“允许计算机关闭此设备以节约电源”取消勾选这个设置坑过太多次。5.5 数值对不上地址差了一位现象组态屏上读到的第一个数据对象数值其实是第二个传感器的。原因Modbus 的寄存器地址在手册里从 1 开始编号报文里从 0 开始。两边差一位。另外自定义协议里如果用了“数据对象1、数据对象2”这种编号也要确认组态侧是不是从 0 开始索引。解决拿一个已知值比如固定 100单独占一个寄存器用抓包工具看报文里的实际偏移对上之后就固定下来。别靠推算一定要抓一次包确认。6. 进阶收口让这份范例从“能通”走到“能上产线”范例代码跑通之后距离真正上产线还差一层。我自己的习惯是加三样东西。第一样是抓包验证。装一个能解 Modbus 或者至少能十六进制查看的抓包工具在服务端和组态屏之间抓一次完整交互把请求和响应保存成文件。以后现场再出问题第一件事就是让运维抓一段包发过来比在电话里描述半天“数据不对”有效得多。抓包文件要按时间命名归档这是排障的后悔药。第二样是协议表驱动化。上面那张偏移表不要只写在文档里直接写成 C# 的静态数组或者配置表字段名偏移类型缩放报警上限温度16float1.085.0压力110float1.01.6运行状态14short————解析函数按表遍历加字段只改表不改代码。缩放这一列特别有用组态侧传上来的是放大 10 倍的整数解析时乘 0.1 就行避免浮点传输带来的精度争议。第三样是日志。每条连接一个日志文件记录连接、断开、每帧长度、CRC 校验结果。日志不用多漂亮但要能回答“几点几分断的、断之前最后一帧是什么”。血泪教训是有一条早年图省事把解析逻辑直接写在ReadAsync返回的缓冲区上测试阶段一次一帧从没出过问题上了现场第三天开始数据错乱查了两个通宵才发现是现场多接了一块屏之后数据量翻倍触发粘包。从那以后我每次接 TCP 项目都强制先用脚本按最坏情况连续压测一小时再谈上现场的事。希望这些能帮到你少走那两晚的弯路。本文还有配套的精品资源点击获取