C#上位机与MCGS触摸屏TCP通信:Modbus报文与断线重连 简介面向工业自动化初学者与C#上位机开发者的TCP通信范例围绕C#与MCGS昆仑通态之间的数据交互展开重点演示在Visual Studio 2013中编写的Windows Forms程序如何连接MCGS并读取其数据。压缩包为rar格式共36个文件、约92KB以9个C#源码文件为核心辅以项目解决方案、工程配置、资源文件、可执行程序及说明文本能够较完整地呈现一个小型上位机工程的组成。目前已有2507人学习适合作为工控通信入门与工程结构参考。范例包含看板人机工程示例读者可据此理解TCP连接建立、数据读取、界面显示与项目配置之间的配合关系也可对照源码梳理WinForms上位机与MCGS通信的基本流程。对于课程设计、毕业设计或设备监控类小项目这份代码可用于搭建验证环境并借鉴其目录组织与排错思路。1. 触摸屏上的数怎么可靠地跑进 C# 上位机车间里最常见的组合一台工控机跑 C# 上位机做报表、追溯和看板旁边一块触摸屏跑 MCGS昆仑通态做现场操作。两边各存一份数据时间一长必然对不上。最省事的打通方式就是走 TCP 通信屏那边开监听端口当服务端C# 这边当客户端连上去读写寄存器一根网线、一个 IP、一个端口就能把数据通道拉直。这套范例代码解决的就是这个场景不装 OPC、不引中间件。它适合做设备数据采集、产线看板、跟 MES 做轻量对接的从业者也适合刚接手组态项目、被“屏和上位机怎么交换数据”卡住的新手。下面按“链路能通、地址对得上、报文拆得开、断线能自己爬起来”的顺序往下推。2. 先让链路通TcpClient 的连接骨架与三个关键参数2.1 谁监听、谁连接第一天就要定死TCP 通信第一件事不是写代码是把两边的角色定死。工控现场九成的情况是 MCGS 那侧做服务端触摸屏或者工控机上的 MCGS 运行时开着监听端口C# 上位机作为客户端主动连过去。这么定有两个好处一是 MCGS 重启之后端口会自动重新监听C# 这边只要把重连做扎实就行二是多台上位机可以同时连同一块屏屏不用关心谁来取数、取几次。反过来也有C# 做服务端MCGS 用“通用 TCP/IP 父设备”主动往上位机连。这种多见于屏在设备端、上位机在机房网络方向受限的场景。缺点是屏那边的重连行为不好控制断线之后经常要人到现场点一下。我一般优先选 MCGS 做服务端除非现场网络只允许单向发起连接。端口这块Modbus TCP 的惯例是 502但 502 在不少工控机上被别的采集软件占着某些系统下低于 1024 的端口还需要额外权限。现场我通常改成 6000 或者 8000MCGS 设备属性里能设本地端口C# 跟着改就行。这件事一定要在联调第一天定下来后面再改两边的配置、文档、现场调试记录全得跟着动血泪经验。2.2 一个能跑通的连接与收发骨架先把“连得上、收得到”这件事单独验证别一上来就写 Modbus 组帧。下面这段是最小骨架重点在超时和 Nagle 这两个容易漏的地方。using System; using System.IO; using System.Net.Sockets; public class TcpLink : IDisposable { private TcpClient _client; private NetworkStream _stream; // ip/port 来自 MCGS 设备属性里的本地 IP 与本地端口 public bool Connect(string ip, int port, int timeoutMs 3000) { Close(); _client new TcpClient(); _client.NoDelay true; // 关掉 Nagle小报文场景别让内核攒包 // BeginConnect 等待句柄补上 TcpClient 自带连接没有超时的问题 var ar _client.BeginConnect(ip, port, null, null); if (!ar.AsyncWaitHandle.WaitOne(timeoutMs)) { Close(); return false; } _client.EndConnect(ar); _stream _client.GetStream(); _stream.ReadTimeout 1000; // 读超时避免卡死采集线程 _stream.WriteTimeout 1000; return true; } public void Send(byte[] frame) { _stream.Write(frame, 0, frame.Length); } // 只负责把字节读进来拆帧交给上层后面 4.4 会用到 public int Read(byte[] buffer) { return _stream.Read(buffer, 0, buffer.Length); } public void Close() { try { _stream?.Close(); } catch { } try { _client?.Close(); } catch { } _stream null; _client null; } public void Dispose() Close(); }BeginConnect那几行是整段里最值得抄的部分。直接调Connect(ip, port)在网线没插、IP 写错、屏还没启动的时候会阻塞很久采集线程就那么挂着界面上什么都不刷看起来像死机。用异步句柄加WaitOne(timeoutMs)3 秒连不上就返回 false上层可以打日志、可以重试、可以提示“通讯断开”行为是可预期的。NoDelay true也建议保留。Modbus 请求帧只有 12 个字节Nagle 算法会把小包攒一攒再发正常情况下影响不大但在 100ms 级的心跳周期下会放大成几百毫秒的抖动排查起来很玄学因为抓包看到的是“晚发”而不是“丢包”。2.3 NoDelay、ReadTimeout、KeepAlive 三个参数的取值参数建议值作用不设的后果NoDelaytrue关闭 Nagle 攒包小帧延迟抖动心跳时间不准ReadTimeout8001500ms单次读阻塞上限对端不回包时线程永久挂起WriteTimeout8001500ms单次写阻塞上限发送缓冲区满时卡住Socket KeepAlive视现场而定内核层探活拔网线后长时间不知道对端已死ReadTimeout不宜设得太短。MCGS 在触摸屏上跑CPU 资源紧张的时候处理一帧要几十毫秒超时设成 200ms 会出现“偶发超时、重试又好了”的假故障。我一般给 1000ms心跳周期 2 秒连续三次失败才判定链路断开这样误判率低。KeepAlive 在内网短连接场景里作用有限拔网线这种情况靠应用层心跳反而更快发现。所以链路层只负责“连和收发”探活放到第 6 章的心跳循环里做职责分开代码也好维护。3. MCGS 侧怎么配设备窗口、通道映射与地址表3.1 驱动选型Modbus TCP 还是通用 TCP/IP 设备MCGS 里跟外部设备通信入口都在“设备窗口”。不同版本的菜单叫法略有差异思路是一致的先放一个父设备再挂具体的协议驱动。做 Modbus TCP 通信时优先用 Modbus TCP 相关驱动它把 MBAP 头、功能码、寄存器区都封装好了你只需要填 IP、端口、站号和通道地址剩下的交给运行时。如果现场用的是自定义帧格式那就用“通用 TCP/IP 父设备”加自定义协议自己定义收发字节。这条路灵活但代价是所有帧解析都得在 MCGS 的脚本里写调试成本高而且不同版本的脚本能力有差异。我的建议是只要对端是 C#、数据是结构化寄存器就走 Modbus TCP别为了省一点映射工作去写自定义协议。配置顺序上有个容易踩的点设备属性填完不等于生效工程要重新下载到触摸屏或者重新启动模拟运行端口才会真正监听起来。很多人改完 IP 就在 C# 那边死连其实屏上跑的还是旧工程。3.2 通道映射地址从 0 开始还是从 1 开始通道是 MCGS 和设备之间的数据桥梁一个通道对应一个寄存器。下面这张表是我做示例工程时的映射C# 侧就按这个地址表去读写。MCGS 通道名寄存器区协议地址PDU数据类型读写用途设备0_读写4000014 区保持寄存器016 位无符号读写启动命令设备0_读写4000024 区保持寄存器116 位无符号读写运行状态设备0_读写4000034 区保持寄存器232 位浮点读写温度给定设备0_读写4000044 区保持寄存器316 位无符号读写报警字设备0_读写4000054 区保持寄存器432 位浮点只读实测温度关键在第三列。通道名里的“400001”是给人看的习惯编号协议报文里传的是从 0 开始的偏移量。也就是说通道名 400001 对应起始地址 0400003 对应起始地址 2。C# 组帧时如果直接把 400003 填进去MCGS 会回一个异常码 0x02非法数据地址这个坑我在第 5 章会单独展开。32 位数据占两个连续寄存器所以温度给定占了地址 2 和 3下一个通道从地址 4 开始。建通道的时候一定要按这个规律排中间别插队否则后面新增点位就得整体重排C# 那边的地址常量全要改。3.3 用内部变量中转别让通道裸奔通道建好之后最省事的做法是让画面上的构件直接连通道。短期能跑长期是灾难换一个屏、改一次地址、加一个单位换算画面上的每个构件都得点开改一遍。我一般会在“实时数据库”里建一组同名内部变量通道只负责往内部变量里写画面和脚本全部读内部变量。这么做有三个实际好处。一是地址变更只动设备窗口一处画面零改动二是可以在通道和变量之间做量程换算比如寄存器里存的是 0.1 摄氏度为单位的整数内部变量按浮点显示脚本里乘 0.1 就行三是报警、存盘、曲线都挂在内部变量上换设备不影响历史数据的结构。变量命名也建议带上单位和方向比如PV_Temp_C、SP_Temp_C、CMD_Start。联调的时候看一眼变量名就知道该读哪个地址比翻设备窗口快得多。4. 报文对齐MBAP 头、功能码与浮点数的字节序4.1 一帧的字节布局先看头七个字节Modbus TCP 的帧结构是在 Modbus RTU 的 PDU 前面加了一个 7 字节的 MBAP 头。这 7 个字节是拆帧和排错的全部依据。偏移长度字段说明02事务标识符由客户端自增响应原样带回用来配对请求和响应22协议标识符Modbus 固定 0x000042长度其后字节数含单元标识符61单元标识符站号要和 MCGS 设备地址一致71功能码0x03 读、0x06 写单个、0x10 写多个8…数据地址、数量或寄存器值组帧代码本身不长但每个字节的位置都不能错错一位对端就回异常码或者干脆不回。// 组一帧 0x03 读保持寄存器请求 // transId 事务号自增unitId 与 MCGS 设备地址一致 static byte[] BuildReadRequest(ushort transId, byte unitId, ushort startAddr, ushort count) { var f new byte[12]; f[0] (byte)(transId 8); // 事务号高字节 f[1] (byte)(transId 0xFF); // 事务号低字节 f[2] 0; f[3] 0; // 协议标识固定 0 f[4] 0; f[5] 6; // 长度单元号1 功能码1 地址2 数量2 f[6] unitId; f[7] 0x03; // 功能码读保持寄存器 f[8] (byte)(startAddr 8); // 起始地址高字节 f[9] (byte)(startAddr 0xFF); f[10] (byte)(count 8); // 寄存器数量高字节 f[11] (byte)(count 0xFF); return f; } // 组一帧 0x06 写单个寄存器 static byte[] BuildWriteSingle(ushort transId, byte unitId, ushort addr, ushort value) { var f new byte[12]; f[0] (byte)(transId 8); f[1] (byte)transId; f[2] 0; f[3] 0; f[4] 0; f[5] 6; f[6] unitId; f[7] 0x06; f[8] (byte)(addr 8); f[9] (byte)addr; f[10] (byte)(value 8); f[11] (byte)value; return f; }startAddr填的是协议地址不是通道名里的编号。读温度给定通道 400003就填 2数量填 2因为浮点占两个寄存器。count别超过 125这是 Modbus 协议对 0x03 功能码的上限超了有的从站直接回异常有的直接断开连接表现不一样排查时容易误判成网络问题。4.2 四个功能码和异常响应怎么认功能码含义地址区单次数量上限常用场景0x01读线圈0 区2000开关量状态0x03读保持寄存器4 区125模拟量、参数0x06写单个寄存器4 区1单个设定值0x10写多个寄存器4 区123批量下发配方异常响应的特征是功能码最高位置 1比如 0x03 变成 0x83后面跟一个字节的异常码。0x02 是非法数据地址基本就是地址算错了或者通道没建0x03 是非法数据值数量或取值越界0x04 是从站设备故障通常是 MCGS 那边脚本报错或者通道绑定的变量类型不匹配。认这三个码现场八成的通信故障能当场定位。4.3 32 位数据两个寄存器怎么拼成浮点浮点数在 Modbus 里没有统一规定字序不同厂家有“高字在前”和“低字在前”两种。MCGS 通道里选 32 位浮点时一般有字序选项C# 这边必须跟它对齐否则读出来的数是个天文数字或者接近 0 的怪值。// 把两个 16 位寄存器拼成 float // swapWord false 表示高字在前多数默认true 表示低字在前 static float ToFloat(ushort first, ushort second, bool swapWord false) { ushort hi swapWord ? second : first; ushort lo swapWord ? first : second; var b new byte[4]; b[0] (byte)(hi 8); b[1] (byte)(hi 0xFF); b[2] (byte)(lo 8); b[3] (byte)(lo 0xFF); // x86 平台是小端BitConverter 按本机字节序解释先翻回来 if (BitConverter.IsLittleEndian) Array.Reverse(b); return BitConverter.ToSingle(b, 0); }判断字序不要靠猜。做法是在 MCGS 画面里给温度给定写一个已知值比如 25.5然后用 C# 把两个寄存器原样读出来打印成十六进制。25.5 的 IEEE754 表示是 0x41CC0000如果读到的是41 CC 00 00就是高字在前读到00 00 41 CC就是低字在前。一次验证后面所有浮点通道都按这个规则来。32 位有符号整数同理只是最后用BitConverter.ToInt32。别用“两个 16 位拼字符串再转”的野路子负数会出错。4.4 写下去再读回来一条完整往返把组帧、发送、拆帧、解析串起来才是能用的读写方法。拆帧必须按 MBAP 长度字段累积不能假设一次 Read 就是一帧。private readonly Listbyte _rx new Listbyte(); private ushort _transId; private readonly object _ioLock new object(); ushort NextTransId() _transId; // 按 MBAP 长度字段从缓冲区里切出完整帧 byte[] ReadFrame(int timeoutMs) { var deadline Environment.TickCount timeoutMs; while (Environment.TickCount deadline) { if (_rx.Count 6) { int bodyLen (_rx[4] 8) | _rx[5]; // 长度字段 int total 6 bodyLen; // 整帧字节数 if (_rx.Count total) { var frame _rx.GetRange(0, total).ToArray(); _rx.RemoveRange(0, total); return frame; } } var buf new byte[1024]; int n _link.Read(buf); if (n 0) throw new IOException(对端关闭连接); for (int i 0; i n; i) _rx.Add(buf[i]); } throw new TimeoutException(等待完整帧超时); } // 单连接串行化避免多线程同时发请求导致回包串台 byte[] SendAndReceive(byte[] request, int timeoutMs 1000) { lock (_ioLock) { _link.Send(request); return ReadFrame(timeoutMs); } } ushort[] ReadHolding(byte unitId, ushort startAddr, ushort count) { var resp SendAndReceive(BuildReadRequest(NextTransId(), unitId, startAddr, count)); if (resp.Length 9) throw new Exception(响应长度不足); if (resp[7] ! 0x03) throw new Exception($异常响应功能码 0x{resp[7]:X2}异常码 0x{resp[8]:X2}); if (resp[8] ! count * 2) throw new Exception(寄存器数量与请求不符); var values new ushort[count]; for (int i 0; i count; i) values[i] (ushort)((resp[9 i * 2] 8) | resp[10 i * 2]); return values; }lock (_ioLock)这一行在多线程采集里是保命的。一个连接上同时发两个请求回包顺序不保证读温度的结果可能被当成读报警的结果数据看起来“偶尔错一下”极难复现。要么串行化要么按事务号建字典配对小项目用前者更省心。5. 避坑与排查五个把联调拖到通宵的坑5.1 链路层连不上、连上没回音、跑着跑着错位坑一C# 报“目标计算机积极拒绝”MCGS 那边看着一切正常。现象是连接瞬间失败错误码很明确。原因通常有三个工程没真正下载到屏上跑设备窗口里配的端口没监听端口被别的软件占了工控机和屏不在一个网段。解决顺序是先Test-NetConnection 屏IP -Port 6000或 telnet 测端口通不了就回去查 MCGS 工程是否重新下载、设备属性里的本地端口是否和代码一致再看两边 IP 掩码。别在代码里反复加重试先确认对端有没有在听。坑二能连上发一帧过去毫无回音或者回 0x83 加异常码 0x02。现象是 socket 建立成功写也成功读超时。原因是单元标识符站号和 MCGS 设备地址不一致或者起始地址超出已建通道的范围或者功能码对应的寄存器区根本没建通道。解决方法是把站号对一遍然后只读地址表里确实存在的通道数量从 1 开始试通了再扩。异常码 0x02 几乎百分百是地址问题别怀疑网线。坑三刚开始正常跑几分钟后读到的值开始“串台”。现象是数值偶尔错位或者读到上一帧的尾巴重启程序又好了。原因是 TCP 是字节流一帧可能被拆成两次到达两帧也可能粘在一次 Read 里而代码里假设“一次 Read 就是一帧”。解决方法就是 4.4 里的累积缓冲区按长度字段切帧切不完整就继续读。这个坑的隐蔽性在于低频偶发压力小的时候测不出来。5.2 数据层数值乱、回包串台坑四整数读得对浮点数读出来是 1.2E-38 这种怪值。现象是同一个通道按 16 位无符号读正常按浮点解析就离谱。原因无非两个字序反了或者 MCGS 通道的数据类型没选成 32 位浮点而 C# 按浮点解释了。解决方法是用 25.5 这种特征值做一次十六进制比对确认字序后在代码里固定swapWord的取值别留成运行时可调参数否则换个现场又得重新试。坑五多线程或定时器并发采集时回包和请求对不上号。现象是读 A 通道返回了 B 通道的值或者事务号对不上。原因是一个 socket 上同时有多个在途请求响应没有按事务标识符匹配。解决方法是所有收发走同一个锁串行化或者维护一个“事务号 → 等待句柄”的字典收到响应再唤醒对应请求。小规模采集用锁就够了几十个点位、秒级周期完全扛得住。6. 稳链与验证心跳周期、重连退避和一把压测脚本6.1 心跳别发空包用真实读请求当探针Modbus 没有标准心跳帧我一般固定读一个“探针寄存器”比如通道 400002 的运行状态周期 2 秒。这么做有两个好处探针本身就是业务帧链路活着的同时还验证了协议栈和地址映射对端不用为心跳做任何特殊处理。判定逻辑是连续三次超时才认为断开单次超时只记日志。// 探针读失败三次才判死避免屏忙时误判 int failCount 0; while (!ct.IsCancellationRequested) { try { if (!IsAlive()) { Connect(_ip, _port); } ReadHolding(_unitId, 1, 1); // 读运行状态寄存器当探针 failCount 0; _backoff 500; } catch (Exception ex) { if (failCount 3) { Close(); } Log(ex.Message); Thread.Sleep(_backoff); _backoff Math.Min(_backoff * 2, 10000); // 指数退避别把屏打爆 } Thread.Sleep(2000); }TcpClient.Connected这个属性不能当实时状态用它只是上次 IO 的结果缓存。拔网线之后它可能还是 true要靠Poll加Available判断或者干脆以读写异常为准。6.2 用压测脚本把偶发问题逼出来手工点几下看不出粘包和并发问题。我习惯用一段脚本连续读一千次统计成功率和平均耗时跑之前先把 MCGS 工程跑起来。#!/usr/bin/env bash # 连续读 1000 次保持寄存器统计成功率和平均往返耗时 IP192.168.1.10; PORT6000; ok0; fail0; total0 for i in $(seq 1 1000); do s$(date %s%3N) n$(python3 - $IP $PORT PY import socket, struct, sys ip, port sys.argv[1], int(sys.argv[2]) # 事务号1协议0长度6单元号1功能码03地址1数量1 req struct.pack(HHHBBHH, 1, 0, 6, 1, 0x03, 1, 1) s socket.create_connection((ip, port), timeout1) s.sendall(req); data s.recv(64); s.close() print(len(data)) PY ) e$(date %s%3N); total$((total e - s)) if [ $n -ge 9 ] 2/dev/null; then ok$((ok1)); else fail$((fail1)); fi done echo ok$ok fail$fail avg$((total/1000))ms成功率低于 99% 就别急着上线先用抓包工具过滤tcp.port6000看事务号是否一一对应、有没有重传。压测跑通之后再接真实业务比在现场对着屏幕猜快得多。这份范例我整理成了 C# 客户端工程、服务端对照工程和一份 MCGS 通道配置说明照着地址表配完就能通。从那以后我每次接新现场都强制先跑一遍连接、再跑一遍压测、最后才写业务逻辑返工次数少了一大半。希望帮到你。本文还有配套的精品资源点击获取