简介:这是一份通过 C# 语言与西门子 S7 系列 PLC 进行通讯的完整实例源码,主要面向工业自动化开发者和需要在上位机与 PLC 之间建立数据通道的.NET 程序员。资源包共包含 39 个文件,以 C# 源码文件为主,另有解决方案工程文件、配置文件、可执行程序、窗体资源及图片等,整体压缩后仅 244KB,结构精简,适合直接编译和按需修改。这套源码目前已有 487 人学习,内容覆盖了 S7 通讯中的关键实现细节:基于 Sharp7 或 S7.Net 类库建立 TCP/IP 连接,配置 IP 地址、端口与 CPU 类型,完成输入输出、中间变量、数据块等存储区的地址映射,并通过读写方法进行变量操作,同时加入异常捕获与错误码检查,提升通讯稳定性。界面部分采用 WinForms 实现,演示了如何在后台线程中执行轮询更新,配合定时器持续读取 PLC 状态,或通过按钮触发写入命令,有效避免阻塞 UI。整体源码既有可直接运行的示例,也保留了清晰的工程组织方式,是学习 C# 与西门子 PLC 通讯流程、掌握工业通讯编程思路的实用参考。
1. 一台真实的注塑机,让我把S7通讯从玄学变成套路
三年前我被拉到车间现场,客户的注塑机是西门子S7-1200,要我三天内把温度、压力、射速实时读到上位机屏幕上。第一次搞S7通讯,对着网上的贴子试了整整一天,连PLC的影子都摸不到。后来才知道,S7协议的核心就两件事:选对连接参数,处理对字节顺序。这份《通过C#实现与西门子PLC的S7通讯实例源码》,把这两件事写成了可以照抄的代码——不需要你精通S7报文结构,不需要看懂ISO-on-TCP握手细节,按调用示例把IP和DB号填进去就能跑起来。适合刚做上位机开发、第一次对接西门子PLC的C#工程师;也适合做MES、数据采集系统的人用来做原型验证。它能解决的是:C#程序通过以太网直接读写S7-300、S7-1200、S7-1500的DB块、M区、I/Q区,不用买通讯模块,不用装Simatic Net,一对网线直连就够了。
2. 选S7通讯方案:为什么这个源码用的是snap7,而不是S7.net或OPC UA
2.1 先理清题目里的三种解法,快、稳、省只能选一个
做C#上位机和西门子PLC通讯,市面上常见的路线有三条:一是用西门子官方的SIMATIC NET、S7-200 PC Access或OPC UA服务器,走OPC DA/UA接口;二是用第三方商业库比如S7.net、Sharp7、Snap7;三是自己抓报文解析S7comm协议。我接过几个项目后,对这三条路线的感受是——官方方案最稳,但最烦的是授权和配置。OPC UA要配证书、防火墙规则、用户权限,客户现场IT一介入,来回扯皮的时间比写代码还多。自己写协议最省成本,但S7comm的PDU协商、TSAP握手、分帧传输,排错的坑多到能把人磨疯。
第三方库里边,S7.net功能全但商业授权要花钱,Sharp7多年不更新,Snap7是目前工控圈子用最多的:开源、无授权费用、C/C++原生库封装成C#类,Windows和Linux都能跑,S7-300、S7-400、S7-1200、S7-1500都支持。这份实例源码采用的就是Snap7的C#封装。它的设计思路很符合工控场景:连接只需要IP、机架号和槽号三个参数;读写操作只有ReadArea/WriteArea两类方法;异步、多客户端、PLC侧仿真都有对应API。我做过的三个项目都是用它落地的,稳定跑了两年没有崩过。
注意:Snap7走的是S7comm协议(ISO-on-TCP封装,TCP 102端口),所以它和西门子HMI、博途在线监控用的是同一条物理链路。如果PLC已经被WinCC或博途占满了连接资源,Snap7会连不上,这点后面避坑章节专门讲。
2.2 最小开发环境:一个控制台程序三分钟跑通连接
先把环境搭好。我用的是Visual Studio 2019/2022,.NET Framework 4.7.2或.NET 6都行;包里带的Snap7动态库分32位和64位,注意跟你编译出来的程序位数匹配,别混着放。用NuGet搜Snap7,或者直接引用包里的Snap7.dll和Snap7.Net.dll,后者是托管封装,前者是原生DLL。原生DLL一定要放到程序输出目录,或者放在和exe同级目录下,否则会报DllNotFoundException。
最省事的验证方式是用控制台程序:
using Snap7; class Program { static void Main(string[] args) { // 创建S7客户端实例,封装了原生DLL的调用 S7Client client = new S7Client(); // 注意参数:IP地址、机架号rack、槽号slot // S7-300默认rack=0, slot=2;S7-1200/1500默认rack=0, slot=1 int result = client.Connect("192.168.0.1", 0, 1); if (result == 0) { Console.WriteLine("连接成功"); client.Disconnect(); } else { Console.WriteLine("连接失败,错误码: " + client.ErrorText(result)); } } }这里两个参数是新手最容易翻车的地方:机架号和槽号。S7-300 CPU在机架0的2号槽,S7-1200和S7-1500通常都填机架0、槽号1。如果填错,连接会返回超时或TSAP错误。你不需要背下来,写一个配置项放在AppSettings里就行,现场排查时改起来最快。
2.3 理解Snap7的Area编号,才能看懂所有读写调用
Snap7把PLC的不同存储区抽象成Area常量,这也是源码里大量出现的东西。在C#封装里直接引用枚举:
public class S7Area { public const int PE = 0x81; // 过程映像输入区,对应I区 public const int PA = 0x82; // 过程映像输出区,对应Q区 public const int MK = 0x83; // 位存储区,对应M区 public const int DB = 0x84; // 数据块,对应DB块 public const int CT = 0x1C; // 计数器 public const int TM = 0x1D; // 定时器 }读写DB块时Area传DB(0x84),DB块号单独作为参数传,偏移地址(start)以字节为单位。读写M区时Area传MK(0x83),DB号填0,start就是M区的字节偏移。I/Q区的用法类似。这个映射关系就是Snap7的"黑匣子钥匙",后面每一个例子都绕不开它。
3. 把读写写成能直接用的封装:DB块、M区、I/Q区全打通
3.1 封装一个S7ClientHelper,把ReadArea和WriteArea包成业务方法
原生Snap7的调用接口很底层,读数据要自己准备byte[]缓冲区,通过out参数返回,用起来绕。我的习惯是先包一层Helper类,把"指定区域、偏移、长度读byte[]"和"按类型解析"分开,这样业务代码里就不会到处是byte数组操作。
using Snap7; public class S7ClientHelper : IDisposable { private S7Client _client; public bool IsConnected { get; private set; } // 连接:IP + 机架 + 槽号 public bool Connect(string ip, int rack = 0, int slot = 1) { _client = new S7Client(); int ret = _client.Connect(ip, rack, slot); IsConnected = (ret == 0); if (!IsConnected) throw new Exception("连接失败: " + _client.ErrorText(ret)); return IsConnected; } // 读原始字节:area=0x84是DB块,dbNumber=DB块号,start=字节偏移,size=读取长度 public byte[] ReadBytes(int area, int dbNumber, int start, int size) { if (!IsConnected) throw new Exception("未连接PLC"); byte[] buffer = new byte[size]; int ret = _client.ReadArea(area, dbNumber, start, size, buffer); if (ret != 0) throw new Exception("读取失败: " + _client.ErrorText(ret)); return buffer; } // 写原始字节,返回是否成功 public bool WriteBytes(int area, int dbNumber, int start, byte[] data) { if (!IsConnected) throw new Exception("未连接PLC"); int ret = _client.WriteArea(area, dbNumber, start, data.Length, data); return ret == 0; } public void Dispose() { if (_client != null) { _client.Disconnect(); _client.Dispose(); } } }逻辑说明:Connect只做三次握手,不校验PLC类型;ReadArea的buffer是输出参数,源码内部会把收到的字节填充进去,长度必须和size一致,否则会抛出缓冲区长度异常。WriteArea的data长度就是要写的字节数,start同样是字节偏移。这里把返回值0视为成功,Snap7里0代表精准返回的"没问题",非0就要用ErrorText转成人类能读懂的提示。
参数说明:读单个实数(REAL)时size填4,读整数(INT)填2,读字节(BYTE)填1。如果你读一个DB块里的连续10个REAL,size就填40,一次取回,不要循环读10次,效率差距很大。
3.2 读DB块实战:把D10.0的REAL温度和数组全读出来
假设PLC里DB1存了一组参数:偏移0.0处是REAL型温度,偏移4.0处是REAL型压力,偏移8.0处是INT型速度,偏移10.0处开始是10个连续的INT数组。用上面的Helper这样写:
using (var helper = new S7ClientHelper()) { helper.Connect("192.168.0.10", 0, 1); // 读温度(REAL),DB1.DBD0 byte[] tempBytes = helper.ReadBytes(0x84, 1, 0, 4); float temp = S7DataConverter.ToFloat(tempBytes); // 读压力(REAL),DB1.DBD4 byte[] pressBytes = helper.ReadBytes(0x84, 1, 4, 4); float press = S7DataConverter.ToFloat(pressBytes); // 读速度(INT),DB1.DBW8 byte[] speedBytes = helper.ReadBytes(0x84, 1, 8, 2); short speed = S7DataConverter.ToInt16(speedBytes); // 读速度数组:DB1.DBW10开始的10个INT,一次性读20个字节 byte[] arrayBytes = helper.ReadBytes(0x84, 1, 10, 20); for (int i = 0; i < 10; i++) { short val = S7DataConverter.ToInt16(arrayBytes, i * 2); Console.WriteLine($"速度[{i}] = {val}"); } }这里的S7DataConverter是我封装的解析类,核心作用就是处理字节序,下一章展开讲。偏移地址要跟TIA博途里的DB地址严格对应:REAL类型占4字节,DBD0、DBD4就是偏移0和4;INT占2字节,DBW8就是偏移8。千万别按C#里int占4字节的习惯去算偏移,PLC的INT就是16位短整型,这是刚接手时最常埋的雷。
3.3 写DB块和M区:把参数写进PLC,别把字节序写反
写入和读取是对称的操作,坑在高低字节。Snap7的写入接口不关心你传的字节数组是怎么来的,但PLC侧解释字节序按大端(高字节在前),如果直接用C#的BitConverter.GetBytes(int)生成小端字节序列写进去,数值会被解释成完全不同的数。
// 写DB1.DBD4为压力设定值 12.5 float targetPress = 12.5f; byte[] writeData = S7DataConverter.GetBytes(targetPress); // 内部做字节反转 helper.WriteBytes(0x84, 1, 4, writeData); // 写M20.0开始的字节(M区没有DB号,dbNumber传0) byte[] mData = new byte[] { 0x01, 0x00, 0x00, 0x00 }; helper.WriteBytes(0x83, 0, 20, mData);M区按字节偏移,M20.0到M23.7共32个位,对应偏移20开始的4个字节。如果你只想写M20.2这个位,Snap7没有专门的位写入方法,常规做法是:读回M20这个字节→把对应的位置1或清0→写回整个字节。这个"读-改-写"三个动作要保证连续性,最好用lock或队列串行,否则多线程下会互相覆盖。
4. 数据转换层:字节序、类型映射与偏移对齐
4.1 大端小端:为什么读出来的REAL是天文数字
S7协议在网络上传输时统一用大端字节序(高字节在前),而C#的BitConverter按小端解析(低字节在前)。直接拿BitConverter.ToSingle去解析Snap7读回来的4个字节,十有八九得到一个莫名其妙的数。这是S7通讯翻车率最高的问题,没有之一。
public static class S7DataConverter { // byte[4] -> float (REAL),Snap7读出来是大端 public static float ToFloat(byte[] bytes) { if (bytes == null || bytes.Length != 4) throw new ArgumentException("REAL类型必须4字节"); // 反转成小端后交给BitConverter Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); } // byte[2] -> short (INT) public static short ToInt16(byte[] bytes) { if (bytes == null || bytes.Length != 2) throw new ArgumentException("INT类型必须2字节"); Array.Reverse(bytes); return BitConverter.ToInt16(bytes, 0); } // float -> byte[4],写入前转成大端 public static byte[] GetBytes(float value) { byte[] bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); return bytes; } // short -> byte[2] public static byte[] GetBytes(short value) { byte[] bytes = BitConverter.GetBytes(value); Array.Reverse(bytes); return bytes; } // 从byte数组指定偏移取DINT(32位有符号整数) public static int ToInt32(byte[] bytes, int offset) { byte[] temp = new byte[4]; Array.Copy(bytes, offset, temp, 0, 4); Array.Reverse(temp); return BitConverter.ToInt32(temp, 0); } }参数说明:ToFloat用于解析DBD开头的REAL变量;ToInt16用于DBW开头的INT;ToInt32用于DBD开头的DINT。写入方向反过来,GetBytes内部先做小端转大端。注意Array.Reverse会修改原数组,如果你还要用原始字节数组做别的操作,先复制一份。
提示:布尔型BOOL不用转换,但要先找到所在字节的位置再判断对应的位。DBX0.7是DB1第0字节的最高位(bit7),DBX0.0是最低位(bit0)。
4.2 类型映射表:PLC侧符号和C#类型的对应关系
常在现场跑的数据类型就这几种,按这张表对号入座,基本不会错。
| PLC数据类型 | 符号 | 字节数 | C#类型 | 解析方法 | TIA偏移示例 |
|---|---|---|---|---|---|
| BOOL | DBX0.0 | 1位 | bool | 定位字节+位判断 | DB1.DBX0.0 |
| BYTE | DBB0 | 1 | byte | 直接取 | DB1.DBB0 |
| CHAR | DBB0 | 1 | char | 转ASCII | DB1.DBB0 |
| INT | DBW0 | 2 | short | ToInt16 | DB1.DBW0 |
| DINT | DBD0 | 4 | int | ToInt32 | DB1.DBD0 |
| REAL | DBD0 | 4 | float | ToFloat | DB1.DBD0 |
| WORD | DBW0 | 2 | ushort | ToUInt16 | DB1.DBW0 |
| DWORD | DBD0 | 4 | uint | ToUInt32 | DB1.DBD0 |
| STRING | DBB0起 | 见说明 | string | 手工解析 | DB1.DBB0 |
STRING类型的解析要单独说:S7的STRING占用256字节空间,第1字节声明最大长度,第2字节是当前实际长度,从第3字节开始才是字符内容。所以一个STRING变量从DBB10开始的完整格式是:DBB10=最大长度,DBB11=当前长度,DBB12开始是字符。解析时读256字节回来,取第11字节当长度,再截取后面的数据转成字符串。这里最容易犯的错是直接从DBB12读数据当成有效字符串,但长度没同步更新,导致显示乱码或末尾带一堆空格。
4.3 偏移对齐:BOOLean位变量会造成假象偏移
在TIA博途里声明DB变量,很多人喜欢顺手建一串BOOL。比如DB1.DBX0.0、DBX0.1、DBX0.7、然后下一个变量是DBW2。看起来偏移是连续的,但实际上BOOL类型在一个字节里按位排满,下一个字节偏移+1,再下一个变量如果选INT,会按2字节对齐,偏移可能变成DBW2或DBW4取决于前面BOOL用了几个字节。
我给初学者的建议是:DB块里别把BOOL和数值混排,一个DB块专门放数值,另一个DB块专门放BOOL、BYTE和状态字。混排也不是不能读,但你得在TIA里逐个看偏移再去代码里算,后期改一个变量声明,所有偏移全部错位,排查成本成倍翻。如果DB块被设了"优化的块访问",S7协议直接读不进去,这个问题放到避坑章节细说。
5. 调试现场:六个把S7通讯搞挂的常见问题与排查
5.1 背包现场复盘,这六个坑我全踩过
下面的每条都是我或同事在现场真实遇到过的,按"现象→原因→解决"的方式写,方便你直接对照。
坑一:连接超时,ping得通但连不上。现象:同一网段,电脑能ping通PLC的IP,但Snap7的Connect返回超时。 原因:S7-1200/1500在TIA博途里默认禁止了外部PUT/GET访问。TIA博途打开PLC属性→保护与安全→连接机制,必须勾选"允许来自远程对象的PUT/GET通信访问",下载到PLC后重启。S7-300一般没这个问题,但S7-1500和S7-1200拿到手第一件事就是去勾这个选项。 解决:勾选后重新下载硬件组态,断电重启PLC。注意博途中每次修改连接机制都要重新编译并下载,只下载程序不下载硬件配置不会生效。
坑二:连接S7-300能通,换成S7-1500就连不上。现象:同一套代码,PLC是300时正常,换成1500后返回错误码0066(模块不可达)。 原因:机架号槽号不对,或者S7-1500的连接资源被TIA博途/WinCC占了。S7-1500默认slot=1,和1200一样;S7-300 CP卡配置不同,可能是slot=2或slot=3。连接资源方面,S7-1500最多支持几十条PUT/GET连接,但如果博途在线监控开着、HMI挂着,剩余资源可能不够。 解决:先停掉TIA在线监控,再看PLC属性里的连接资源占用。代码里把rack/slot改成配置项,现场能快速试。
坑三:读DB1的DBD0返回0x8404(DB不存在的错误)。现象:DB1在TIA里明明存在,程序读DB1偏移0,返回错误"DB不存在"或"数据块不可用"。 原因:DB块属性勾选了"优化的块访问"。S7-1500新建DB默认就是优化访问,这种DB不按物理偏移布局,S7协议里的DB号访问方式被屏蔽。这个是最迷惑人的坑,因为博途里变量名正常、在线监控也正常,但外部S7的ReadArea就是访问不了。 解决:在DB属性→Attributes→取消勾选"Optimized block access",重新下载。取消后TIA里能看到变量的物理偏移地址,以物理偏移为准。
坑四:REAL值读出来是NaN或一个极大的数。现象:能连通、能读取、返回成功,但数值不对,比如温度读出-107374176.0。 原因:字节序没处理。Snap7返回的是大端字节序,直接用BitConverter.ToSingle解析出来就是错的。这也是为什么我强烈建议所有读写都走统一的数据转换层。 解决:用S7DataConverter.ToFloat解析,写入时用GetBytes(float)先转换。
坑五:程序偶尔崩溃,异常在DllNotFoundException。现象:开发机跑正常,拷到现场工控机上运行后崩溃,启动时报找不到Snap7.dll。 原因:Snap7的原生DLL是动态加载的,NuGet包的native DLL可能没有自动复制到输出目录。工控机上没装VC++运行库也会出现类似症状。 解决:把Snap7.dll(原生库)和Snap7.Net.dll(托管封装)都复制到exe同目录,位数必须一致。如果系统缺VC++运行库,装上对应版本的Microsoft Visual C++ Redistributable。这个坑不要在客户现场踩,去之前先检查输出目录的DLL文件。
坑六:读数组速度慢,一次读一个值卡顿。现象:100个REAL循环读取,每次ReadArea读4字节,整个界面卡死。 原因:ReadArea一次交互要走完TCP请求-响应全流程,循环100次就是100个RTT。PLC侧S7协议还限制单次PDU大小,但小数据多次读的损耗主要在网络往返。 解决:一次ReadArea读整段连续数据。比如100个REAL就是400字节,一次读回来,在C#里按偏移解析成数组。Snap7单次能读8192字节(S7-1500),远够用。这里注意:DB里数据不连续,比如两个变量中间有间隔,就多读一点把间隔带回来,解析时跳过。
5.2 排查顺序:从物理层到应用层,别乱查
现场出了问题,我现在的排查顺序固定是这样,连环排查省时间。第一步看物理链路,ping IP,把防火墙临时关掉测试(Win10/工控机自带的防火墙经常拦TCP 102端口)。第二步看PLC侧配置,去TIA里确认PUT/GET勾选、DB是否优化块访问、连接资源占用。第三步看代码参数,机架槽号、DB号、偏移、长度四项逐一核对。第四步看数据类型,特别是字节序和偏移对齐。这四步按次序走,80%的问题五分钟内定位。
注意:Snap7的错误码在客户端状态下可以通过client.ErrorText(errorCode)转成描述,但错误码返回的是数字,一定要配合ErrorText看,直接查十六进制码值容易看走眼。
6. 验证通讯并养成习惯:一个三分钟连通性自检脚本
6.1 把"能不能连、读出来对不对"变成自动化检查
我刚做S7项目时总在现场跟PLC软硬件工程师扯皮,他怪我的程序连不上,我怪他的PLC没配置对。后来我总结出一个习惯,写一个小工具验证三层问题,把它固化到正式程序启动流程里:连通性、读取正确性、写入回读一致性。这样再出问题,先自检再喊人,省了大把扯皮时间。
static void SelfCheck(S7ClientHelper helper, string ip, int rack, int slot) { // 第一步:TCP 102端口连通性检查 using (var tcp = new System.Net.Sockets.TcpClient()) { tcp.Connect(ip, 102); // PLC的S7通讯固定端口是102 Console.WriteLine("TCP 102端口可用"); } // 第二步:S7握手,确认rack/slot配置正确 helper.Connect(ip, rack, slot); Console.WriteLine("S7握手成功"); // 第三步:用一个已知变量读写回读验证(比如DB1.DBD0写一个测试值再读) float testVal = 123.456f; byte[] writeData = S7DataConverter.GetBytes(testVal); helper.WriteBytes(0x84, 1, 0, writeData); byte[] readData = helper.ReadBytes(0x84, 1, 0, 4); float readBack = S7DataConverter.ToFloat(readData); if (Math.Abs(testVal - readBack) < 0.001f) Console.WriteLine("读写回读验证通过"); else Console.WriteLine($"读写回读不一致, 写入{testVal}, 读回{readBack}"); }这段代码的作用是把"方案对不对"从玄学变成可量化的事。注意第三个步骤会向PLC写入一个测试值,运行前确认DB1.DBD0不是生产中的重要参数,或者用专门的测试DB块。TCP 102的连通性检查可以在连接S7前抢跑,如果这一关就失败,不用再去看Snap7的错误码,一定是网通不通、防火墙没放行或PLC没上电。
6.2 从单次读到批量轮询:把现场数据用起来
连接和转换打通以后,下一步就是上生产:定时刷新数据。我一般用System.Threading.Timer做周期轮询,150ms一个周期读一次大块数据,事件回调里再触发UI刷新,这样界面流畅度明显好于300ms循环里再读4字节的小粒度方式。代码结构是把DB1里所有要监控的REAL、INT、BOOL按偏移整理成一个结构体,一次ReadArea读回一整个byte[],再用S7DataConverter逐一解析填充,这样轮询开销被压到很小。如果要写记录、报警,就把时间戳和结构体一起放进队列,由后台线程统一落库,避免UI线程被IO卡住。
从那以后我每次接S7项目,都强制自己先写这个三分钟自检脚本,再动业务代码——它能在一分钟内把通讯层的锅从业务层里摘出去,让后续开发不带着糊涂账往前走。希望帮到你。
本文还有配套的精品资源,点击获取