news 2026/10/27 6:19:08

用C#实现西门子PLC S7通讯:从报文到轮询全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用C#实现西门子PLC S7通讯:从报文到轮询全解析

简介:这套C#与西门子PLC S7通讯实例源码面向工业自动化开发者与.NET程序员,演示如何借助S7.Net等通讯库实现TCP/IP连接建立、变量读写、错误处理及多线程后台交互,可作为从零上手PLC以太网通讯的参考模板。压缩包共39个文件,约244KB,包含11个C#源码文件、项目配置与窗体设计文件,以及编译生成的exe和pdb,另有界面资源与说明文档,便于直接运行和二次开发。目前已有487人学习下载。通过阅读Sharp7.cs、Form1.cs等核心代码,可掌握连接参数配置、ReadArea/WriteArea调用、界面与通讯逻辑分离等关键写法,并复用其中的线程调度与异常捕获方案,适用于快速搭建上位机监控或控制原型。

1. 用C#自己实现与西门子PLC的S7通讯,到底值不值

很多设备上位机项目里,一提到“和西门子PLC通讯”,第一反应就是OPC UA、买通讯库、或者干脆用组态软件。但等你真正陷入现场,会发现最简单的需求往往是:读几十个变量,写几个参数,要求实时性高、延迟低,最好还能在断线时快速重连。这时候S7通讯——直接用TCP/IP和PLC的102端口对话——反而是最稳、最可控的路线。C#写这类通讯的优势在于上手快、排错直观,而且无论是WinForm还是后端服务,都能和现有系统无缝接起来。本文给的是一套可以直接抄的实例源码思路:从S7报文结构讲起,到手写读写请求、然后完整封装成一个可上线的轮询类,最后把常见坑一次说清。适合已经能写C#但没碰过S7协议的人。

2. S7协议层拆开看:ISO-on-TCP握手、作业包结构与PDU协商

2.1 为什么是端口102而不是你熟悉的OPC UA端口

西门子S7的以太网通讯走的是ISO-on-TCP,也就是常说的RFC 1006,默认端口就是102。它和HTTP那种“发请求拿响应”的模型不一样,或者说更接近:TCP连接建立之后,首先要做一个链路层的连接握手,握手通过后再开始发业务数据。这个业务数据就是真正的S7通讯报文,也叫S7 PDU。

很多第一次接触的人会懵在这一点上:明明我已经TcpClient.Connect成功了,为什么发什么都不回?原因就是S7通讯在TCP之上还包了一层“连接确认”;你跳过了它,PLC根本不理你的业务报文。理解这个分层是写代码的前提。

这一层其实只有两次握手:

  1. 客户端发送COTP连接请求(CR)报文;
  2. PLC返回COTP连接确认(CC)报文。

握手完成之后,TCP连接才算真正“被PLC认可”。接下来才能发S7的建立通信请求——这一步一般用来协商PDU大小,也就是后续单条报文最多能承载多少字节数据。

2.2 先用手里的套接字把COTP连接建立起来

COTP连接请求报文可以直接用固定字节数组。我一般这样写:

byte[] cotpCR = new byte[] { 0x03, 0x00, 0x00, 0x16, // TPKT头:版本0x03,总长度22字节 0x11, 0xE0, // COTP PDU类型:连接请求(CR) 0x00, 0x00, 0x00, 0x01, // TPDU引用号 0x00, 0xC0, 0x01, 0x0A, // 源TSAP,这里取0x010A 0xC0, 0x01, 0x0C, // 目标TSAP,这里取0x010C 0x01, 0x00, 0x01, 0x00, 0x00, 0x00 // 连接参数固定段 };

这段数组的含义是固定的,绝大多数S7-300/400/1200/1500节点都接受这个默认握手。TCP连接建立后,直接把这段发给PLC,然后读回22字节的响应。响应里第5字节是0xD0,代表连接确认。

所以我做连接时从来不用“sleep一会儿再发业务报文”这种玄学办法,而是老老实实读回CC响应:

public bool Connect(string ip, int port = 102) { _client = new TcpClient(); _client.Connect(ip, port); _stream = _client.GetStream(); _stream.Write(cotpCR, 0, cotpCR.Length); byte[] ccBuffer = new byte[22]; int total = 0; while (total < 22) { int read = _stream.Read(ccBuffer, total, 22 - total); if (read <= 0) return false; total += read; } // CC响应固定以 0x11 0xD0 开头 return ccBuffer[4] == 0x11 && ccBuffer[5] == 0xD0; }

注意这里的Read不能只调用一次,TCP底层可能分片。你要是直接Read一次拿到不到22字节就判失败,在现场偶尔会翻车。

2.3 建立通信:PDU大小协商

COTP握完手,接下来发S7建立通信请求。它的作用是告诉PLC:“我这条连接后续单条报文最大支持480字节,你那边如果也支持,我们按这个值跑。”这就是PDU大小协商。

参考报文如下:

byte[] s7Negotiate = new byte[] { 0x03, 0x00, 0x00, 0x19, // TPKT头:总长度25字节 0x02, 0xF0, 0x80, // COTP数据报文(DT) 0x32, // S7协议ID 0x01, // ROSCTR:作业请求(Job) 0x00, 0x00, // 冗余 0x00, 0x02, // PDU引用号,这里从2开始 0x00, 0x00, // 参数长度0 0x00, 0x08, // 数据长度8 0xF0, 0x00, 0x00, 0x01, // 数据:协议参数 0x00, 0x01, 0x01, 0xE0 // 最大PDU长度:0x01E0,即480字节 };

这段发过去后,PLC会回一个同样结构的报文。关键是回包里的“最大PDU长度”字段:如果对方协商下来比480小,那你后续所有读写请求里的数据长度都得按这个更小值来。很多只读一个DB块就超时的案例,问题就出在这里。

2.4 看清S7报文头的11个字节

S7通讯报文在TPKT和COTP之后,有一段统一定义的报文头:

字段字节数说明
协议ID1固定0x32
ROSCTR10x01作业请求,0x03确认数据
冗余2固定0x0000
PDU引用2每次请求自增,用于匹配响应
参数长度2参数区长度
数据长度2数据区长度

这11个字节是后面所有代码的骨架。读数据也好、写数据也好,改变的只是“参数区”和“数据区”的内容,头部结构永远不变。

3. 读写PLC内存:M区、DB块和地址换算那点事

3.1 S7地址和PLC地址是怎么对应的

写S7通讯绕不开“怎么表示一个PLC变量”。S7协议里一条地址用下面几部分描述:

  • 区域:有输入I区、输出Q区、M区、DB区;
  • DB号:只有访问DB区时才用;
  • 字节地址;
  • 位地址(一般写0)。

区域常量对应关系如下:

区域十六进制值常见写法
输入I区0x81I0.0、IB0
输出Q区0x82Q0.0、QB0
M区0x83M0.0、MB0
DB区0x84DB1.DBW0

地址换算在C#里特别容易出错。拿DB1.DBW100举例,它等于在DB1里偏移100个字节处取一个字。S7报文里,DB号写在“传输大小”后面的两字节里,字节地址写在“区域”后面的两字节里。实际拼报文时,这两个值都要按高字节在前。

3.2 手工构造一条读请求并解析响应

一条最简读请求,比如读MB100的一个字(M100.0开始的16位),完整报文如下:

byte[] readMw100 = new byte[] { 0x03, 0x00, 0x00, 0x1F, // TPKT头:总长度31字节 0x02, 0xF0, 0x80, // COTP数据 0x32, 0x01, // S7Job请求 0x00, 0x00, // 冗余 0x00, 0x03, // PDU引用号=3 0x00, 0x0E, // 参数长度=14 0x00, 0x00, // 数据长度=0(读请求无数据段) 0x00, 0x04, // 功能码:0x04=读 0x01, // 读取项数=1 0x12, 0x0A, // S7ANY,长度10字节 0x10, // 语法ID 0x04, // 传输大小:0x04=字 0x00, 0x01, // 重复次数=1 0x00, 0x00, // DB号=0(因为读的是M区) 0x83, // 区域:M区 0x00, 0x64 // 字节偏移100,位0 };

如果这段看懂了,读任何一个变量都只是改后面六个字节的事:传输大小、DB号、区域、字节偏移。

发送之后读取响应。S7读响应是ROSCTR=0x03的确认报文,结构和请求不一样,我一般这样解析:

public int ReadWord(byte[] request, out ushort value) { _stream.Write(request, 0, request.Length); // 读够TPKT总长度字段指示的字节数 byte[] header = new byte[4]; int len = ReadFull(header, 4); if (len < 4) return -1; int totalLen = (header[2] << 8) | header[3]; byte[] payload = new byte[totalLen - 4]; ReadFull(payload, totalLen - 4); // payload里第5、6字节是返回码,0x00表示成功 int retCode = payload[12]; if (retCode != 0x00) return retCode; // 数据段最后一个字才是我们要的值 int size = (payload[14] << 8) | payload[15]; value = (ushort)((payload[16] << 8) | payload[17]); return size; }

解析时只校验返回码,不去过度解析,这样做现场排错最有效率。返回码非零时,PLC会返回具体的数字含义,最常见的是0x05表示地址不存在、0x04表示区域不存在。

3.3 写请求怎么构造

写请求和读请求最大的区别是:写请求在数据段里要带上“要写的值”。以写MW100一个整型16位数据为例,功能码变成0x05,数据长度变为4字节:

public byte[] BuildWriteRequest(ushort value) { byte[] req = new byte[35]; req[0] = 0x03; req[1] = 0x00; req[2] = 0x00; req[3] = 0x23; // 长度35 req[4] = 0x02; req[5] = 0xF0; req[6] = 0x80; req[7] = 0x32; req[8] = 0x01; // Job req[9] = 0x00; req[10] = 0x00; // 冗余 req[11] = 0x00; req[12] = 0x04; // PDU引用 req[13] = 0x00; req[14] = 0x0E; // 参数长度 req[15] = 0x00; req[16] = 0x04; // 数据长度=4 req[17] = 0x00; req[18] = 0x05; req[19] = 0x01; // 功能05,1项 req[20] = 0x12; req[21] = 0x0A; req[22] = 0x10; // S7ANY req[23] = 0x04; req[24] = 0x00; req[25] = 0x01; // 传输大小=字 req[26] = 0x00; req[27] = 0x00; // DB号 req[28] = 0x83; // M区 req[29] = 0x00; req[30] = 0x64; // 地址100 req[31] = 0x00; req[32] = 0x04; // 写一个字符数组,长度4 req[33] = (byte)(value >> 8); req[34] = (byte)(value & 0xFF); return req; }

注意最后两字节的顺序:S7多字节数据一律高字节在前,也就是大端序。你要是按Intel的小端序直接拼,写进去的数据在PLC里会变成乱码。C#里BitConverter默认按本机字节序工作,千万别直接拿来转PLC数据。

3.4 一块搞定多地址轮询的封装函数

实际项目中很少单点读写,我一般会设计一个S7Address结构体,把地址解析成一个统一的报文片段:

public struct S7Address { public byte TransportSize; // 1=位,2=字节,4=字,6=实数 public byte Area; public ushort DbNumber; public ushort ByteAddress; public byte[] ToAddressField() { byte[] field = new byte[6]; field[0] = 0x10; field[1] = TransportSize; field[2] = 0x00; field[3] = 0x01; field[4] = (byte)(DbNumber >> 8); field[5] = (byte)DbNumber; return field; } }

然后基于它生成读请求,一个循环里把一批地址拼进同一条读请求。这样比每个地址单独收发一条报文效率高很多,现场实测在200ms的刷新周期里读50个地址完全没压力。

4. 实例源码:一个WinForm的S7读写助手

4.1 源码结构与应用场景

我通常把整个通讯层拆成三个类:

  • S7Transport:只管TCP连接、握手、收发报文;
  • S7Reader:封装地址和读写逻辑,返回Dictionary<string, object>;
  • S7Form:WinForm界面,定时器触发轮询。

这三个类分开之后,任何一层出问题都能单独调试。特别是测PLC配置时,我会先用一个简单的控制台工程只调S7Transport,确认握手通顺,再回到WinForm里做界面。

WinForm里放一个TextBox填IP,一个Button做连接,一个DataGridView显示变量名、地址、值、质量戳。轮询用System.Windows.Forms.Timer,间隔200ms左右。

4.2 核心轮询代码直接抄

下面的代码是S7Reader的核心循环。它把一批地址拆成多条读请求,每条请求最多放8个地址,避免单包超长:

public Dictionary<string, object> ReadBatch(List<S7Address> addresses) { var result = new Dictionary<string, object>(); // 分批:每批最多8个地址 for (int i = 0; i < addresses.Count; i += 8) { var batch = addresses.Skip(i).Take(8).ToList(); byte[] request = BuildReadRequest(batch); var response = _transport.RequestResponse(request); if (response == null) { foreach (var addr in batch) result[addr.Name] = null; continue; } int offset = 16; // 跳过回复头和数据项头 foreach (var addr in batch) { result[addr.Name] = ParseValue(response, ref offset, addr.TransportSize); } } return result; }

这里有两个坑。第一个是分批:如果PLC协商的PDU只有240字节,一次读20个REAL必然超长,所以分批逻辑必须有。第二个是ParseValue里的偏移管理:响应数据是一个连续字节流,每个地址按传输大小向后挪动。

解析值的时候我只处理四种常用类型,代码很直接:

private object ParseValue(byte[] buf, ref int offset, byte transportSize) { if (transportSize == 0x01) // 位 { bool bitVal = (buf[offset + 2] & 0x01) != 0; offset += 2; return bitVal; } else if (transportSize == 0x02) // 字节 { byte val = buf[offset]; offset += 1; return val; } else if (transportSize == 0x04) // 字/INT { short val = (short)((buf[offset] << 8) | buf[offset + 1]); offset += 2; return val; } else if (transportSize == 0x06) // 实数REAL { int raw = (buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]; offset += 4; return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); } return null; }

位类型的偏移计算要特别注意:S7里位不是单独占用一个字节,而是和同一字节地址下的其它位一起返回。所以读取位时,响应里是按字节返回,你只取最低位。这个细节我踩过两次,一次是读一个启停位一直读到0。

4.3 界面刷新与线程安全

WinForm里直接在定时器回调里做同步读写是可行的,因为Timer本身跑在UI线程上,读写TCP不阻塞太久,200ms周期足够。但如果你把刷新周期压到50ms以下,或者PLC响应稍慢,界面会“卡一下”,这时候就必须把收发放到后台线程。

我的做法是加一个CancellationTokenSource,后台线程循环调用ReadBatch,把结果放进ConcurrentDictionary,界面用BindingSource定时刷新。这样通讯线程和界面彻底分离,任何一方出问题都不会拖死对方。

5. 避坑与故障排查:S7通讯最常见的5个问题

5.1 现象:TCP能连上,但发完建立通信请求后PLC不回复

现象是Connect成功,Write也成功,但读不到任何响应,程序卡在读取上。

原因基本是两种:一是COTP握手没有做,直接发了S7业务报文;二是TSAP不匹配。S7-1200/1500默认的TSAP是0x0102(对应槽位1)或者0x0100,而很多旧代码默认PLC TSAP是0x010C。

解决:先用第2章里的固定握手报文测一遍;如果PLC仍然不回,把目标TSAP从0x010C改成PLC组态里实际配置的值,比如0x0102。改的是COTP报文里0xC0, 0x01, 0x0C这组。

5.2 现象:连接正常,读少量数据没问题,批量读就超时

这个坑十有八九是PDU大小协商没生效。PLC返回的协商PDU可能是240字节,而你的代码里默认按480字节拼报文,一次请求带太多地址,报文长度超过PLC允许范围,它就直接丢弃。

解决:解析握手响应里的“最大PDU长度”字段,把它存成实例变量。后面构造读写请求时,计算当前报文总长度,超过这个值的绝对不发。

5.3 现象:读DB块,数据全是0,或者全是255

原因通常是“优化DB访问”没关。S7-1200/1500新建的DB块默认是“优化的块访问”,这种块没有固定的内存偏移地址,用绝对地址读不到任何真实数据。

解决:在TIA博图里打开DB块属性,取消“优化的块访问”勾选,重新编译下载。注意这会改变PLC侧符号寻址的某些表现,但为了上位机S7通讯,这个开关必须关。另外还要在PLC“防护与安全”里勾选“允许来自远程对象的PUT/GET通信”。这俩配置缺一个,你代码写再多也读不到。

5.4 现象:程序在自己电脑上正常,部署到工控机上就频繁掉线

这问题最玄学。排查下来多数是网卡节能或防火墙。Windows默认的网卡驱动开着“节能以太网”,网络空闲时自动降速,PLC通讯偶发超时。

解决:在设备管理器里把网卡“电源管理”中的“允许计算机关闭此设备以节约电源”取消勾选;同时关闭防火墙对可执行程序的拦截。要么直接加策略,要么把程序加入防火墙白名单。代码层面可以把TCP的KeepAlive打开,心跳超时后主动重连。

5.5 现象:PLC程序一运行,上位机读到的数据就会跳变一次

这不是通讯问题,是你在上位机里把“写”和“读”的地址搞混了。比如PLC里DB10.DBW0是设定值,你上位机也往这个地址写,PLC程序每个扫描周期又对它做运算,数值当然会反复跳。

解决:划分好通讯区。常规做法是单独划出一个DB块,只做上位机读写,PLC逻辑不直接改写它。上位机负责写“命令字”,PLC程序负责在收到命令后把结果放到另一个“状态区”。上位机只读状态区、只写命令区,两边不重叠。

6. 验证你的通讯链路:用Wireshark把每个包看清

写好的代码能否上线,别只凭“能连上”就下结论。我的习惯是第一次调试必开Wireshark,抓目的端口102的包。抓包能看到三件事:COTP握手是否成功、PDU协商值是多少、读写请求返回的报文内容是什么。

先在过滤器里填tcp.port == 102,然后触发你的连接。你会看到前几个包是TCP握手,然后是组COTP连接报文。如果你的程序发出的是S7业务报文,但前面没有COTP那两组,那就能直接判定问题出在握手步

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 6:18:53

视觉语言模型MindOmni:多步逻辑推理生成技术解析

1. 项目概述&#xff1a;当视觉语言模型遇上推理生成2025年NIPS会议即将揭晓的MindOmni项目&#xff0c;正在重新定义视觉语言模型&#xff08;Vision Language Models, VLMs&#xff09;的能力边界。这个由RGPO&#xff08;Reasoning Generation via Policy Optimization&…

作者头像 李华
网站建设 2026/10/27 6:17:54

垂直AI技术解析:从领域痛点到落地实践

1. 垂直AI的崛起背景与行业痛点2006年亚马逊AWS的诞生标志着云计算时代的开始&#xff0c;企业首次能够通过标准化IT基础设施快速部署业务。这种"万金油"式的通用计算平台在随后十年间成为行业标配&#xff0c;但人工智能的发展轨迹正在颠覆这个范式。我亲历过多个AI…

作者头像 李华
网站建设 2026/10/27 6:16:56

Agent-Reach:面向多源API协同的轻量级运行时协议

1. “Agent-Reach”不是新模型&#xff0c;而是一套面向开发者的服务协同协议你点开 GitHub 搜索“Agent-Reach”&#xff0c;大概率会看到一个空仓库、几条零星的 issue&#xff0c;或者某个 CLI 工具的 README 里偶然带出的术语。它没有官方文档&#xff0c;没有发行版号&…

作者头像 李华
网站建设 2026/10/27 6:15:16

PostgreSQL触发器详解:从基础到高级应用

1. 理解PostgreSQL触发器的基础概念在数据库管理系统中&#xff0c;触发器&#xff08;Trigger&#xff09;是一种特殊的存储过程&#xff0c;它会在特定事件发生时自动执行。PostgreSQL作为功能强大的开源关系型数据库&#xff0c;提供了完善的触发器机制。与普通存储过程不同…

作者头像 李华
网站建设 2026/10/27 6:13:50

端到端测试实战指南:从e2e profile1热词到落地场景全解析

1. 当“e2e”不再只是三个字母&#xff1a;从热词到落地场景的拆解“e2e”这三个字母&#xff0c;最近在技术社区和产品圈子里出现的频率明显高了起来&#xff0c;尤其是“e2e profile1”这个组合词冲上热搜之后&#xff0c;很多刚入行的朋友一脸懵&#xff1a;这到底是个什么东…

作者头像 李华
网站建设 2026/10/27 6:13:26

Absolute Database v7.93源码编译与Delphi避坑

简介&#xff1a;Absolute Database v7.93 源代码包面向 Delphi 4 至 11&#xff08;含 Alexandria&#xff09;开发者&#xff0c;适合需要研读数据库引擎实现、进行二次开发或做企业级定制的中高级程序员。压缩包共 756 个文件、约 18.9MB&#xff0c;以 pas 单元与 hpp 头文…

作者头像 李华