简介:这份范例围绕 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 List<byte>(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 构造参数一致 | 端口对不上,连接直接被拒 |
| 通信超时 | 3000~5000ms | 太小,网络抖动就判定断线,频繁重连 |
| 重连间隔 | 2000~5000ms | 太短会在服务端产生大量半开连接 |
| 采集周期 | 100~500ms 起步 | 设成 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; } // 大端四字节还原成 float,offset 指向高字节 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 变量映射表怎么定,一次定死不返工
报文里每个字段的位置得有一张表,这张表要跟着代码一起进版本库,不能只存在某个人的脑子里。
| 偏移 | 长度 | 类型 | 含义 | 组态侧对应 | 备注 |
|---|---|---|---|---|---|
| 0 | 2 | byte | 帧头 0xAA55 | 固定 | 用于帧同步 |
| 2 | 2 | ushort | 负载长度(大端) | 固定 | 不含帧头和 CRC |
| 4 | 2 | ushort | 站号 | 设备编号 | 多屏时区分来源 |
| 6 | 4 | float | 温度1 | 数据对象1 | 大端 IEEE754 |
| 10 | 4 | float | 压力1 | 数据对象2 | 大端 IEEE754 |
| 14 | 2 | short | 运行状态 | 数据对象3 | 0 停 1 运行 2 故障 |
| 16 | 16 | string | 批次号 | 字符对象 | GBK 编码,右补 0x00 |
| 32 | 2 | ushort | CRC16 | —— | 覆盖长度到负载末 |
偏移一旦定死就不要动。要加字段,只能往后追加,老字段位置保持不变。这条规矩听着啰嗦,但现场升级的时候你就知道值了:新版本上位机接老屏,老屏不认新字段,直接忽略尾部就行,不用两边同时停机升级。字符串那栏写 GBK 是有原因的,见第 5 章。
4. 收发流水线:粘包、心跳、重连三件事怎么写才不翻车
到这一章,链路和报文都定好了,剩下的活是把收发做成一条不会断的流水线。这三件事教科书上一句话带过,现场却能折腾掉一整晚。
4.1 粘包半包:长度前缀加缓冲区,别用“一次 Read 一帧”的假设
TCP 是字节流,没有消息边界。组态屏 100ms 发一帧,网络好的时候一次 Read 正好一帧,测试阶段看起来一切正常;等现场数据量上来,或者交换机上多了几台设备,立刻开始出现“数据错位”“数值跳变”。
解决办法只有一个:在缓冲区里自己找帧边界。协议里那个两字节长度字段就是为此准备的。
// 帧格式:AA 55 | len(2字节大端,仅负载) | payload | CRC16(2字节) static bool TryTakeFrame(List<byte> acc, out byte[] frame) { frame = Array.Empty<byte>(); 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 ConcurrentDictionary<string, 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 ConcurrentDictionary<string, 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 一个 GBK,ASCII 字符正常,中文全废。
解决:协议里明确写死编码,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 KeepAlive:
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); // Windows 上还需要改注册表的 KeepAliveTime,默认两小时太长同时去设备管理器里把网卡的“允许计算机关闭此设备以节约电源”取消勾选,这个设置坑过太多次。
5.5 数值对不上:地址差了一位
现象:组态屏上读到的第一个数据对象,数值其实是第二个传感器的。
原因:Modbus 的寄存器地址在手册里从 1 开始编号,报文里从 0 开始。两边差一位。另外自定义协议里如果用了“数据对象1、数据对象2”这种编号,也要确认组态侧是不是从 0 开始索引。
解决:拿一个已知值(比如固定 100)单独占一个寄存器,用抓包工具看报文里的实际偏移,对上之后就固定下来。别靠推算,一定要抓一次包确认。
6. 进阶收口:让这份范例从“能通”走到“能上产线”
范例代码跑通之后,距离真正上产线还差一层。我自己的习惯是加三样东西。
第一样是抓包验证。装一个能解 Modbus 或者至少能十六进制查看的抓包工具,在服务端和组态屏之间抓一次完整交互,把请求和响应保存成文件。以后现场再出问题,第一件事就是让运维抓一段包发过来,比在电话里描述半天“数据不对”有效得多。抓包文件要按时间命名归档,这是排障的后悔药。
第二样是协议表驱动化。上面那张偏移表不要只写在文档里,直接写成 C# 的静态数组或者配置表:
| 字段名 | 偏移 | 类型 | 缩放 | 报警上限 |
|---|---|---|---|---|
| 温度1 | 6 | float | 1.0 | 85.0 |
| 压力1 | 10 | float | 1.0 | 1.6 |
| 运行状态 | 14 | short | —— | —— |
解析函数按表遍历,加字段只改表不改代码。缩放这一列特别有用,组态侧传上来的是放大 10 倍的整数,解析时乘 0.1 就行,避免浮点传输带来的精度争议。
第三样是日志。每条连接一个日志文件,记录连接、断开、每帧长度、CRC 校验结果。日志不用多漂亮,但要能回答“几点几分断的、断之前最后一帧是什么”。
血泪教训是有一条:早年图省事,把解析逻辑直接写在ReadAsync返回的缓冲区上,测试阶段一次一帧从没出过问题,上了现场第三天开始数据错乱,查了两个通宵才发现是现场多接了一块屏之后数据量翻倍触发粘包。从那以后我每次接 TCP 项目,都强制先用脚本按最坏情况连续压测一小时,再谈上现场的事。希望这些能帮到你少走那两晚的弯路。
本文还有配套的精品资源,点击获取