简介:这是面向C#开发者与工业自动化从业者的西门子S7 PLC通信实例源码,由工控老马整理并验证可用,适合从入门到进阶的工程师参考学习。压缩包共32个文件,整体约140KB,典型结构包含9个C#源文件、项目工程与解决方案文件、配置文件及编译输出文件,其中Form1.cs与Form1.Designer.cs主导界面逻辑,App.config用于连接参数配置,可直接在Visual Studio中打开S7PLCTest.sln运行调试。已有322人学习使用。资料通过S7.NET库实现与西门子PLC的数据交互,覆盖通信连接、读写操作与界面展示等关键环节,并附带可执行exe与调试符号,便于对照学习和验证效果。目录结构清晰,适合希望快速上手.NET方式PLC通信、理解实际封装思路的开发人员。
1. C# 上位机直连西门子S7 PLC:先想清楚用哪条路
很多做过设备采集的 C# 工程师,第一次接到“把西门子 PLC 的数据取出来”的需求时,第一反应是装 WinCC、配 OPC 或者去翻 SIMATIC Manager。其实在 .NET 里通过 S7 以太网协议可以直接读写 S7-1200、S7-1500 和 S7-300/400,代码量并不大。适合的场景很明确:写 C# 上位机界面、做小型数据采集网关、或者在 MES 项目里临时抓几个 DB 变量。不需要额外授权,也不需要 PLC 停机的软件组态,只要 IP 能通,把 Rack、Slot、DB 地址和轮询线程这四件事理顺,变量就能出现在 WinForms 或者 WPF 里。下面按原理、实例、循环采集、排错这条路线展开一套能直接落地的方案。
2. S7 通信原理与 .NET 侧选型:协议形态和库的取舍
在实际写代码之前,先要弄清楚一条链路里有哪些角色。西门子 S7 的以太网通信走的是 TCP 102 端口,PLC 作为 TCP 服务器,上位机是客户端。无论是 S7-1200、S7-1500 还是老款的 S7-300/400,都允许通过 S7 协议发起读写请求,只是 PLC 侧默认不一定放行。TIA Portal 里必须打开“允许来自远程对象的 PUT/GET 通信”,这个选项在 CPU 属性 -> 保护与安全 -> 连接机制下面。第一次接 S7-1500 的人最容易在这里卡住,因为出厂配置对直接访问比较严,Open 能成功,Read 却会拿到访问拒绝,代码改来改去都找不到原因。
一旦开关打开,剩下的工作就是“寻址”。PLC 里的 DB1.DBD4,在 C# 地址字符串中也写成DB1.DBD4,但读出来的数据类型要你自己声明成 float、int 还是 bool。这个映射非常固定,熟悉之后完全可以不看报文直接写代码。
2.1 S7 通信不是 HTTP:请求/响应和 PDU 协商
S7 协议和 Modbus TCP 最大的差异在于,连接建立后不是马上就能发数据。plc.Open()会先完成一次 PDU 协商,双方确认能处理的请求长度;随后读取每个 DB 区域时,客户端发送读请求,服务端返回数据或者错误码。这个过程被类库封装好,但排错时要知道异常文本对应哪个环节。遇到PlcException时,常见提示包括目标地址不存在、DB 块长度不足、连接被拒绝三类,分别指向 DB 编号写错、偏移超出块长度、PUT/GET 未放行。
对应用层开发来说,这些背景不用背下来,却决定了选型。很多方案里在 C# 和 S7 之间插了一层 OPC UA,使用成本立刻高了一截。如果你的上位机只服务一两台 PLC,直接用 S7 客户端库是更省事的做法;只有当多个品牌 PLC 或多种系统访问同一批点位时,OPC UA 才值得上线。我一般把 OPC UA 当作最后选项,避免为一个小工具装两个后台服务。
2.2 三个 .NET 方案的取舍:S7.NetPlus、Sharp7 与 OPC
| 方案 | 部署形态 | 读取方式 | 适合场景 |
|---|---|---|---|
| S7.NetPlus | NuGet 引用,一个 dll | plc.Read("DB1.DBD0")字符串地址 | 上位机界面、点位少、开发快 |
| Sharp7 | 单 dll,偏底层封装 | S7Client.DBRead()按字节块读 | 高频采集、需要控制报文细节 |
| OPC UA/DA | 安装 OPC Server,再写客户端 | 标准接口,统一地址空间 | 异构设备、多客户端、MES 对接 |
S7.NetPlus 的优势是 API 离 PLC 变量表很近,地址怎么写,代码就怎么写,适合本文的场景。Sharp7 更接近底层,当你需要一次读回 200 字节 DB 时,它的 DBRead 更高效。OPC 方案在部署上要额外维护一个服务器,如果不是项目里已经存在,不建议为单台 PLC 引入。选择之后,就可以开始配置 PLC 侧参数了。
2.3 连接前先用一条命令确认 PLC 的 102 端口
大多数 S7 连接失败不是 C# 代码的问题,而是网络没通。我会在写上位机之前先用 PowerShell 探测 PLC 的 TCP 102 端口:
Test-NetConnection 192.168.0.1 -Port 102这条命令返回TcpTestSucceeded : True,才说明 PC 和 PLC 之间的 S7 通道是通的。如果返回 False,先检查 IP 是否同段、物理网卡是否启用、Windows 防火墙是否放行 TCP 102,然后再回 C# 代码里找原因。
PLC 侧还需要确认三个参数:一是 CPU 的 IP,二是机架号 Rack,三是 CPU 所在槽位 Slot。常见的对应关系是:S7-1200 和 S7-1500 基本是 Rack=0、Slot=1;S7-300 大多数是 Rack=0、Slot=2;S7-400 要看硬件组态,通常在 0 或 1 起步。这三个值会直接传给Plc构造函数,填错的表现往往是 Open 超时,而不是登录失败。除地址外,要读取的 DB 块还需要取消“优化的块访问”,否则从外部访问时看到的变量表是空的。这条要记住,下面实例代码默认按“非优化访问”处理。
3. 用 S7.NetPlus 在 10 分钟内收发 DB 与 I/O 的实例代码
开始写代码前,确认项目里通过 NuGet 引用了 S7.NetPlus 包。示例程序以 WinForms 或控制台为例都没问题,公共逻辑是“创建 Plc -> Open -> Read/Write -> Close”。下面这段代码会演示 DB、M 区和 I/O 区的读写,地址字符串直接对标 PLC 程序里的 DB 变量。
3.1 建立连接:new Plc(cpu, ip, rack, slot) 四个参数要写对
using S7.Net; using System; class S7Client { private readonly string _ip; private readonly short _rack; private readonly short _slot; private Plc _plc; public S7Client(string ip, short rack, short slot) { _ip = ip; _rack = rack; _slot = slot; } public bool Connect() { // CpuType 要和实际 PLC 一致:1200、1500、300、400 都有独立枚举 _plc = new Plc(CpuType.S71200, _ip, _rack, _slot); _plc.Open(); return _plc.IsConnected; } public void Close() { _plc?.Close(); } }代码里的Open()不是简单地建立一个 Socket 连接,它内部完成了 TCP 连接和 S7 PDU 握手,如果 PLC 不存在、IP 不通、端口被防火墙拦,都会抛PlcException。参数中CpuType.S71200表示目标机型,库在解析地址时依赖这个枚举;写错型号容易出现连接成功但 Read 得到 PDU 错误。Rack 和 Slot 的默认值上文已经给过,连接 S7-1200 时用 0 和 1 即可。注意Plc对象不要频繁创建,一个 PLC 保持一个长连接,采集循环里复用。
3.2 读写 DB 和 M 区:地址字符串就是 PLC 坐标
读操作:
public void ReadDemo() { // DB1 的第 0 字节第 0 位,返回 bool bool startBtn = Convert.ToBoolean(_plc.Read("DB1.DBX0.0")); // DB1 第 8 字节开始的 32 位浮点数 float temp = Convert.ToSingle(_plc.Read("DB1.DBD8")); // M 存储区的第 100 字节第 2 位 bool alarm = Convert.ToBoolean(_plc.Read("M100.2")); }写操作:
public void WriteDemo() { // 把 DB1.DBD8 对应的 Real 变量改成 22.5 _plc.Write("DB1.DBD8", 22.5f); // 启动信号写到 M100.2 _plc.Write("M100.2", true); }这段代码最需要理解的是地址后缀:DBX是位,DBB是字节,DBW是字,DBD是双字;DB 后的第一个数字是 DB 块号,第二个数字是字节偏移。写值时类型由 C# 值类型决定,bool、float、int 都会映射到 PLC 对应类型。S7.NetPlus 内部已经做了大端转换,所以完全不用关心 CPU 的字节序,这点和后面自己读字节数组的场景不一样。常用地址的映射关系如下:
| 地址示例 | C# 类型 | PLC 数据类型 |
|---|---|---|
DB1.DBX0.0 | bool | Bool |
DB1.DBB0 | byte | Byte |
DB1.DBW0 | ushort | Word/Int |
DB1.DBD4 | uint/float | DWord/Real |
M100.2 | bool | Bool |
如果目标是整段读取,比如 DB10 中连续 50 个 Real 变量,逐个Read会发起 50 次请求,效率太低。常见做法是ReadBytes(DataType.DataBlock, 10, 0, 200)一次抓回 200 字节,再用BitConverter解析,既减少通信次数,也便于写日志。下面工具类把两种方式都封装进去。
3.3 带超时和自动重连的读写工具类
public class SafeS7Client { private readonly string _ip; private readonly short _rack; private readonly short _slot; private Plc _plc; public SafeS7Client(string ip, short rack, short slot) { _ip = ip; _rack = rack; _slot = slot; } public float ReadFloat(string address) { EnsureConnected(); return Convert.ToSingle(_plc.Read(address)); } public byte[] ReadDbBytes(int db, int start, int length) { EnsureConnected(); return _plc.ReadBytes(DataType.DataBlock, db, start, length); } private void EnsureConnected() { if (_plc != null && _plc.IsConnected) return; _plc?.Close(); _plc = new Plc(CpuType.S71200, _ip, _rack, _slot); _plc.Open(); } }EnsureConnected()在每次读写前检查状态,掉线时重新执行 Close 和 Open,重建 S7 会话。这里有一个常见误用:很多人以为IsConnected会实时反映 TCP 状态,实际上它只是一个上次 Open 后的标志,网络断开后不会自动变成 false。因此更可靠的方式是把 Read 放进 try-catch,一旦捕获PlcException或SocketException,调用重连并重新读取一次。要避免任何异常都立刻重连,连续失败达到 3 次以上时停 2 秒再试,否则会对 PLC 形成一次重连风暴。S7-1200 允许的并发连接数有限,重连过快反而会把设备拖死。
4. 循环数据采集与 UI 刷新:把 PLC 轮询从界面上拆走
搜过 C# 上位机的人,一定见过这类问题:循环读取 PLC 数据时界面卡死,窗口一直转圈,关闭还要卡几秒。原因几乎都出在同一个地方:把 PLC 的直接读写放在了 UI 线程里。下面这套方案把采集和显示拆成两段,每段各管一个时钟。
4.1 为什么把轮询放进 UI 线程就会卡
多数初始写法是在一个 Button 点击里写while (true) { label.Text = plc.Read("DB1.DBD0").ToString(); }。每一次 Read 都是一次同步的 TCP 请求-响应,遇到网络延时或 PLC 忙,单个请求可能阻塞几百毫秒;此时 UI 线程被独占,Windows 消息循环无法执行,窗口必然无响应。更隐蔽的问题是,如果 Read 失败后马上重连,UI 线程卡的时间会被重连的超时叠加,最后连关闭按钮都点不动。不要用Application.DoEvents()去缓解,它会在一次采集里重入大量 UI 消息,界面看起来响应了,数据反而会乱跳。
把采集放到后台线程,只在需要的时候把带时间戳的快照交还给界面。这里的约束是:UI 控件是单线程组件,跨线程组件通信必须通过消息封送。与其到处写Invoke,不如用一个 UI 定时器去队列里取数据,这样并发边界非常干净。
4.2 用 Channel 做采集队列,后台任务只管读
using System.Threading.Channels; public class MachineSnapshot { public float Temp { get; set; } public bool Running { get; set; } public DateTime Timestamp { get; set; } } Channel<MachineSnapshot> _snapChannel = Channel.CreateBounded<MachineSnapshot>( new BoundedChannelOptions(200) { FullMode = BoundedChannelFullMode.DropOldest });后台循环:
void StartCollectLoop(CancellationToken token) { Task.Run(() => { while (!token.IsCancellationRequested) { try { var snap = new MachineSnapshot { Temp = Convert.ToSingle(_plc.Read("DB10.DBD0")), Running = Convert.ToBoolean(_plc.Read("DB10.DBX0.0")), Timestamp = DateTime.Now }; _snapChannel.Writer.TryWrite(snap); } catch (Exception ex) { // 连续异常时记录日志,交给上层决定是否重连 Console.WriteLine(ex.Message); } Thread.Sleep(100); // 采集周期 100ms,可按工艺调整 } }, token); }这里的Channel是 .NET Core 3.0 及以上内置的标准库队列,容量设为 200 帧,DropOldest表示队列满时丢最旧的数据,避免 UI 慢时内存无限涨。如果你还在 .NET Framework 4.8,需要补一个System.Threading.Channels的 NuGet 包,或者直接把队列换成ConcurrentQueue,效果接近。采集周期 100ms 对大多数设备足够;如果读的是温度、压力这类慢过程,可以放到 500ms;如果做高速包装线计数,再降到 50ms。TryWrite 不会阻塞,即使 UI 消费跟不上,后台任务也能继续按时采下一帧。没有用 Unbounded,是因为无界队列在 UI 卡死一小时时会积压几十万帧数据,没必要。
| 采集周期 | UI 刷新周期 | 适用场景 |
|---|---|---|
| 50ms | 200ms | 高速包装、实时报警 |
| 100ms | 500ms | 通用设备监控 |
| 500ms | 1s | 温度、液位等过程量 |
4.3 UI 端用 Timer 定期取队列,不要一帧刷一次
System.Windows.Forms.Timer uiTimer = new System.Windows.Forms.Timer(); uiTimer.Interval = 500; uiTimer.Tick += (s, e) => { while (_snapChannel.Reader.TryRead(out MachineSnapshot snap)) { lblTemp.Text = snap.Temp.ToString("F2"); btnRun.Enabled = snap.Running; lblTime.Text = snap.Timestamp.ToString("HH:mm:ss.fff"); } }; uiTimer.Start();这个 Timer 运行在 UI 线程,因此直接赋值给 Text 和 Enabled 是合法的。每隔 500ms 把队列里剩余的快照全部取出来,界面刷新是一帧,不会出现逐帧闪烁;后台采集仍然是 100ms 一次,数据粒度不受刷新频率影响。若界面只需要显示最新值,也可以把 Channel 换成ConcurrentQueue<MachineSnapshot>加TryDequeue,或者干脆用一个字段只保存最新快照。区别在于:Channel 保留的最近 200 帧可以用于曲线回放,单字段只能得到当前值。
5. 西门子 S7 连不上、掉线、读数不对:把这几处当排查入口
S7 通信项目里,大部分问题不是 C# 语法,而是 PLC 配置和环境问题。我在现场见过最快解决的问题是防火墙拦截,最难发现的问题是 DB 开了优化访问。下面的排查顺序按优先级排过,照着走一遍比反复改代码效率高。
5.1 掉线先按顺序查这几处
| 排查顺序 | 检查位置 | 具体操作 |
|---|---|---|
| 1 | IP 与物理链路 | 先 ping 通 PLC,再用 PowerShell 测 102 端口 |
| 2 | Windows 防火墙 | 放行出站和入站的 TCP 102 |
| 3 | PUT/GET 开关 | TIA 属性 -> 保护与安全 -> 连接机制,勾选允许 PUT/GET |
| 4 | DB 优化访问 | 在 DB 属性中取消“优化的块访问”,重新下载 |
| 5 | Rack/Slot 参数 | 用 CpuType 对应型号,按默认值逐一试验 |
另外,把上位机装在虚拟机里跑时,虚拟网卡的网络模式不要选 NAT 或仅主机,改成桥接到物理网卡,否则经常出现 ping 得通但 Open 超时的情况。
5.2 Real 和字符串读出来全是乱码时处理字节序
如果读取结果不是明显报错,而是数值大小离谱,先怀疑字节序。西门子 S7 的数据在 CPU 里是大端,工业库在返回时大多做了转换,但当你用ReadBytes、拼接多字节变量或者直接写 byte[] 时,必须自己处理。最朴素的检查方法是打印原始字节:
byte[] raw = _plc.ReadBytes(DataType.DataBlock, 1, 4, 4); Console.WriteLine(BitConverter.ToString(raw));如果读的是 DB1.DBD4,期望得到的 float 是 22.5,但原始字节顺序不对,就按下面的方式反转:
if (BitConverter.IsLittleEndian) { Array.Reverse(raw); } float temp = BitConverter.ToSingle(raw, 0); Console.WriteLine(temp);注意ReadBytes的参数顺序是 DataType、DB 号、起始字节、长度,这里的DataType.DataBlock, 1, 4, 4表示读 DB1 从第 4 字节开始的 4 个字节,也就是 DBD4。把同一个反转逻辑封装成一个ToPlcFloat(byte[])方法,所有自定义报文都走这一处,就不会出现一半数据正常一半读成天文数字的情况。把字节序检查放在数据库记录之前,能省掉大量脏数据。
本文还有配套的精品资源,点击获取