简介:在工业上位机开发中,以C#为客户端、MCGS昆仑通态为服务端的TCP通信是一类常见需求。这份范例代码面向自动化集成、组态软件二次开发的工程师,提供读取MCGS数据的完整样例工程,解决C#与昆仑通态触摸屏及组态环境之间的网络数据交换问题。压缩包共三十六个文件,体积约九十二KB,包含九个C#核心代码文件、四个文本说明、三个可执行程序以及工程配置、资源文件、调试符号等,覆盖源码、配置、运行与调试的典型文件类型,可直接打开解决方案查看和修改。目前已有二千五百零四人学习下载,适合有一定C#基础、需要快速实现与MCGS通信的开发者。通过该范例可以掌握Socket通信的建立、连接与数据帧解析方法,结合看板人机MCE文件和窗体应用工程结构,还能了解上位机界面与组态画面的联动测试方式,为后续接入实际设备或扩展多数据类型读写提供可直接复用的起点。
1. 用 C# 捅穿 MCGS 的 TCP 黑匣子:先想清楚三个问题
做上位机开发的人迟早会撞上 MCGS(昆仑通态)触摸屏,前期用 USB 下载程序、用串口调试都没问题,一到产线就要走以太网。手头这份「C# 与 MCGS 使用 TCP 通信范例代码」,真正的难点不在 C# 怎么写 Socket,而在三件事:MCGS 组态里的设备通道怎么映射成 Modbus 寄存器地址,报文按什么字节序解析,断线重连到底由谁负责。把这三件事想清楚,TCP 通信基本一次通。这篇笔记适合两类人:一类是刚接手上位机开发、需要从零对接组态屏的从业者;另一类是已经在用串口或 OPC、想切到 TCP 又怕踩坑的开发者。先说结论:MCGS 侧的 TCP 配置远比想象的琐碎,但摸清它的寄存器寻址规律后,C# 侧的代码其实可以写得很收敛,一份请求函数加一份解析函数就能覆盖九成场景。
2. 从组态端开始:MCGS 设备窗口与 TCP 参数配置
2.1 设备通道与寄存器地址映射
很多人拿到范例代码先去改 C#,结果连不上,实际上第一道坎在组态软件里。MCGS 里的「设备窗口」负责挂驱动、建通道、配变量。要点是先确定触摸屏用的是「TCP/IP」驱动还是「Modbus TCP」驱动,这两者报文格式一样,但变量地址的组织方式有差别。范例里用的是 Modbus TCP 驱动,因为它把通道地址直接暴露成 4x 区保持寄存器,方便上位机用功能码 03、06、16 去读写。
添加设备后,每个变量要绑定一个通道,通道地址格式通常是4x开头,比如4x0001。这个4x不是摆设,它告诉驱动这个变量映射到保持寄存器区。对应的 Modbus 协议地址要换算成报文里的寄存器编号,规则是报文地址 = 通道地址 - 40001,也就是说4x0001对应报文里的 0x0000,4x0010对应 0x0009。C# 侧构造请求时,寄存器地址写的是 0 基地址,不是组态里显示的那个从 1 开始的地址。这个偏移关系是大多数联调失败的根源,务必先确认清楚。
2.2 TCP 服务端/客户端与 IP 端口设置
MCGS 的 TCP/IP 驱动有两种工作模式:触摸屏做服务端,上位机做客户端主动连接;或者触摸屏做客户端,上位机开一个 TCP 服务端等它连上来。大多数产线场景选前者,因为上位机程序启动时机不确定,而触摸屏上电后应该一直监听。范例代码默认也是这个方案:触摸屏作为服务端监听 502 端口,C# 程序作为客户端发起连接。
在 MCGS 的 TCP/IP 设备属性里,需要填写本地 IP、端口号,以及允许连接的远程 IP。这里有一个容易被忽略的选项:如果组态里勾选了「仅允许固定 IP 连接」,而 C# 所在电脑的 IP 不在列表里,连接会被直接拒绝,表现是上位机发了 SYN 但触摸屏不回应。排查时先把这个选项放开,等联调通了再收紧。另外,如果 C# 程序跑在虚拟机上,注意虚拟机网卡和桥接模式,否则 IP 在同一个物理网段也收不到包。
2.3 变量联动与读写权限配置
建好通道后,还要在 MCGS 的「实时数据库」里建对应的变量,并把变量与通道关联。这里建议把变量名、通道地址、数据类型整理成一张对照表,C# 注释里也保留同一份表,否则后期加变量会乱。读写权限方面,MCGS 里每个通道可以设置「只读」「只写」「读写」三种权限。上位机要读数据,通道至少要是只读或读写;要下发参数,必须设为读写或只写。如果 C# 写入成功但触摸屏端数值不变,十有八九是通道权限没放开,而不是通信问题。
提示:联调之前,先把触摸屏和电脑接到同一台交换机,关掉两端防火墙,用 ping 确认 IP 通。这一步能省掉后面至少两小时排错时间。
3. C# 侧 TCP 连接:Socket 还是 TcpClient,参数怎么给
3.1 连接超时与断线重连
范例代码里可以只用最原始的 Socket,也可以用封装好的 TcpClient。我的习惯是直接用 Socket,因为 TcpClient 的 Connect 方法在网络不通时会卡很久,超时控制不如 Socket 直观。常见做法是用异步连接加等待句柄,简单可靠:
using System.Net.Sockets; using System.Net; using System.Threading; private static bool ConnectWithTimeout(Socket socket, IPEndPoint remote, int timeoutMs) { var result = socket.BeginConnect(remote, null, null); // 发起异步连接 bool connected = result.AsyncWaitHandle.WaitOne(timeoutMs, false); // 等待指定毫秒 if (connected) { socket.EndConnect(result); // 连接成功,结束异步操作 return true; } socket.Close(); // 超时则关闭,避免半连接残留 return false; }这段代码的价值在于把连接超时变成可控参数。BeginConnect不会阻塞调用线程,WaitOne负责等结果,timeoutMs在生产环境我一般给 3000 毫秒。EndConnect必须在连接成功后调用,否则 Socket 会报「异步操作未完成」。超时后直接Close是必须的,不然连接对象还挂在系统里,下次重连时会继续占用资源,表现为端口被耗尽。
断线重连策略不要做成「断了就立刻重连」,那会让触摸屏疲于处理连接请求。我一般用递增退避:第一次等待 1 秒,第二次 2 秒,最大 30 秒封顶,连续失败 10 次后复位到 1 秒再来一轮。
3.2 发送缓冲区与粘包处理
Modbus TCP 报文很短,一般不涉及大缓冲区设置。真正要注意的是接收端的粘包与半包问题。C# 的Receive每次返回的字节数不保证恰好是一个完整报文,可能一次收到两帧,也可能只收到半帧。范例代码里用MemoryStream做累积缓冲,每次收到数据先追加,再循环尝试解析出一个完整帧:
private byte[] _buffer = new byte[4096]; private MemoryStream _stream = new MemoryStream(); private void OnDataReceived(byte[] data) { _stream.Write(data, 0, data.Length); // 新数据先写入累积流 while (TryExtractFrame(_stream, out byte[] frame)) { ProcessFrame(frame); // 完整帧交给解析逻辑 } }粘包的处理核心是:最外层循环只关心「能不能从累积流里取出一整帧」,取不出来就等下一包,而不是每次Receive都直接当成一个响应去解析。TryExtractFrame内部先读 6 字节报文头,用报文头里的字节长度字段算出总长,再看累积流里够不够那么多字节,够就截取,不够就返回 false。
3.3 报文构造与 CRC 的误区
Modbus TCP 和 Modbus RTU 最大的区别是:TCP 帧没有 CRC 校验,校验交给 TCP 协议栈,报文格式也更紧凑。所以我见过有人把 RTU 的 CRC 计算代码直接搬过来,硬塞进 TCP 报文尾部,结果触摸屏返回非法报文错误。范例代码里不需要任何 CRC 函数,报文结构是固定的 7 字节 MBAP 头加 PDU。
构造请求时只需关注三个字段:事务标识符(可用自增计数器)、协议标识符(固定 0x0000)、长度字段(高字节 0、低字节为后续字节数)。很多新手在长度字段上翻车,把整个报文长度写进去了,实际应该写「从单元标识符开始算起的字节数」,也就是协议数据单元长度加 1。这个细节后续写报文时会再具体展示。
4. Modbus TCP 报文拆解与 C# 实现:功能码 03、06、16 的读写组合
4.1 读多个保持寄存器(0x03)的请求响应拆包
读数据是上位机最频繁的操作。功能码 03 用于读取连续多个保持寄存器,在 MCGS 里也就是批量读取 4x 区变量。请求报文只有 12 字节:事务标识符占 2 字节、协议标识符占 2 字节、长度占 2 字节、单元标识符占 1 字节、功能码占 1 字节、起始地址占 2 字节、寄存器数量占 2 字节。C# 构造函数可以这样写:
private static byte[] BuildReadRequest(ushort transactionId, byte unitId, ushort startAddr, ushort count) { int length = 6; // 单元标识符 + 功能码 + 起始地址 + 数量 byte[] frame = new byte[12]; frame[0] = (byte)(transactionId >> 8); // 事务标识符高字节 frame[1] = (byte)(transactionId & 0xFF); // 事务标识符低字节 frame[2] = 0x00; // 协议标识符高字节,固定 0 frame[3] = 0x00; // 协议标识符低字节,固定 0 frame[4] = (byte)(length >> 8); // 长度高字节,此处恒为 0 frame[5] = (byte)(length & 0xFF); // 长度低字节,6 frame[6] = unitId; // 单元标识符,通常为 1 frame[7] = 0x03; // 功能码:读保持寄存器 frame[8] = (byte)(startAddr >> 8); // 起始地址高字节 frame[9] = (byte)(startAddr & 0xFF); // 起始地址低字节 frame[10] = (byte)(count >> 8); // 寄存器数量高字节 frame[11] = (byte)(count & 0xFF); // 寄存器数量低字节 return frame; }transactionId每次都加一,目的是把同一时刻发出的多个请求和对应响应配对。unitId在触摸屏默认配置里通常是 1,但如果组态里改了站号,这里必须跟着改。startAddr用第 2 章说的 0 基地址,比如要读组态里的4x0001,这里传 0 而不是 1。响应报文首字节是单元标识符,次字节是功能码 0x03,第三字节是字节计数,数值等于寄存器数量乘以 2,后面才是寄存器数据,每个寄存器占 2 字节,高字节在前。
4.2 写单个寄存器(0x06)与写多个寄存器(0x10)
下发的场景通常有两种:改一个参数、切换一个模式,用功能码 06 写单个寄存器;批量下发配方或设定值,用功能码 16(0x10)写多个连续寄存器。功能码 06 的请求是 12 字节:MBAP 头加单元标识符加 0x06 加寄存器地址加寄存器值。功能码 16 的请求会更长一些,在地址和数量之后还要加一个字节计数字段,然后才是数据区:
private static byte[] BuildWriteMultipleRequest(ushort transId, byte unitId, ushort startAddr, ushort[] values) { int byteCount = values.Length * 2; byte[] frame = new byte[13 + byteCount]; // 13 字节头 + 数据区 frame[0] = (byte)(transId >> 8); frame[1] = (byte)(transId & 0xFF); frame[2] = 0x00; frame[3] = 0x00; frame[4] = (byte)((7 + byteCount) >> 8); // 长度 = 单元标识符 + 功能码 + 地址 + 数量 + 字节计数 + 数据区 frame[5] = (byte)((7 + byteCount) & 0xFF); frame[6] = unitId; frame[7] = 0x10; // 功能码 16:写多个寄存器 frame[8] = (byte)(startAddr >> 8); frame[9] = (byte)(startAddr & 0xFF); frame[10] = (byte)(values.Length >> 8); frame[11] = (byte)(values.Length & 0xFF); frame[12] = (byte)byteCount; for (int i = 0; i < values.Length; i++) { frame[13 + i * 2] = (byte)(values[i] >> 8); // 数据高字节 frame[14 + i * 2] = (byte)(values[i] & 0xFF); // 数据低字节 } return frame; }注意长度字段的计算:这里7 + byteCount包含了单元标识符、功能码、起始地址、数量、字节计数和数据区。写操作最大的坑是「写成功了但值不对」,多半是寄存器地址传错,或者数据字节序和组态端不一致。MCGS 默认大端模式,即高字节在前,上面的代码已经按这个顺序排列。如果组态里改成小端,则需要把source & 0xFF放到低位前面。
4.3 数据格式转换:int、float、bool 与大小端陷阱
MCGS 变量类型比寄存器数据类型多了一层映射。一个 32 位浮点数在 Modbus 里占两个寄存器,也就是 4 字节。问题是这两个寄存器谁在前谁在后,以及每个寄存器内部的高低字节顺序,组态软件里通常有「字节顺序」和「字顺序」两个选项。范例代码里最常见的组合是「单字小端 + 字序大端」,即每个寄存器的低字节在前,但两个寄存器中高地址字在前。如果不确定,先从触摸屏组态里找出设备驱动的默认设置,再对齐 C# 的解析代码。
private static float ReadFloat(byte[] response, int offset) { // 大端模式:高字节在前,高字在前 byte highWordHigh = response[offset]; // 第一个寄存器高字节 byte highWordLow = response[offset + 1]; // 第一个寄存器低字节 byte lowWordHigh = response[offset + 2]; // 第二个寄存器高字节 byte lowWordLow = response[offset + 3]; // 第二个寄存器低字节 if (BitConverter.IsLittleEndian) { byte[] bytes = { lowWordLow, lowWordHigh, highWordLow, highWordHigh }; return BitConverter.ToSingle(bytes, 0); } byte[] bigEndian = { highWordHigh, highWordLow, lowWordHigh, lowWordLow }; return BitConverter.ToSingle(bigEndian, 0); }这段代码的意图是把收到的 4 字节组装回 C# 环境下的 float。因为 C# 在本机通常是小端序,直接BitConverter.ToSingle会把字节序弄反,所以需要手动重新排列。offset指向响应报文里第一个数据字节的位置,即「单元标识符 + 功能码 + 字节计数」之后。bool 类型通常占一个寄存器,值非 0 即 true,但要注意 MCGS 里可能用 0 和 1 之外的值表示状态,建议在 C# 侧做归一化判断,而不是直接比较等于 1。
5. 避坑:与 MCGS 联调的七个血泪踩坑记录
5.1 现象:上位机连接失败,触摸屏没反应
原因:MCGS 设备窗口里没启用 TCP/IP 驱动,或者驱动配好了但设备没有处于「运行」状态。触摸屏只有进入运行环境后才开始监听端口,在组态环境或停止运行时端口根本不打开。
解决:在触摸屏上启动运行环境,用电脑命令行执行telnet 屏IP 502,能看到连接提示说明端口已开。如果 telnet 都连不上,回到组态确认设备驱动状态,并在触摸屏上查看系统日志里的通信记录。
5.2 现象:连接成功但读回来的数值全是 0,且不变化
原因:寄存器地址偏移错误或者变量没有绑定通道。MCGS 显示地址和 Modbus 协议地址相差 1,4x0001对应协议地址 0。如果 C# 里直接用 1 发起请求,实际读到的是 4x0002,那个通道没绑定变量,自然返回 0。
解决:把 C# 的请求地址改回通道地址 - 40001,并和组态里的通道表逐条核对。可以在触摸屏上临时给某个变量赋一个固定非零值,上位机如果能读到这个值,就证明地址映射正确。
5.3 现象:float 读出来是天文数字,int 读出来有规律但不对
原因:字节序或者字序不匹配。组态端和 C# 端有两层排列需要对齐:寄存器内部的字节高低顺序、多个寄存器之间的字顺序。任何一层不一致,读出来的二进制重组结果都是错的。
解决:先用一个已知的简单整数变量调通,比如给变量赋 0x1234,上位机读回后打印原始字节,看是12 34还是34 12。确定字节序后,再处理字序;int 没问题但 float 异常时,基本就是字序反了。范例代码里已经给出大端写法,按实际组态切换。
5.4 现象:写入返回成功,但触摸屏画面上的值没变
原因:通道权限是只读,或者变量被组态画面里的脚本持续改写。MCGS 中如果同一变量在多个脚本里被赋值,上位机写入后很快又被脚本覆盖。
解决:在组态里把目标通道的读写权限改为「读写」,并检查画面脚本里是否有周期赋值逻辑。生产环境里这种「隐性写入」最难发现,排查时把触摸屏画面停掉,单独验证通信是否正常。
5.5 现象:运行一段时间后通信中断,程序没有报错
原因:半开连接。触摸屏重启、网线瞬断、交换机重启都会让 TCP 连接进入半开状态,C# 侧不知道对端已死,后续写入失败后才触发异常,但此时还想复用旧连接。
解决:为 Socket 设置读写超时,比如ReceiveTimeout设为 2000 毫秒,同时把断线检测放到独立的看门狗线程里,定时用功能码 03 读一个固定寄存器,连续失败 3 次就关闭连接并走重连流程。范例代码里重连的退避策略也是为这个场景准备的。
5.6 现象:轮询频率很高时,触摸屏出现通信报警
原因:MCGS TCP 驱动对请求频率有限制,上位机以几十毫秒间隔连续请求时,触摸屏来不及处理,通信错误计数飙升。
解决:把轮询周期从 50 毫秒改为 200 毫秒以上,必要的话把多个地址合并成一次功能码 03 批量读取,减少请求次数。触摸屏不是 PLC,它更擅长把数据展示给人看,不适合当高速采集前端。
5.7 现象:返回异常帧,功能码是 0x83 或 0x90
原因:Modbus 异常响应的功能码是请求功能码 + 0x80,后面的异常码能直接指出问题。0x83 表示功能码 03 的异常响应,常见异常码 0x02(非法数据地址)、0x03(非法数据值)、0x01(非法功能码)。
解决:收到异常帧时打印异常码再排查。0x02 多为寄存器地址越界,0x03 多为写入值超范围。不要忽略异常帧直接丢弃,它比超时有价值得多。
6. 验证与进阶:用自写小工具回环测试,三分钟确认链路通不通
范例代码跑起来之前,我习惯先做一个回环测试:不接真实触摸屏,在电脑上自己起一个最简单的 Modbus TCP 服务端,模拟 MCGS 的响应行为。这样做的好处是把问题切成两半——先证明 C# 客户端逻辑正确,再对接触摸屏,出问题时不会两头猜。
测试工具不用太复杂,核心就三件事:监听 502 端口、接收请求、按功能码回包。功能码 03 返回若干个寄存器的固定值,功能码 06 和 16 原样返回请求帧。用 C# 写一个TcpListener在一分钟内就能搭好,配合一个调试工具抓包,看 C# 发出的报文和模拟端返回的报文是否对称。这个习惯帮我挡掉过大量问题:有一次对接时触摸屏怎么都连不上,回环测试证明客户端代码没问题,最后发现是组态里设备没启动。从那以后,我每次联调都强制走一遍回环测试,先在本地把报文逻辑验证完,再拉到现场对接,节省的时间远比写测试工具多。
进阶层面还有两个习惯值得养成。第一个是给每条请求打日志,记录事务标识符、功能码、起始地址、字节内容、耗时和响应状态,日志输出成 CSV,出问题时直接看最后一段。第二个是轮询节奏分优先级:急停、状态类变量用 100 毫秒周期,温度、压力等慢变量用 500 毫秒到 1 秒周期,配方下发只在用户触发时执行。把读写分离到两个独立线程,写操作做超时控制,读操作失败时只告警不阻塞下发的界面操作。这样产线运行几个月后依然不会因为通信问题拖垮上位机。希望帮到你。
本文还有配套的精品资源,点击获取