C#上位机使用S7.NET读写西门子PLC数据实战指南
2026/9/16 15:46:46 网站建设 项目流程

简介:面向C#上位机开发者的西门子PLC通信示例,基于S7.NET协议实现与S7-200smart、S7-1200、S7-1500系列PLC的网口通信。程序由作者在工业现场长期运行验证,实测稳定可靠,代码结构清晰、注释完整,不仅适合入门学习,也方便在此基础上扩展自己的业务逻辑。资源压缩包共包含34个文件,以C#源码、配置文件、可执行程序、DLL依赖库和PDF说明文档为主,同时带有运行截图、工程解决方案等辅助材料,整体大小约1008KB,非常轻量。目前已有204人学习下载,社区反馈实用性强。通过该资源,读者可获得可直接运行的示例程序、完整工程源码、通信实现思路以及现场调试经验,对于正在搭建PLC上位机监控系统的开发者有直接参考价值,能够显著缩短S7.NET协议对接的摸索时间。

1. 用C#上位机连西门子PLC,先搞清楚S7.NET能干什么

做设备数据采集或者产线监控,只要对面是西门子S7-1200/1500,甚至老一点的S7-300/400,S7.NET几乎是C#开发者捡起来就能用的协议库。它不依赖Simatic Net软件授权,直接在TCP层实现了西门子的S7通信协议,用NuGet拉包就能连PLC的网口做读写。相比OPC UA,S7.NET的优点是轻、延迟低、API直白,适合做MES工位数据上报、参数下发这类上位机开发场景。这篇文章按我做项目的顺序来写:连接参数怎么配、DB块和M区怎么读写、循环数据采集和UI刷新卡顿怎么解决、断线怎么自动重连,最后给一套可以直接复制的字节解析工具。

2. S7.NET连接前的准备:CPU型号、TSAP和IP怎么配

2.1 TSAP决定连接层能否握手:S7-1200/1500和300/400的差异

S7.NET在底层做的事情,是先用TCP连到PLC的102端口,然后通过TPKT/COTP协议发起一次S7会话。这个过程中,TSAP(Transport Service Access Point,传输服务访问点)的作用是告诉PLC“我是上位机/编程器”,同时指定我要访问CPU的哪个槽位。很多第一次用S7.NET的人报错Unable to connect,实际不是IP不通,而是TSAP没配对。

S7-1200和S7-1500在默认组态下,CPU的TSAP是0x0100,对应S7.NET里的写法就是CpuType.S71200CpuType.S71500,配合rack=0, slot=0即可。S7-300和S7-400则要根据硬件组态来决定,因为CPU在机架里的物理槽位不同,比如S7-300的CPU通常在2号槽。这里有个常见误用:有人拿S7-300的代码去连1200,把slot填成2,连接就会一直超时。

CpuType枚举典型Rack典型Slot适用场景
S7120000S7-1200全系列,固件V4.x以上
S7150000S7-1500全系列,软PLC也常见
S730002S7-300,CPU在2号槽
S740003S7-400,部分机架为4号槽

提示:S7.NET的构造参数顺序是(cpuType, ip, rack, slot),中间没有端口号,因为S7协议固定走102端口。

2.2 最小连接代码:NuGet引入S7.Net并Open

在Visual Studio 2019里建一个WPF或WinForm项目,NuGet搜索S7.Net,装稳定版即可。注意区分S7.NetSharp7,前者API更贴近C#开发习惯,后者更底层、速度更快,但需要自己管理更多缓冲区细节。我一般用S7.Net做快速落地。

using S7.Net; public class PlcConnection { private Plc _plc; private readonly object _lock = new object(); public bool Connect(string ip, CpuType cpuType, short rack, short slot) { lock (_lock) { _plc = new Plc(cpuType, ip, rack, slot); _plc.Open(); return _plc.IsConnected; } } public void Disconnect() { lock (_lock) { _plc?.Close(); _plc = null; } } }

逻辑说明:lock锁是必要的,因为S7.NET的Plc实例不是完全线程安全的。如果采集线程和界面线程共用同一个_plc,同时调用ReadWrite可能出现TCP数据包交错,导致返回的字节数组错位。我见过有人把这个问题归结为“PLC数据不稳定”,其实是连接实例被多线程并发使用。另外,Open()内部是同步TCP连接,默认超时时间在几秒到十几秒之间,如果PLC不在线,调用线程会卡住,所以最好在调用前先Ping或者做超时控制。

2.3 多台PLC并存时的配置文件与路由检查

产线上通常不止一台西门子PLC,多工位、多机台各自独立。我不会在代码里硬编码IP和CPU型号,而是维护一个JSON配置文件,程序启动时加载并逐个建立连接。

[ { "name": "PLC_Station1", "ip": "192.168.1.10", "cpu": "S71200", "rack": 0, "slot": 0 }, { "name": "PLC_Station2", "ip": "192.168.1.11", "cpu": "S71500", "rack": 0, "slot": 0 } ]

对应的加载逻辑就是遍历这个数组,反射枚举值后new Plc(...)Open()。这种做法在替换PLC或者改IP时只动配置文件,不用重新编译上位机。还有一点容易被忽略:如果一台工控机有多个网卡,分别连着办公网和工控网,到PLC的流量必须走工控网那张网卡。S7.NET没有暴露绑定本地IP的选项,最终走哪条路由是由操作系统路由表决定的。排查手段是route print看目标网段的路由优先级,或者在工控机上把办公网卡的网关去掉。

3. S7.NET读写PLC数据的实操:DB块、M区与字节序解析

3.1 ReadBytes拉取DB块:为什么用它替代Read

S7.NET最核心的读取API是ReadBytes(DataType.DataBlock, dbNumber, startByteAdr, count),它按字节偏移从指定的DB块里取一段连续区域。举个例子,读DB10前100个字节:

var bytes = _plc.ReadBytes(DataType.DataBlock, 10, 0, 100);

Read方法虽然也能读单个变量,但一次只能读一个,当变量一多,代码就变成了一长串Read("DB10.0")Read("DB10.2"),每次都是一次完整的S7请求-响应。而ReadBytes一次把整块区域拉回来,再由本地代码做类型解析,S7通信次数从N次降成1次。对于100ms采集周期来说,这差距非常明显,尤其是DB块里几十个变量的时候。

ReadBytes时还有个技巧:PLC端的数据布局要事先规划好,把相同刷新频率的变量放在相邻的字节区域,这样一次能读全。比如温度、压力、速度模拟量放在DB10的0到199字节,设备状态字放在200到219字节,互不混排,上位机读起来就很有规律。

byte[] raw = _plc.ReadBytes(DataType.DataBlock, 10, 0, 200); // 此时已经拿到DB10的0~199字节,可以整体解析

参数说明:DataType.DataBlock表示访问DB块;第二个参数10是DB块号,必须和PLC里实际存在的块号一致;第三个参数是起始字节偏移,从0开始;第四个参数是要读取的字节数,最大长度受S7协议PDU大小限制,一般一次不超过240字节,超过就需要分多次读。

3.2 Write方法写M区和Q区:类型与位地址的对应

写入用Write方法,签名和Read类似。下面是三个典型操作:

// 向MW20写入整数50,对应S7的Int类型 _plc.Write(DataType.Memory, 20, 0, (short)50); // 向M22.0写入True,注意bitAdr=0 _plc.Write(DataType.Memory, 22, 0, true); // 向QB0写入一个字节,控制输出模块 _plc.Write(DataType.Output, 0, 0, (byte)0xFF);

第一行代码里,DataType.Memory是M区,20是起始字节地址,0是位偏移,值用short类型是因为S7的Int是16位整数。如果误传了C#的int,S7.NET的转换逻辑可能会占用4个字节,把隔壁的MW22给覆盖掉。这个坑我在现场排查过,现象是电机速度写进去,旁边的阀门开度跟着变了。

DataType.Output对应Q区,也就是输出映像区。写入Q区的值会直接影响设备输出,所以现场调试时要格外小心。我一般会在写Q区之前加一层软保护:只有当前设备处于手动模式且未报警时,才允许上位机写输出。M区虽然不像Q区那样直接驱动设备,但很多PLC程序用M区做模式切换或启动停止的中间变量,写错了同样会引起逻辑错乱。

3.3 字节序坑:为什么读上来的数值不对

西门子PLC的数据在内存里是Big-Endian,也就是高位字节在前。而C#的BitConverter默认按Little-Endian解析。直接拿BitConverter.ToInt32去转换读到的字节,数值大概率不对。

// 假设raw里存的是从DB读出的4个字节 byte[] raw = new byte[] { 0x41, 0xA0, 0x00, 0x00 }; if (BitConverter.IsLittleEndian) Array.Reverse(raw); float temperature = BitConverter.ToSingle(raw, 0); // 20.0f

这段代码先判断当前运行时是不是小端环境,是的话就把字节反转回来再做解析。Array.Reverse是原地反转,反转后raw的顺序就符合西门子的原始字节序了。同理,读16位Int的时候要反转2字节,读32位DInt要反转4字节。为了不让业务代码到处写反转逻辑,我习惯把解析方法集中到一个静态工具类里,传byte[]和偏移量,返回解析好的数值,业务层就不用关心字节序了。

4. 循环采集不卡UI:S7.NET的异步调用与断线重连

4.1 卡顿根因:同步Read阻塞在UI线程

用C#写上位机,最常见的界面卡顿原因不是CPU计算量大,而是把阻塞的PLC通信放到了UI线程里。很多人刚开始写的采集循环是这样的:

while (true) { var data = _plc.ReadBytes(DataType.DataBlock, 10, 0, 100); textBox1.Text = data[0].ToString(); // 直接在UI线程操作 Thread.Sleep(100); }

这段代码的问题在于:ReadBytes是一个同步阻塞调用,PLC一个扫描周期是10到50毫秒,加上网络传输和等待响应,总耗时可能到几十甚至上百毫秒。在UI线程里执行这个循环时,界面上的按钮、拖拽、输入框全部得不到响应,用户直观感受就是“窗口卡住了”。更糟的是,当PLC已经断线但尚未超时,ReadBytes会一直阻塞到TCP超时,界面可能卡十几秒才恢复,在产线上这就是事故了。

提示:凡是涉及网络通信的循环,无论S7.NET还是Modbus TCP,采集逻辑都应该放在后台线程,UI只负责订阅数据。

4.2 用Task.Run和async/await改造采集循环

推荐的改造方案是利用Task.Run把阻塞调用丢到线程池,再配合async/await保持代码的同步风格。下面是一个带取消令牌的后台采集示例:

private async Task PollLoopAsync(Channel<byte[]> channel, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { byte[] data = await Task.Run(() => _plc.ReadBytes(DataType.DataBlock, 10, 0, 200), ct); await channel.Writer.WriteAsync(data, ct); } catch (OperationCanceledException) { break; // 程序退出时正常终止 } catch (Exception ex) { _logger.LogError(ex, "读取PLC数据失败"); } await Task.Delay(100, ct); } }

这个循环每100毫秒采集一次DB10的前200字节,读回的数据通过Channel传给UI层。Task.Run里包的是之前那个同步的ReadBytes调用,这样UI线程永远不会被PLC的响应时间拖住。Channel是.NET内置的生产者消费者集合,比直接Invoke跨线程更新控件更解耦,UI层只需要单独开一个异步消费者去接收数据并刷新控件。

参数说明:CancellationToken可以来自CancellationTokenSource,在程序退出或用户点击“停止采集”时调用Cancel(),循环内的Task.Delay(100, ct)也会立即抛异常退出,避免线程泄漏。

4.3 心跳任务与重连状态机

工厂环境里网络闪断、PLC重启是常态。S7.NET连接一旦断开,旧的Plc实例基本上是废的,直接再调Open()虽然也能连上,但在某些固件版本下会出现句柄复用问题。所以我写了一个独立的心跳任务,每10秒检查一次连接状态,断线就新建Plc实例重连:

public async Task KeepAliveAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { if (_plc == null || !_plc.IsConnected) { try { _plc?.Close(); _plc = new Plc(_cpuType, _ip, _rack, _slot); _plc.Open(); Console.WriteLine($"{DateTime.Now}: PLC重连成功"); } catch { Console.WriteLine($"{DateTime.Now}: PLC重连失败,稍后重试"); } } await Task.Delay(10_000, ct); } }

注意这里每次重连都是new Plc(),不是在旧实例上反复Open,因为旧的socket状态可能已经残留了半开连接的数据。plc.Read()在连接断开后会抛异常,所以我们把这个异常视作触发重连的信号。

5. S7.NET实战技巧:多PLC并行采集与统一字节解析

5.1 并行采集时每个PLC用独立连接实例

项目里如果有多台西门子PLC需要同时采集,不要在一个Plc实例上轮询多台设备的地址,那样根本没有这个API,S7.NET一个实例只能连一台PLC的CPU。正确做法是每台PLC建一个独立的PlcConnection,然后用Task.WhenAll并行处理:

var tasks = plcConnections.Select(async conn => { byte[] data = await Task.Run(() => conn.ReadBytes(DataType.DataBlock, 20, 0, 100)); return (conn.Name, data); }); var results = await Task.WhenAll(tasks);

每台PLC有自己的socket连接,互不阻塞。这样当一台PLC断电时,其他工位的采集不受影响。

5.2 把Real和String解析提成工具方法

最后说一个实用的工具类。S7.NET虽然自带Types类能做Class映射,但那种方式需要定义与DB块结构一一对应的C#类,遇到字段增删就要重新编译。我更倾向于用偏移量手动解析,灵活性和可维护性都好一些:

public static class S7Converter { public static float ReadReal(byte[] buffer, int offset) { var bytes = new byte[4]; Array.Copy(buffer, offset, bytes, 0, 4); if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); } public static int ReadDInt(byte[] buffer, int offset) { var bytes = new byte[4]; Array.Copy(buffer, offset, bytes, 0, 4); if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToInt32(bytes, 0); } public static string ReadString(byte[] buffer, int offset) { int currentLength = buffer[offset + 1]; return Encoding.ASCII.GetString(buffer, offset + 2, currentLength); } }

ReadString里第一个字节是字符串声明的最大长度,第二个字节是当前实际长度,数据从第三个字节开始。这个格式和博途中定义的String变量完全一致,直接按偏移取就行,不需要先读长度再读内容。项目中我通常会在PLC组态里把所有字符串统一用S7 String类型声明,并且规定最大长度,上位机这边的解析代码就可以保持稳定。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询