ARTICLE DETAIL

资讯详情

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

C#上位机TCP直连西门子PLC:S7协议报文解析与读写实现

C#上位机TCP直连西门子PLC:S7协议报文解析与读写实现 简介这是一套面向C#开发者的西门子PLC TCP通信源码示例包适用于工业自动化上位机开发、远程数据采集以及需要基于S7协议与PLC交互的工程师。程序重点演示了TcpClient连接建立、NetworkStream数据读写、通信报文封装与解析、异常处理与重试机制等完整流程能够帮助开发者快速理解PLC开放以太网通信的实现要点。压缩包共包含95个文件除C#与VB双版本工程源码外还有可直接运行的exe、通信DLL、PDB调试符号、XML接口说明、txt函数接口文档和PDF组件使用手册等整体约588KB目录结构清晰便于对照查阅和二次开发。已有2187人学习下载对正在调试C#与西门子PLC通讯功能、需要验证协议封包逻辑的开发者而言这份源码能显著节省协议联调时间适合作为自动化项目通信模块的起步模板。 搞C#上位机开发的迟早会接到一个任务不通过OPC、不买网关用TCP把西门子PLC的数据读上来。最开始我也被“S7协议”“ISO-on-TCP”这些词吓到过真正抓包看一遍之后会发现它就是在TCP之上套了一个固定格式的私有报文读变量、写变量都是发一段字节流收一段字节流。这篇文章不绕弯子直接给一套可在 .NET 环境下运行的C#源码覆盖TCP连接、握手、读DB块、写DB块同时把S7报文的字节结构拆开讲清楚。适合刚接手上位机开发或者想绕开OPC、直接用TCP直连PLC的工程师参考。1. 通讯方案选型直连PLC前先想清楚走哪条路1.1 西门子PLC网口通讯的几条路线在写代码之前先梳理一下市面上常见的几种连接方式不然很容易陷入“代码写完了PLC型号不支持”的尴尬局面。下表是我实际接触过的几种方案方案适用设备优点缺点适用场景手写TCP S7协议S7-1200/1500/300/400不依赖组件可控性强要理解报文格式调试成本稍高定制化上位机、协议学习Modbus TCPS7-200 SMART、部分1200报文简单网上一搜一大把地址映射繁琐要避开DB区优化老设备改造、第三方仪表互通OPC UA全系列跨品牌、接口统一部署和授权偏重中大型产线、多种PLC并存第三方库如S7netplus等S7-1200/1500开箱即用开发快依赖外部程序集版本更新有风险项目工期紧张快速交付如果你对接的是S7-1200或S7-1500我建议优先考虑“手写TCP S7协议”这条路线。因为西门子原生支持S7协议端口固定是102PLC里不需要做任何额外的通讯组态块只要在TIA里开放PUT/GET访问上位机就可以直接连。相比OPC UA少了一堆配置和License处理相比Modbus TCP省去了将DB地址映射成保持寄存器的痛苦。1.2 为什么最终选“TCP S7报文”很多人一听“S7协议”就发怵其实它本质就是运行在TCP之上的一个工业应用层协议。TCP三次握手由Socket层自动完成建连之后我们还要主动完成一次S7层的“握手”用来协商PDU长度等参数之后才能正常收发数据。自己撸报文的好处有三个第一不依赖第三方组件部署到客户现场只要一个exe不会因为缺DLL而翻车第二出了问题可以用抓包工具直接定位从TCP层到应用层每一帧都能看懂第三遇到非标需求比如批量读取、连续地址采集你可以按自己的节奏控制报文长度而不是被库的封装限制住。缺点也很明显要仔细抠字节一个偏移算错PLC那边就静默不理你。2. S7报文结构拆解网上源码看不懂的都卡在这一步2.1 一次通讯要发两次“握手”S7通讯不算复杂但建连过程容易糊。TCP层通过 socket 的三次握手建立连接这个不用你操心。连接建立后需要发两段数据完成“应用层握手”第一段是COTP连接请求通常说COTP CR包03 00 00 16 11 E0 00 00 00 01 00 C1 02 10 00 C2 02 10 02第二段是S7的setup communication报文用于协商PDU长度03 00 00 19 02 F0 80 32 01 00 00 00 04 00 00 08 00 00 04 01 00 00 00 82 A0如果从抓包工具里看这两段数据是先后发出的服务端会分别返回COTP CC包和S7协议的确认包。只有两端都通过后面发出的读DB、写DB请求才会被PLC接受。2.2 读DB块的报文模板PLC的数据存储区域很多实际项目里接触最多的就是DB块。以“读DB1中从字节0开始的4个字节”为例完整请求报文如下03 00 00 1C 02 F0 80 32 01 07 00 00 00 00 0D 00 00 04 01 12 0A 10 02 00 01 00 00 01 00 04我来拆一下这段报文03 00 00 1CTPKT头声明这是TCP包总长度28字节02 F0 80COTP数据头代表后续是DT数据包32 01 07 00 00 00 00 0D 00 00S7头32是协议ID01表示请求Job00 0D是参数区长度对应后面的13个字节04 01 12 0A 10 02 00 01 00 00 01 00 04参数区04是功能码Read Var01表示读取1个变量10代表DB编号00 01是DB号100 00 01是起始地址字节偏移0最后的00 04表示读取4个字节。写DB块的报文类似只是功能码从04改成05并且在参数区后面会多出一段数据区用于存放要写入的字节内容。理解了读DB的模板写DB就是在其基础上追加“待写入数据”而已。2.3 响应报文怎么取数PLC返回的响应报文也是由TPKT、COTP、S7头、参数区、数据区组成。注意S7头里有一个“返回状态”字段如果高4位不是0说明请求被PLC拒绝后面的数据区就不该继续解析。正常情况下数据区就是从S7头加参数区之后开始连续取读取长度个字节。这个取数逻辑我会在源码里体现。新手常犯的错误是把整个响应报文从第7个字节开始全当成数据结果取出来的数值怎么都不对其实就是没把S7头里的参数区长度跳过去。3. C#源码从零写一个可用的S7 TCP客户端类3.1 建立连接与握手下面这段代码直接放到项目里就能编译我使用的是System.Net.Sockets没有引入任何第三方包.NET Framework 4.8和.NET 6都兼容先看连接方法using System; using System.Net.Sockets; using System.Text; public class S7Client { private TcpClient _tcp; private NetworkStream _stream; public bool Connect(string ip, int port 102) { _tcp new TcpClient(); _tcp.SendTimeout 3000; _tcp.ReceiveTimeout 3000; _tcp.Connect(ip, port); _stream _tcp.GetStream(); // COTP Connect Request byte[] cotp new byte[] { 0x03, 0x00, 0x00, 0x16, 0x11, 0xE0, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC1, 0x02, 0x10, 0x00, 0xC2, 0x02, 0x10, 0x02 }; _stream.Write(cotp, 0, cotp.Length); byte[] resp ReadResponse(22); if (resp null || resp[5] ! 0xD0) return false; // S7 Setup Communication byte[] setup new byte[] { 0x03, 0x00, 0x00, 0x19, 0x02, 0xF0, 0x80, 0x32, 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x08, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x82, 0xA0 }; _stream.Write(setup, 0, setup.Length); resp ReadResponse(27); return resp ! null resp[21] 0x00; } }这段代码里我特意设置了收发超时时间为3秒。实际现场遇到过PLC地址写错或网线松了的情况如果没有超时程序会卡死在读取上上位机界面假死。先拨号、再协商两步都成功才算连接建立。3.2 读DB、写DB核心方法握手完成之后读写DB就是拼报文、发报文、读响应三步。我把通用的“发请求取数据”方法封装好再分别封装读和写private byte[] RequestResponse(byte[] req, int expectedLen) { _stream.Write(req, 0, req.Length); return ReadResponse(expectedLen); } public byte[] ReadDb(int dbNumber, int startByte, int length) { byte[] req new byte[31]; req[0] 0x03; req[1] 0x00; req[2] 0x00; req[3] 0x1F; req[4] 0x02; req[5] 0xF0; req[6] 0x80; req[7] 0x32; req[8] 0x01; req[9] 0x07; req[10] 0x00; req[11] 0x00; req[12] 0x00; req[13] 0x0E; req[14] 0x00; req[15] 0x00; req[16] 0x04; // Read Var req[17] 0x01; // 1个变量 req[18] 0x12; // 项长度 req[19] 0x0A; req[20] 0x10; // DB区 req[21] 0x02; req[22] (byte)(dbNumber 8); req[23] (byte)(dbNumber 0xFF); req[24] (byte)((startByte 8) 0xFF); req[25] (byte)(startByte 0xFF); req[26] 0x00; req[27] 0x04; // 传输大小代码 req[28] (byte)(length 8); req[29] (byte)(length 0xFF); byte[] resp RequestResponse(req, 28 length); if (resp null || resp.Length 32) return null; byte[] data new byte[length]; Array.Copy(resp, resp.Length - length, data, 0, length); return data; } public bool WriteDb(int dbNumber, int startByte, byte[] data) { byte[] req new byte[31 data.Length]; // 头部、参数区与读DB一致 req[0] 0x03; req[1] 0x00; req[2] 0x00; req[3] (byte)(31 data.Length); req[4] 0x02; req[5] 0xF0; req[6] 0x80; req[7] 0x32; req[8] 0x01; req[9] 0x07; req[10] 0x00; req[11] 0x00; req[12] 0x00; req[13] 0x0E; req[14] 0x00; req[15] 0x00; req[16] 0x05; // Write Var req[17] 0x01; req[18] 0x12; req[19] 0x0A; req[20] 0x10; req[21] 0x02; req[22] (byte)(dbNumber 8); req[23] (byte)(dbNumber 0xFF); req[24] (byte)((startByte 8) 0xFF); req[25] (byte)(startByte 0xFF); req[26] 0x00; req[27] 0x04; req[28] (byte)(data.Length 8); req[29] (byte)(data.Length 0xFF); // 写数据区 Array.Copy(data, 0, req, 30, data.Length); byte[] resp RequestResponse(req, 30); return resp ! null resp[14] 0x00; }这里有个容易踩坑的点读请求的TPKT总长度是31字节0x1F因为COTP固定3字节、S7头12字节、参数区16字节加一起正好31。写请求因为多了数据区总长度要动态计算不能写死。我早期抄网上的例子时没注意这个总长导致PLC一直接收不到完整报文问题排查了很久。3.3 从原始字节到真实数值的转换DB块里的数据可能是INT、DINT、REAL也可能是字节数组。TCP传输采用大端模式高字节在前而C#的BitConverter默认是小端模式所以不能直接用BitConverter.ToInt32去转必须先把字节序反过来public static short ToShort(byte[] data) { if (data null || data.Length 2) return 0; return (short)((data[0] 8) | data[1]); } public static float ToReal(byte[] data) { if (data null || data.Length 4) return 0; byte[] rev new byte[4]; rev[0] data[3]; rev[1] data[2]; rev[2] data[1]; rev[3] data[0]; return BitConverter.ToSingle(rev, 0); }示例调用也非常简单S7Client s7 new S7Client(); if (s7.Connect(192.168.0.1)) { byte[] raw s7.ReadDb(1, 0, 4); float val ToReal(raw); Console.WriteLine(DB1.DBD0 val); }ReadDb返回的是4个原始字节再交给ToReal转换。如果你要的是INT就取前两个字节用ToShort要是DINT或DWORD就取4个字节按大端拼成32位整数。4. PLC侧配置TIA里漏一个开关代码写得再对也白搭4.1 开启PUT/GET通讯访问C#代码写得再漂亮PLC侧没开放权限也是白搭。S7-1200/1500默认是不允许外部设备直接读写DB块的需要在TIA Portal里手动打开开关。操作路径打开项目进入CPU的设备组态在“属性” - “防护与安全” - “连接机制”里勾选“允许来自远程对象的PUT/GET通讯访问”然后下载硬件配置并重启CPU。这一步漏掉上位机执行TCP连接时PLC虽然没有拒绝但后续的S7读写请求会一直得不到正常响应。另外要注意S7-1500有些固件版本对PUT/GET的安全策略不同如果勾选了仍然连不上去检查一下CPU的防护等级和用户权限设置必要时把“防护等级”临时调到“允许完全访问无保护”做联调确认通讯正常后再改回来。这个做法适合测试环境生产环境请按现场安全规范来。4.2 DB块为什么读不到优化DB的坑很多新手遇到“连接正常握手正常但读DB返回空”的问题十有八九是用了优化DB块。S7-1200/1500默认新建的DB块是“优化的块访问”这种DB块没有固定的内存偏移地址DB编号和偏移地址由系统动态分配手写的S7报文根本没法寻址。解决办法是在TIA里选中对应的DB块右键打开“属性”取消勾选“优化的块访问”改为“非优化”访问。改完之后在DB块编辑器里要能明确看到“偏移量”这一列比如DB1中变量A的偏移量是0.0变量B是4.0这时候我才能用ReadDb(1, 0, 4)去读变量A。如果不想改动PLC项目的块属性也可以把需要通讯的数据单独放到一个非优化DB块里相当于给上位机开一个“共享数据区”这个做法在生产项目中更安全。4.3 确认CPU的IP和TSAP/CONNECT参数TCP连接要成功IP地址必须通这是基础。S7协议建连时COTP连接请求包里的TSAP传输服务访问点需要和PLC侧匹配。对于S7-1200/1500CPU的TSAP一般是0x0100本地TSAP/0x0102远端TSAP这样的组合不同系统版本会有一点差异但绝大多数场景下我们使用的标准报文已经包含了兼容信息不需要手动改。万一连接被重置建议先用厂商自带的ProSave或仿真软件确认TSAP设置再用抓包工具对比COTP请求和响应。5. 联调实录连接失败、数据错位、UI卡顿怎么解5.1 连接超时或被拒绝的排查顺序联调阶段最常见的现象是TCP连接都建立不起来或者连上了但握手收不到响应。按照下面顺序排查基本能解决九成问题现象排查点解决方案TCP connect超时网线、IP地址、子网掩码先用ping确认PLC网络可达连接成功但握手无响应CPU没开放PUT/GET按4.1打开连接机制开关握手有响应读写无返回DB块属于优化访问按4.2改为非优化DB偶尔连接失败重试恢复上位机端口被占用或防火墙拦截更换源端口关闭/放行防火墙程序重启后第一次连接慢PLC侧仍在释放上一次会话连接设置更长的超时或先断开再重连这里特别提一下端口占用问题。有些上位机程序会在同一个进程里频繁创建TcpClient关闭时不彻底释放底层Socket过一段时间就会报出bind: only one usage of each socket address之类的问题。我的习惯是复用同一个TcpClient实例断线重连时先Close()再重新创建而不是new一个又一个。5.2 数据错位和字节序问题通讯正常了但读出来的数值大得离谱或者完全不对这种情况先别怀疑协议把报文抓出来看数据区。我见过最多的问题是偏移地址算错DBD4不是DBX4.0很多新手把“字节偏移4”当成“位偏移4”实际读出来的数据从错误的位置开始数据区长度不对读一个REAL要用4字节结果只读了2字节后半截数据被截断大小端没处理C#直接BitConverter.ToInt32去转4字节得到的是反过来的数必须手工交换字节顺序PLC地址区域选错要读DB块结果把地址区的0x10写成了0x84I区数据自然不对。遇到数据错位我有个笨但有效的办法先在PLC里给变量赋一个特征值比如写十六进制0x12345678上位机读出来打印成十六进制一对比马上就知道是偏移问题还是大小端问题。5.3 高频采集时UI卡顿的优化思路这个坑不解决程序能跑但体验极差。C#里如果在UI线程里循环调用ReadDb并直接更新文本框即使只读4个字节高频执行时界面也会明显卡顿。原因是NetworkStream.Read会阻塞线程UI线程被网络等待占住界面自然不动。我的做法是把采集放到后台线程或者用async/await异步执行UI线程只负责把数据更新到控件。核心思路是读DB的耗时操作不占用UI线程UI更新只做一次赋值。比如用System.Timers.Timer定时读取DB读到数据后通过BeginInvoke切回UI线程更新显示这样读数和刷新互不干扰。另一个经验是控制读取频率大多数工业场景100ms采一次完全够用不必死磕10ms级别真需要高频率时优先考虑一次读取连续地址的多字节数据块而不是频繁发起小报文读取这样可以大幅减少TCP交互次数报文效率高很多。6. 一些在项目里被验证过的小习惯最后说几个容易忽略的小习惯。不管代码多简单Dispose一定做好上位机长时间运行时频繁开TCP连接最终会耗尽系统Socket资源程序越跑越慢最后连不上任何设备这种问题在客户现场非常难查。我的做法是给S7Client实现IDisposable接口在Dispose里关闭NetworkStream和TcpClient。另外接收响应时务必判断返回长度。PLC端如果出问题可能只返回一半数据甚至不返回如果Read方法返回的字节数不满足报文长度程序应该主动抛出异常或返回空而不是继续解析半个包否则后面的数据转换全会错乱。我在代码里用ReadResponse方法时会先读取完整头再根据头中的长度字段读取剩余部分这样能避免半包问题。如果你只是急着交付项目我也不会反对直接用开源库毕竟效率高、验证充分但我仍然建议在正式集成前用自己手写的这个简单类做一次通断测试。这个测试过程能帮你理解S7协议的底层逻辑后面真要排查问题思路会比只会封装库调用的人清晰很多。本文还有配套的精品资源点击获取
返回列表