ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#与汇川PLC通信:ModbusTCP从原理到实战完整指南

C#与汇川PLC通信:ModbusTCP从原理到实战完整指南 简介面向工业自动化上位机开发者提供C#通过Modbus TCP协议与汇川PLC通信的完整工程示例。资源代码基于VS2013与.NET Framework 4.5编写兼容汇川AutoShop环境可直接用于各类ModbusTCP通信的调试与应用开发适合需要快速搭建上位机与PLC联调方案的工程师学习。资源包共148个文件压缩包仅275KB以cs源码、csproj工程文件、cfg配置文件及dll/exe为主附带PLC程序相关ld、st、dat等类型文件便于对照查看通信配置与程序逻辑。目前已有2027人学习下载可作为ModbusTCP通信入门的参考案例。通过该工程可了解TcpClient连接、Modbus报文构造与解析、寄存器读写等关键实现同时借助AutoShop工程文件理解汇川PLC侧参数配置是一份轻量实用的联调参考资料。 做上位机这些年最常被问的问题之一就是“C#怎么和汇川PLC通信”。说实话这个需求一旦拆开看核心就三件事搞清楚PLC那边开的是什么服务、摸透ModbusTCP的报文结构、最后才是把C#代码写好跑通。很多教程要么只讲协议不谈设备要么只贴代码不讲原理结果就是你在电脑上把Demo跑通了一接到现场的汇川PLC就各种超时、数据错位、连接卡死。这篇文章我打算从实际项目里的经验出发把C#通过ModbusTCP与汇川PLC通信这件事从头到尾捋一遍。不管你是刚入门的上位机开发者还是已经在现场调试过几台设备的工程师只要需要和汇川的H3U、H5U、AM系列或者IT7000触摸屏通信这篇文章应该都能给你一些可直接落地的参考。1. 先别急着写代码确认你的PLC和通信方案1.1 汇川PLC的以太网通信能力分布汇川的PLC产品线比较杂不同系列的以太网支持情况差别很大。我接触过的几个主流系列可以简单梳理一下H1U/H2U系列这类小型PLC自带网口不多部分型号需要通过扩展模块才能走ModbusTCP实际项目里用得少。H3U系列这个系列自带以太网口支持ModbusTCP从站也支持EtherCAT主站看具体型号是目前中小型项目里很常见的型号。H5U系列汇川主推的中型PLC原生支持ModbusTCP、EtherCAT、EtherNet/IP等多种协议性能强不少。AM系列AM400/AM600面向中大型项目的运动控制器ModbusTCP是标配。IT7000触摸屏这个经常被忽略但它内置了PLC功能也可以作为ModbusTCP从站和上位机通信。我早期踩过一个坑客户现场用的是H1U系列说是“支持网口”结果那个网口只能用来下载程序根本跑不了ModbusTCP最后只能加一个串口转以太网的网关模块。所以选型第一步先确认你手里具体是哪个系列哪个型号再看它的用户手册里有没有明确的“ModbusTCP从站”功能说明。1.2 为什么选ModbusTCP而不是RTU或其他方式现场通信方案的选择本质上是在“实时性”“开发成本”“通用性”三者之间找平衡点。ModbusRTU走串口接线简单两线或四线但波特率一般也就9600、19200、38400一帧报文传完差不多要几毫秒到十几毫秒。而且RS485是半双工的同一时刻只能一方发送轮询一堆从站时周期会明显拉长。如果你要采集的数据点很多RTU的轮询时间会让你很难受。ModbusTCP走以太网100M甚至千兆口报文往返延迟基本上是亚毫秒级。多台设备可以通过交换机组网上位机同时采集多个PLC也没压力。而且和EtherCAT这种实时总线相比ModbusTCP的协议栈极其简单——你不需要专门的从站芯片不需要复杂的配置工具一个原生Socket就能搞定。EtherCAT确实是汇川中大型PLC的强项同步精度能达到微秒级但那是给伺服运动控制用的。如果你只是做数据采集、参数下发、状态监控用EtherCAT属于杀鸡用牛刀而且上位机要支持EtherCAT主站光是买主站授权就够喝一壶的。ModbusTCP在这个场景里就是“最实用的那个”不是性能最强但绝对不是短板。2. ModbusTCP和RTU最大的区别MBAP报文头2.1 为什么TCP模式下没有CRC校验第一次从RTU转过来的朋友最容易问ModbusTCP的报文怎么没有CRC校验这其实是TCP/IP协议栈帮你把活干了。RTU是串口链路数据在物理线路上裸奔电磁干扰、线缆质量问题都可能导致数据翻转所以Modbus协议自己在报文尾部加了CRC16校验。但TCP协议本身就有校验和机制校验整个TCP报文段链路层还有以太网FCS校验到了TCP这一层数据完整性已经有了保障。如果再叠一层CRC纯属重复劳动。所以ModbusTCP报文结构就变成了MBAP报文头 功能码 数据段。MBAP全称是Modbus Application Protocol它取代了RTU从站地址和CRC的位置专门解决TCP/IP环境下“怎么从字节流里切出一帧完整的报文”这个问题。2.2 MBAP四个字段逐个拆解MBAP报文头固定7个字节结构如下字段长度字节说明示例值Transaction Identifier事务标识符2用于匹配请求和响应每次请求递增0x0001Protocol Identifier协议标识符2Modbus协议固定为0x00000x0000Length长度2后续字节数Unit ID 功能码 数据段0x0006Unit Identifier单元标识符1从站地址一般填PLC的站号或0xFF0x01Transaction Identifier是调试时非常关键的字段。TCP是全双工、可以并发收发数据的如果没有事务标识符你发出去两个请求回来两个响应压根分不清哪个响应对应哪个请求。所以正常情况下每发一帧请求Transaction ID就加1收到响应后要和请求时的ID对上才算有效。Length字段也容易搞错。它统计的是Unit Identifier 功能码 数据区这三段的总字节数不包含MBAP头自身的7个字节。比如一个典型的读保持寄存器请求功能码0x03起始地址0x0000数量0x000A数据区是5个字节加上功能码和Unit IDLength就是1 1 5 7十六进制就是0x0007。Unit Identifier单元标识符在直连PLC时通常填PLC的从站地址如果不确定就填0xFF很多PLC支持用0xFF作为广播/默认地址。需要注意如果通过网关把ModbusTCP转成ModbusRTU这个字段就必须填真实的从站地址否则网关不知道怎么转发。2.3 一个完整的请求报文长什么样假设我们要读汇川PLC保持寄存器从地址100开始的10个寄存器对应PLC侧地址通常标成40101请求报文就是事务标识符高字节 0x00 事务标识符低字节 0x01 协议标识符高字节 0x00 协议标识符低字节 0x00 长度高字节 0x00 长度低字节 0x07 单元标识符 0x01 功能码 0x03 起始地址高字节 0x00 起始地址低字节 0x64 // 100的十六进制 寄存器数量高字节 0x00 寄存器数量低字节 0x0A // 10个一共12个字节。抓包的时候看到报文头前7个字节这个规律基本就能确认是ModbusTCP了。这里还有一个所有新手都会踩的坑PLC手册里标的地址和报文里的协议地址经常差1。汇川手册里说“保持寄存器%MW100对应的Modbus地址是40101”但在报文里填的却是1000x0064不是101。因为Modbus协议规定数据地址从0开始编号而PLC厂家为了兼容传统的“4XXXX”编址方式对外宣称的是1基地址内部换算时又悄悄减了1。如果直接填101你会发现读回来的是MW101的数据整体错位一个字。3. C#核心实现从连接管理到报文收发3.1 网络连接初始化C#下实现ModbusTCP主站最底层依托的就是System.Net.Sockets.TcpClient。很多教程直接让你用TcpClient.ConnectAsync说实话这在Demo里没问题但现场环境复杂我习惯加一些控制参数using System.Net.Sockets; public class ModbusTcpClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private readonly object _lockObj new object(); private ushort _transactionId 0; private string _ip; private int _port; public ModbusTcpClient(string ip, int port 502) { _ip ip; _port port; } public bool Connect(int timeoutMs 3000) { try { _tcpClient new TcpClient(); // 设置TCP_NODELAY禁用Nagle算法降低小报文的延迟 _tcpClient.NoDelay true; var task _tcpClient.ConnectAsync(_ip, _port); if (!task.Wait(timeoutMs)) { _tcpClient.Close(); return false; } _stream _tcpClient.GetStream(); _stream.ReadTimeout 2000; _stream.WriteTimeout 2000; return true; } catch { return false; } } public void Disconnect() { _stream?.Close(); _tcpClient?.Close(); _stream null; _tcpClient null; } public bool IsConnected _tcpClient?.Connected ?? false; }这里重点说下NoDelay true。Nagle算法会把多个小数据包合并成一个发送对文件传输是优化但对工业通信是灾难——它会拖慢响应速度甚至在某些PLC上导致请求积压。ModbusTCP每帧才十几字节禁用Nagle是必须的操作。3.2 构建和解析报文的核心函数我习惯把报文构建和解析封装成独立的静态方法这样测试和复用都方便。来看最常用的读保持寄存器功能码0x03public static byte[] BuildReadHoldingRegisters(byte unitId, ushort startAddress, ushort quantity) { if (quantity 1 || quantity 125) throw new ArgumentOutOfRangeException(nameof(quantity), 读保持寄存器一次最多125个); byte[] frame new byte[12]; // Transaction ID此处1 frame[0] 0x00; frame[1] 0x01; // Protocol ID 0 frame[2] 0x00; frame[3] 0x00; // Length UnitID(1) FunctionCode(1) 起始地址(2) 数量(2) 6 frame[4] 0x00; frame[5] 0x06; frame[6] unitId; frame[7] 0x03; frame[8] (byte)(startAddress 8); frame[9] (byte)(startAddress 0xFF); frame[10] (byte)(quantity 8); frame[11] (byte)(quantity 0xFF); return frame; }解析响应也封装好public static ushort[] ParseReadHoldingRegisters(byte[] response) { // 最小长度 MBAP头7 功能码1 字节数1 9 if (response.Length 9) throw new ModbusException(响应帧长度不足); // 异常响应功能码最高位置1后面跟1个异常码 if ((response[7] 0x80) ! 0) throw new ModbusException($PLC返回异常码: 0x{response[8]:X2}); if (response[7] ! 0x03) throw new ModbusException($功能码不匹配期望0x03实际0x{response[7]:X2}); int byteCount response[8]; if (response.Length 9 byteCount) throw new ModbusException(响应帧长度不完整); int regCount byteCount / 2; ushort[] values new ushort[regCount]; for (int i 0; i regCount; i) { values[i] (ushort)((response[9 i * 2] 8) | response[10 i * 2]); } return values; }为什么要限制一次最多读125个寄存器这是Modbus协议的规定——功能码0x03的字节数最大255去掉MBAP头和功能码数据区最多250字节也就是125个寄存器。有些PLC支持连续读更多但按标准来肯定没错。3.3 发送请求循环直到收到对应事务ID的响应ModbusTCP是面向连接的TCP字节流你没办法保证一次Read就能收到完整的一帧所以你需要在读取时处理粘包和半包问题。我写的这个函数专门做这件事public byte[] SendReceive(byte[] request) { lock (_lockObj) { if (!IsConnected) throw new InvalidOperationException(连接未建立); // 递增事务ID并更新到报文前两个字节 _transactionId; request[0] (byte)(_transactionId 8); request[1] (byte)(_transactionId 0xFF); // 清理读缓冲区残留数据避免读到上一次的脏数据 if (_stream.DataAvailable) { byte[] clearBuf new byte[_stream.DataAvailable]; _stream.Read(clearBuf, 0, clearBuf.Length); } _stream.Write(request, 0, request.Length); _stream.Flush(); // 解析MBAP头得到Length字段 byte[] header ReadExactly(7); int length (header[4] 8) | header[5]; // 剩余报文 Length - UnitID(1) - 功能码(1) 不对Length已经包含了UnitID和功能码。 // 实际上Length已经包含了UnitID和功能码长度所以剩余需要读 length - 1 - 1 length - 2 int remaining length - 2; byte[] body ReadExactly(remaining); byte[] response new byte[7 body.Length]; Array.Copy(header, 0, response, 0, 7); Array.Copy(body, 0, response, 7, body.Length); // 校验事务ID是否匹配 ushort respTid (ushort)((header[0] 8) | header[1]); if (respTid ! _transactionId) throw new ModbusException(事务ID不匹配); return response; } } private byte[] ReadExactly(int count) { byte[] buffer new byte[count]; int offset 0; while (offset count) { int n _stream.Read(buffer, offset, count - offset); if (n 0) throw new ModbusException(连接已断开); offset n; } return buffer; }这里有几处值得说一下。第一lock锁保证了同一时刻只有一个请求在途避免多线程并发导致Transaction ID错乱和响应错配。第二读数据前清空缓冲区的残留是因为上一次读取可能把下一次请求的响应也一并读进来了粘包不清空的话就会导致帧错位。第三ReadExactly这个循环读取的思路特别重要——TCP是流式传输一次Read可能只返回半个报文必须循环等到够数了才算完整一帧。4. 功能码怎么选从寄存器地址映射说起4.1 汇川PLC常用寄存器区与Modbus功能码对应Modbus协议定义了四个数据区但实际用PLC时最常碰到的就是保持寄存器和线圈。以汇川H5U为例其他系列类似PLC数据区数据类型对应Modbus区读功能码写功能码%MW保持寄存器字16位保持寄存器4XXXX0x030x06/0x10%MX内部线圈位1位线圈0XXXX0x010x05/0x0F%AIW模拟量输入字16位输入寄存器3XXXX0x04不支持%MD双字寄存器双字32位保持寄存器相邻两个4XXXX0x030x10%MW是字单位一个%MW占16位。如果是%MD100这种32位数据它在Modbus侧就对应%MW100和%MW101两个连续寄存器分别存放高16位和低16位。读取的时候需要一次读两个寄存器再用位移和或运算拼成32位整数。这里有一个汇川特有的细节%MW100和%MW101的字节顺序是由PLC侧的数据格式决定的跟C#端无关。汇川默认是高字节在前大端但如果你在PLC程序里用了字节交换指令C#这边也得跟着调整否则数据就会错乱。4.2 通信报文实操读一个32位整数假设PLC侧有%MD20032位里面存的是转速你想读到C#端。实际操作步骤是请求读保持寄存器起始地址200数量2。响应数据区会有4个字节B0 B1 B2 B3。其中B0B1是%MW200B2B3是%MW201。拼成32位整数uint high (uint)((response[9] 8) | response[10]); // %MW200 uint low (uint)((response[11] 8) | response[12]); // %MW201 uint combined (high 16) | low; int value (int)combined;如果这个32位数是浮点数比如温度显示就需要BitConverter.ToSingle(BitConverter.GetBytes(combined), 0)。这里要注意C#的BitConverter在本机是小端模式而PLC大部分是大端模式虽然你已经手动拼好高低位了但传给BitConverter.ToSingle之前一定要确认字节序。4.3 写寄存器单写和多写写单个保持寄存器用功能码0x06报文结构相对简单public static byte[] BuildWriteSingleRegister(byte unitId, ushort address, ushort value) { byte[] frame new byte[12]; frame[0] 0x00; frame[1] 0x02; frame[2] 0x00; frame[3] 0x00; frame[4] 0x00; frame[5] 0x06; // Length 1 1 2 2 6 frame[6] unitId; frame[7] 0x06; frame[8] (byte)(address 8); frame[9] (byte)(address 0xFF); frame[10] (byte)(value 8); frame[11] (byte)(value 0xFF); return frame; }写多个连续寄存器用功能码0x10帧结构会复杂一点因为多了“字节数”和“数据区”public static byte[] BuildWriteMultipleRegisters(byte unitId, ushort startAddress, ushort[] values) { if (values null || values.Length 0 || values.Length 123) throw new ArgumentOutOfRangeException(nameof(values), 写寄存器一次最多123个); int length 6 1 values.Length * 2; // UnitID(1)FC(1)起始地址(2)数量(2)字节数(1)数据 byte[] frame new byte[13 values.Length * 2]; frame[0] 0x00; frame[1] 0x03; frame[2] 0x00; frame[3] 0x00; frame[4] (byte)(length 8); frame[5] (byte)(length 0xFF); frame[6] unitId; frame[7] 0x10; frame[8] (byte)(startAddress 8); frame[9] (byte)(startAddress 0xFF); frame[10] (byte)(values.Length 8); frame[11] (byte)(values.Length 0xFF); frame[12] (byte)(values.Length * 2); 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; }写多个的Length字段需要仔细算Unit ID 1字节、功能码1字节、起始地址2字节、数量2字节、字节数1字节、数据N字节加起来就是61N。这个计算如果不细心PLC会一直返回非法数据值的异常码。5. 现场调试的正确姿势和常见坑5.1 先用调试工具验证PLC配置别一上来就跑代码我的经验是写代码之前先用ModbusPoll或者汇川官方调试软件把PLC的通信参数确认一遍。这样可以快速定位问题是在PLC侧还是在C#侧。常见的调试步骤确认PLC IP可以Ping通。用ModbusPoll连接PLC设置从站地址、功能码、起始地址看能不能读到正确的数据。确认PLC程序里对应的%MW区确实有数据在变化。如果ModbusPoll能读到C#连不上那就是代码的问题如果ModbusPoll也读不到那就是PLC配置或者地址映射的问题。这个顺序可以帮助你省下至少半天时间。我之前遇到过客户说“C#代码连不上PLC”结果用ModbusPoll一测就发现PLC的ModbusTCP从站功能根本没使能属于配置问题。另外推荐用Wireshark抓包辅助排查。ModbusTCP的过滤器是modbus.tcp抓包能直接看到请求帧和响应帧报文头字段是不是对的、Length算错没有、响应是不是异常的一目了然。5.2 PLC侧必须设置的几个参数在汇川AutoShop或者编程软件里PLC程序里其实要设置的不多但有几个关键点ModbusTCP从站功能要使能有些PLC型号默认关闭需要在系统参数里打开。端口号默认是502如果改过上位机那边要对应。单元标识符Unit ID直连PLC时一般填1或者0xFF如果PLC侧做了多从站网关映射要按网关配置的地址来。防火墙Windows防火墙会拦截来自网络的外部连接。如果你在自己的开发电脑上测试第一次运行C#程序时系统会弹窗询问是否允许访问一定要点允许如果是在现场工控机上部署建议把防火墙规则提前配好。5.3 高频坑清单与解决办法我把自己实际踩过和帮别人排查过的问题整理成一张表每个都很典型异常现象可能原因解决办法Connect超时IP不对、PLC ModbusTCP从站未使能、网线物理断开先Ping再用ModbusPoll验证连接正常但请求无响应PLC程序没跑、从站被其他主站占用、Unit ID不对检查PLC运行状态确认唯一主站返回异常码02起始地址越界地址可能超了PLC实际范围减小起始地址检查寄存器区映射返回异常码03寄存器数量超出单帧限制或超出PLC数据区范围单次读不超过125写不超过123返回异常码01功能码不受支持确认该数据区是否支持对应功能码读到的数据全是0或整体错位寄存器地址差1一基地址和零基地址混淆报文地址减1后重新测试数据字节顺序反了32位数据高低字序不匹配检查PLC数据格式设置C#交换高低16位偶尔超时、丢包网络冲突、交换机端口协商问题、防火墙干扰检查交换机状态优先走工业级交换机5.4 关于C# TCP连接数量的疑问热搜词里有“c# tcp连接数量多少”这个对ModbusTCP通信方案影响挺大。TCP本身没有严格的连接数量限制理论上一个TcpListener可以同时接受上万个连接。但在工业上位机场景ModbusTCP主站通常只需要每个PLC维持一条TCP连接就够了没必要一个请求开一个连接。频繁建立/断开TCP连接会占用大量资源而且在PLC侧也会产生大量TIME_WAIT状态的连接记录拖慢PLC的通信任务。我一般是一个PLC对应一个ModbusTcpClient实例长期保持连接靠心跳检测断开再重连。如果你有多台PLC就建一个连接管理器用字典按设备名存起来避免重复Connect。6. 让通信更稳的进阶做法6.1 批量读写和轮询机制设计现场设备点数多了轮询效率就差很多。比如一套设备有50个温度点、20个压力点如果每个点单独发一帧读请求一轮就是70帧算上每帧2ms的往返时间一轮就是140ms现场可能还觉得勉强能接受。但如果你把连续的地址合并读比如温度点恰好分布在%MW100-%MW149一次功能码0x03的请求就能读完50个寄存器53个请求变成3个请求效率提升是数量级的。Modbus协议不支持跨区的批量读比如不能一帧同时读保持寄存器和输入寄存器所以设计数据区时尽量把同类型数据排布在连续地址段里这在PLC程序设计阶段就要规划好。另外轮询周期要考虑PLC的扫描周期。汇川PLC的扫描周期一般是1ms到10ms不等取决于程序大小。如果上位机每10ms就狂刷一轮请求PLC的通信任务会被占满影响控制逻辑执行。我通常会把轮询周期放宽到100ms~500ms这取决于工艺要求数据不敏感的可以放到1s。6.2 断线重连和状态监视工业现场总有网络抖动连接断开是常态而不是异常。所以我的连接管理器里都会加一个后台心跳线程public async Task KeepAliveLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { if (!_client.IsConnected) { _client.Connect(3000); Log.Info(ModbusTCP重新连接成功); } else { // 发送一个读命令检查通信是否真的通畅 var resp _client.ReadHoldingRegisters(1, 0, 1); } } catch (Exception ex) { Log.Warn($心跳异常: {ex.Message}); _client.Disconnect(); } await Task.Delay(3000, ct); } }这里的心跳不仅仅是检查TcpClient.Connected属性——这个属性只代表TCP层连接没断不代表PLC还活着。你需要在应用层发一个报文试探比如读一个已知值的寄存器如果成功说明PLC响应正常如果异常再进入重连流程。重连的时候建议加个退避策略连续失败后间隔逐步拉长比如3s、5s、10s、30s避免PLC恢复期间上位机疯狂重连打爆PLC连接队列。6.3 日志模块的规划现场排障最怕什么最怕报错信息只有“通信失败”四个字。所以我做上位机时会专门设计通信日志模块每帧请求和响应都记录原始报文十六进制同时记录收发时间戳、事务ID、功能码、耗时。这样现场出了问题翻日志就能定位是PLC没响应还是响应异常码、是网络延迟还是数据解析错误。ModbusTCP的报文本身就是可读的十六进制串日志里别只存解析后的结果原始报文一定要留。我就有过一次经历客户说数据偶尔不对排查了PL程序、排查了C#代码都没找到问题最后翻日志发现是交换机端口协商异常导致偶发帧被丢弃重传后事务ID错位只要看报文就一目了然。7. 总结一下我的经验C#通过ModbusTCP与汇川PLC通信本身不复杂但真的要做到现场稳定运行还是有一些需要叮嘱的点第一协议理解要到位。MBAP头和RTU的从站地址/CRC完全是两回事事务ID、长度字段、地址差1这些问题搞不清楚就一定会踩坑。第二编码时要把TCP的流式特性刻在脑子里。粘包、半包、超时、重传这些处理不好Demo跑得再好到现场都会翻车。第三调试时遵循“从PLC到代码”的排查顺序先用现成的调试工具验证设备再上手写代码能节省大量时间。第四现场部署要把网络环境当回事防火墙规则、交换机质量、线缆连接这些看起来跟代码无关的因素往往才是通信不稳定的真正原因。最后再分享一个小技巧如果在现场没有ModbusPoll这类工具你也可以用C#写一个简易的调试终端把上面的BuildReadHoldingRegisters和SendReceive方法做成一个控制台输入地址和数量就能直接读数据。这个工具我一直在用很多同事看到后都让我拷了一份排查问题是真的好用。本文还有配套的精品资源点击获取
返回列表