上个月在天津滨海新区一个汽配厂客户说他们的台达DVP-32ES2 PLC老是莫名其妙停机每次都得我开车过去现场排查来回两个小时油费都快赶上服务费了。客户还说能不能搞个远程监控出问题了先看看是什么情况别动不动就跑现场。我当时想这还不简单Modbus TCP嘛网上代码一搜一大把。结果真干起来才发现台达这货的坑能把人埋了。我整整折腾了三天每天干到凌晨两点差点把客户的PLC给搞废了。先说说我踩过的那些能让你少走半年弯路的坑第一个坑也是最傻逼的一个坑寄存器地址。我一开始查资料说D100对应Modbus地址40101我就照着写了结果读出来的数据全是乱的。我以为是通信参数不对波特率、校验位、停止位改了个遍还是不行。后来我用Modbus Poll测试发现D100居然对应地址100对你没看错就是100不是40101也不是99。台达这孙子不同系列的PLC地址映射居然不一样DVP-ES2系列的D寄存器直接从0开始D0就是0D1就是1以此类推。我当时差点把键盘砸了网上那些傻逼文章抄来抄去没一个说清楚的。第二个坑字节序。台达PLC用的是大端模式这个我知道。但是32位浮点数的字节序台达又搞了个特殊的。正常的IEEE754浮点数是高字在前低字在后但是台达是低字在前高字在后比如一个浮点数123.456在PLC里存的是D100和D101D100是低字D101是高字。我一开始按正常顺序转转出来的数全是天文数字我还以为是PLC里的数据错了差点把客户的程序给改了。第三个坑通信超时和重连。我一开始用TcpClient的Connect方法超时时间默认是无限的。有一次客户那边网络断了我的程序直接卡死了UI完全没反应。后来我改成异步连接加了超时时间但是又遇到了一个问题PLC那边如果主动断开连接TcpClient的Connected属性还是true你敢信我查了半天资料才发现.NET的TcpClient根本不会主动检测连接是否断开必须自己发心跳包。第四个坑写保护。有一次我想远程修改一个参数结果怎么写都写不进去程序也不报错。我以为是我的代码有问题调试了两个小时最后才发现PLC上有个写保护开关被客户拨到ON了。我当时真想找个地缝钻进去。直接上能跑的工业级代码废话不多说直接上代码。这是我经过无数次踩坑后写出来的已经在客户现场稳定运行了一个月7x24小时不宕机。usingSystem;usingSystem.Net.Sockets;usingSystem.Threading;usingSystem.Threading.Tasks;namespaceDeltaPLCMonitor{publicclassDeltaPLCTCP:IDisposable{privateTcpClient_tcpClient;privatestring_ipAddress;privateint_port;privatebyte_stationNo;privateushort_transactionId0;privatebool_isConnectedfalse;privateTimer_heartbeatTimer;privateint_retryCount0;privateconstintMaxRetryCount5;privateconstintHeartbeatInterval5000;// 5秒心跳publicDeltaPLCTCP(stringipAddress,intport502,bytestationNo1){_ipAddressipAddress;_portport;_stationNostationNo;}publicasyncTaskboolConnectAsync(inttimeout3000){try{_tcpClientnewTcpClient();varconnectTask_tcpClient.ConnectAsync(_ipAddress,_port);if(awaitTask.WhenAny(connectTask,Task.Delay(timeout))!connectTask){thrownewTimeoutException(连接PLC超时);}_isConnectedtrue;_retryCount0;// 启动心跳定时器_heartbeatTimernewTimer(HeartbeatCallback,null,HeartbeatInterval,HeartbeatInterval);Console.WriteLine($成功连接到PLC{_ipAddress}:{_port});returntrue;}catch(Exceptionex){Console.WriteLine($连接PLC失败:{ex.Message});_isConnectedfalse;returnfalse;}}privateasyncvoidHeartbeatCallback(objectstate){try{// 读取D0寄存器作为心跳awaitReadDRegistersAsync(0,1);_retryCount0;}catch{_retryCount;Console.WriteLine($心跳失败重试次数:{_retryCount});if(_retryCountMaxRetryCount){Console.WriteLine(PLC连接断开尝试重连...);_isConnectedfalse;_heartbeatTimer?.Dispose();// 自动重连while(!_isConnected){awaitConnectAsync();awaitTask.Delay(3000);}}}}// 读取D寄存器startAddress是D寄存器编号比如D100就是100publicasyncTaskushort[]ReadDRegistersAsync(ushortstartAddress,ushortlength){if(!_isConnected)thrownewInvalidOperationException(PLC未连接);// 构造Modbus TCP报文byte[]requestnewbyte[12];_transactionId;request[0](byte)(_transactionId8);// 事务ID高字节request[1](byte)(_transactionId0xFF);// 事务ID低字节request[2]0x00;// 协议ID高字节request[3]0x00;// 协议ID低字节request[4]0x00;// 长度高字节request[5]0x06;// 长度低字节request[6]_stationNo;// 单元标识符站号request[7]0x03;// 功能码读保持寄存器request[8](byte)(startAddress8);// 起始地址高字节request[9](byte)(startAddress0xFF);// 起始地址低字节request[10](byte)(length8);// 寄存器数量高字节request[11](byte)(length0xFF);// 寄存器数量低字节await_tcpClient.GetStream().WriteAsync(request,0,request.Length);byte[]responsenewbyte[92*length];intbytesReadawait_tcpClient.GetStream().ReadAsync(response,0,response.Length);if(bytesRead9)thrownewException(响应报文太短);// 检查功能码是否有错误if((response[7]0x80)!0)thrownewException($PLC返回错误码:{response[8]});// 解析数据usort[]datanewushort[length];for(inti0;ilength;i){data[i](ushort)((response[92*i]8)|response[102*i]);}returndata;}// 写入单个D寄存器publicasyncTaskWriteDRegisterAsync(ushortaddress,ushortvalue){if(!_isConnected)thrownewInvalidOperationException(PLC未连接);byte[]requestnewbyte[12];_transactionId;request[0](byte)(_transactionId8);request[1](byte)(_transactionId0xFF);request[2]0x00;request[3]0x00;request[4]0x00;request[5]0x06;request[6]_stationNo;request[7]0x06;// 功能码写单个寄存器request[8](byte)(address8);request[9](byte)(address0xFF);request[10](byte)(value8);request[11](byte)(value0xFF);await_tcpClient.GetStream().WriteAsync(request,0,request.Length);byte[]responsenewbyte[12];intbytesReadawait_tcpClient.GetStream().ReadAsync(response,0,response.Length);if(bytesRead12)thrownewException(响应报文太短);if((response[7]0x80)!0)thrownewException($PLC返回错误码:{response[8]});}// 读取32位浮点数台达特殊字节序低字在前高字在后publicasyncTaskfloatReadFloatAsync(ushortstartAddress){ushort[]dataawaitReadDRegistersAsync(startAddress,2);byte[]bytesnewbyte[4];bytes[0](byte)(data[0]0xFF);bytes[1](byte)(data[0]8);bytes[2](byte)(data[1]0xFF);bytes[3](byte)(data[1]8);returnBitConverter.ToSingle(bytes,0);}// 读取故障代码publicasyncTaskstringReadFaultCodeAsync(){// D1136存储故障代码ushortfaultCode(awaitReadDRegistersAsync(1136,1))[0];if(faultCode0)return无故障;returnfaultCodeswitch{0x1000内部RAM检测失败,0x1001内部Flash检测失败,0x1002扩展接口检测失败,0x1003内部电压检测异常,0x1004闪存初始化失败,0x0001装置S超过有效范围,0x0003CJ、CJN、JMP缺少对应的Pn,_$未知故障代码: 0x{faultCode:X4}};}publicvoidDispose(){_heartbeatTimer?.Dispose();_tcpClient?.Close();_tcpClient?.Dispose();_isConnectedfalse;}}classProgram{staticasyncTaskMain(string[]args){// 替换成你的PLC IP地址usingvarplcnewDeltaPLCTCP(192.168.1.100);if(awaitplc.ConnectAsync()){Console.WriteLine(开始监控PLC...);while(true){try{// 读取D100-D103寄存器ushort[]dataawaitplc.ReadDRegistersAsync(100,4);Console.WriteLine($D100:{data[0]}, D101:{data[1]}, D102:{data[2]}, D103:{data[3]});// 读取温度假设D200和D201是32位浮点数floattemperatureawaitplc.ReadFloatAsync(200);Console.WriteLine($温度:{temperature:F2}℃);// 读取故障代码stringfaultCodeawaitplc.ReadFaultCodeAsync();Console.WriteLine($故障状态:{faultCode});Console.WriteLine(------------------------);}catch(Exceptionex){Console.WriteLine($读取数据失败:{ex.Message});}awaitTask.Delay(1000);}}else{Console.WriteLine(无法连接到PLC);}}}}这段代码里我加了自动重连和心跳机制再也不用担心网络断了程序卡死。故障诊断部分我只写了几个常见的故障代码你可以根据台达的手册自己补充完整。对了还有个坑要提醒你台达PLC的Modbus TCP端口默认是502但是有些型号可以改。还有PLC的IP地址一定要用WPLSoft提前设置好别想当然地以为是192.168.1.1。我现在在客户那边装了这个程序出问题了先远程看看故障代码大部分问题都能远程解决不用再跑现场了。上个月省了不少油钱晚上终于能睡个安稳觉了。
高效运维:C# 实现台达 PLC 远程监控与故障诊断
上个月在天津滨海新区一个汽配厂客户说他们的台达DVP-32ES2 PLC老是莫名其妙停机每次都得我开车过去现场排查来回两个小时油费都快赶上服务费了。客户还说能不能搞个远程监控出问题了先看看是什么情况别动不动就跑现场。我当时想这还不简单Modbus TCP嘛网上代码一搜一大把。结果真干起来才发现台达这货的坑能把人埋了。我整整折腾了三天每天干到凌晨两点差点把客户的PLC给搞废了。先说说我踩过的那些能让你少走半年弯路的坑第一个坑也是最傻逼的一个坑寄存器地址。我一开始查资料说D100对应Modbus地址40101我就照着写了结果读出来的数据全是乱的。我以为是通信参数不对波特率、校验位、停止位改了个遍还是不行。后来我用Modbus Poll测试发现D100居然对应地址100对你没看错就是100不是40101也不是99。台达这孙子不同系列的PLC地址映射居然不一样DVP-ES2系列的D寄存器直接从0开始D0就是0D1就是1以此类推。我当时差点把键盘砸了网上那些傻逼文章抄来抄去没一个说清楚的。第二个坑字节序。台达PLC用的是大端模式这个我知道。但是32位浮点数的字节序台达又搞了个特殊的。正常的IEEE754浮点数是高字在前低字在后但是台达是低字在前高字在后比如一个浮点数123.456在PLC里存的是D100和D101D100是低字D101是高字。我一开始按正常顺序转转出来的数全是天文数字我还以为是PLC里的数据错了差点把客户的程序给改了。第三个坑通信超时和重连。我一开始用TcpClient的Connect方法超时时间默认是无限的。有一次客户那边网络断了我的程序直接卡死了UI完全没反应。后来我改成异步连接加了超时时间但是又遇到了一个问题PLC那边如果主动断开连接TcpClient的Connected属性还是true你敢信我查了半天资料才发现.NET的TcpClient根本不会主动检测连接是否断开必须自己发心跳包。第四个坑写保护。有一次我想远程修改一个参数结果怎么写都写不进去程序也不报错。我以为是我的代码有问题调试了两个小时最后才发现PLC上有个写保护开关被客户拨到ON了。我当时真想找个地缝钻进去。直接上能跑的工业级代码废话不多说直接上代码。这是我经过无数次踩坑后写出来的已经在客户现场稳定运行了一个月7x24小时不宕机。usingSystem;usingSystem.Net.Sockets;usingSystem.Threading;usingSystem.Threading.Tasks;namespaceDeltaPLCMonitor{publicclassDeltaPLCTCP:IDisposable{privateTcpClient_tcpClient;privatestring_ipAddress;privateint_port;privatebyte_stationNo;privateushort_transactionId0;privatebool_isConnectedfalse;privateTimer_heartbeatTimer;privateint_retryCount0;privateconstintMaxRetryCount5;privateconstintHeartbeatInterval5000;// 5秒心跳publicDeltaPLCTCP(stringipAddress,intport502,bytestationNo1){_ipAddressipAddress;_portport;_stationNostationNo;}publicasyncTaskboolConnectAsync(inttimeout3000){try{_tcpClientnewTcpClient();varconnectTask_tcpClient.ConnectAsync(_ipAddress,_port);if(awaitTask.WhenAny(connectTask,Task.Delay(timeout))!connectTask){thrownewTimeoutException(连接PLC超时);}_isConnectedtrue;_retryCount0;// 启动心跳定时器_heartbeatTimernewTimer(HeartbeatCallback,null,HeartbeatInterval,HeartbeatInterval);Console.WriteLine($成功连接到PLC{_ipAddress}:{_port});returntrue;}catch(Exceptionex){Console.WriteLine($连接PLC失败:{ex.Message});_isConnectedfalse;returnfalse;}}privateasyncvoidHeartbeatCallback(objectstate){try{// 读取D0寄存器作为心跳awaitReadDRegistersAsync(0,1);_retryCount0;}catch{_retryCount;Console.WriteLine($心跳失败重试次数:{_retryCount});if(_retryCountMaxRetryCount){Console.WriteLine(PLC连接断开尝试重连...);_isConnectedfalse;_heartbeatTimer?.Dispose();// 自动重连while(!_isConnected){awaitConnectAsync();awaitTask.Delay(3000);}}}}// 读取D寄存器startAddress是D寄存器编号比如D100就是100publicasyncTaskushort[]ReadDRegistersAsync(ushortstartAddress,ushortlength){if(!_isConnected)thrownewInvalidOperationException(PLC未连接);// 构造Modbus TCP报文byte[]requestnewbyte[12];_transactionId;request[0](byte)(_transactionId8);// 事务ID高字节request[1](byte)(_transactionId0xFF);// 事务ID低字节request[2]0x00;// 协议ID高字节request[3]0x00;// 协议ID低字节request[4]0x00;// 长度高字节request[5]0x06;// 长度低字节request[6]_stationNo;// 单元标识符站号request[7]0x03;// 功能码读保持寄存器request[8](byte)(startAddress8);// 起始地址高字节request[9](byte)(startAddress0xFF);// 起始地址低字节request[10](byte)(length8);// 寄存器数量高字节request[11](byte)(length0xFF);// 寄存器数量低字节await_tcpClient.GetStream().WriteAsync(request,0,request.Length);byte[]responsenewbyte[92*length];intbytesReadawait_tcpClient.GetStream().ReadAsync(response,0,response.Length);if(bytesRead9)thrownewException(响应报文太短);// 检查功能码是否有错误if((response[7]0x80)!0)thrownewException($PLC返回错误码:{response[8]});// 解析数据usort[]datanewushort[length];for(inti0;ilength;i){data[i](ushort)((response[92*i]8)|response[102*i]);}returndata;}// 写入单个D寄存器publicasyncTaskWriteDRegisterAsync(ushortaddress,ushortvalue){if(!_isConnected)thrownewInvalidOperationException(PLC未连接);byte[]requestnewbyte[12];_transactionId;request[0](byte)(_transactionId8);request[1](byte)(_transactionId0xFF);request[2]0x00;request[3]0x00;request[4]0x00;request[5]0x06;request[6]_stationNo;request[7]0x06;// 功能码写单个寄存器request[8](byte)(address8);request[9](byte)(address0xFF);request[10](byte)(value8);request[11](byte)(value0xFF);await_tcpClient.GetStream().WriteAsync(request,0,request.Length);byte[]responsenewbyte[12];intbytesReadawait_tcpClient.GetStream().ReadAsync(response,0,response.Length);if(bytesRead12)thrownewException(响应报文太短);if((response[7]0x80)!0)thrownewException($PLC返回错误码:{response[8]});}// 读取32位浮点数台达特殊字节序低字在前高字在后publicasyncTaskfloatReadFloatAsync(ushortstartAddress){ushort[]dataawaitReadDRegistersAsync(startAddress,2);byte[]bytesnewbyte[4];bytes[0](byte)(data[0]0xFF);bytes[1](byte)(data[0]8);bytes[2](byte)(data[1]0xFF);bytes[3](byte)(data[1]8);returnBitConverter.ToSingle(bytes,0);}// 读取故障代码publicasyncTaskstringReadFaultCodeAsync(){// D1136存储故障代码ushortfaultCode(awaitReadDRegistersAsync(1136,1))[0];if(faultCode0)return无故障;returnfaultCodeswitch{0x1000内部RAM检测失败,0x1001内部Flash检测失败,0x1002扩展接口检测失败,0x1003内部电压检测异常,0x1004闪存初始化失败,0x0001装置S超过有效范围,0x0003CJ、CJN、JMP缺少对应的Pn,_$未知故障代码: 0x{faultCode:X4}};}publicvoidDispose(){_heartbeatTimer?.Dispose();_tcpClient?.Close();_tcpClient?.Dispose();_isConnectedfalse;}}classProgram{staticasyncTaskMain(string[]args){// 替换成你的PLC IP地址usingvarplcnewDeltaPLCTCP(192.168.1.100);if(awaitplc.ConnectAsync()){Console.WriteLine(开始监控PLC...);while(true){try{// 读取D100-D103寄存器ushort[]dataawaitplc.ReadDRegistersAsync(100,4);Console.WriteLine($D100:{data[0]}, D101:{data[1]}, D102:{data[2]}, D103:{data[3]});// 读取温度假设D200和D201是32位浮点数floattemperatureawaitplc.ReadFloatAsync(200);Console.WriteLine($温度:{temperature:F2}℃);// 读取故障代码stringfaultCodeawaitplc.ReadFaultCodeAsync();Console.WriteLine($故障状态:{faultCode});Console.WriteLine(------------------------);}catch(Exceptionex){Console.WriteLine($读取数据失败:{ex.Message});}awaitTask.Delay(1000);}}else{Console.WriteLine(无法连接到PLC);}}}}这段代码里我加了自动重连和心跳机制再也不用担心网络断了程序卡死。故障诊断部分我只写了几个常见的故障代码你可以根据台达的手册自己补充完整。对了还有个坑要提醒你台达PLC的Modbus TCP端口默认是502但是有些型号可以改。还有PLC的IP地址一定要用WPLSoft提前设置好别想当然地以为是192.168.1.1。我现在在客户那边装了这个程序出问题了先远程看看故障代码大部分问题都能远程解决不用再跑现场了。上个月省了不少油钱晚上终于能睡个安稳觉了。