简介:面向需要实现上位机与西门子PLC数据交互的C#开发者,这份例程源码基于S7netplus库,完整演示了连接S7系列PLC、读取与写入BOOL/INT/DWORD等数据类型,以及异常处理、断开连接等关键流程。资源内同时提供“读写PLC数据”和“S71500”两个独立示例工程,代码结构清晰,初学者可快速掌握通信步骤,有经验的工程师也能将其作为可复用的通信模板。包体共93个文件,压缩包大小为762KB,以.cs源文件、.config配置文件、.dll依赖库及编译生成的.exe可执行程序为主,同时包含项目解决方案和配置信息,解压后可直接使用Visual Studio打开运行。目前已有1688人学习下载,源码中对PLC地址区域、偏移量和连接参数均有明确演示,并可据此扩展循环读写、多线程监控等实际工业场景功能。
1. C#读写西门子PLC数据:例程源码之外,先解决“为什么连不上”
C#要读写西门子PLC,网上能搜到的例程源码少说有几十份,但真正能跑通的没几个。绝大多数例子卡在同一个位置:不是读写API不会调,而是连接参数、DB偏移和数据类型映射这三件事没对齐。这篇文章用一个完整的读写链路把这些补齐——用S7.NET建立连接,读DB块里的Bool、Real和字符串,写回参数,再把循环采集、UI刷新和断线恢复一起处理掉。适合要做上位机、MES数据采集或者设备联调的人,也适合刚看完西门子PLC编程入门教程、准备动手写第一版通信程序的人。读完你会得到一个可以直接改着用的最小框架,而不是一份看着能跑、一运行就超时的代码。
2. 用S7.NET建立C#与西门子PLC的最小连接:从选库到Open
2.1 为什么绕开OPC UA直连S7协议
很多第一次做上位机的人,第一反应是上OPC UA。OPC UA适合多客户端、跨平台、带复杂信息模型的场景,但如果你只是读写几十个点,引入OPC Server反而多了一层部署和维护成本。西门子PLC的以太网口原生支持S7协议,TCP端口102,报文是明文,C#侧解析起来并不难,直接连是最轻量的一条路。
C#生态里常见的库有两个:S7.NET和Sharp7。S7.NET用字符串地址,比如“DB1.DBD0”,写起来直观,适合中小规模应用;Sharp7更偏底层,API风格接近原生C库,适合大批量读写和Unity这类特殊宿主环境。如果你的目标设备是S7-200 SMART,S7.NET是连不上的,因为200 SMART不支持S7以太网直连,常见做法是走Modbus TCP,或者用PC Access做网关。选型前先确认CPU型号,这一步错了后面全是白搭。
如果你的总线设备是ABB变频器这类从站,通常不需要C#直接和变频器通信。实际工程里常见的拓扑是:C#写PLC的DB块,PLC通过PROFINET或PROFIBUS把数据映射给变频器。所以C#侧的连接目标始终是PLC,不是现场总线上的其他设备。
2.2 安装S7.NET并写出最小连接代码
在Visual Studio里打开“管理NuGet包”,搜索S7.Net安装;命令行环境下直接执行:
dotnet add package S7.Net装完后写一个最基础的连接类:
using S7.Net; class PlcConnector { private Plc _plc; public void Connect() { // CpuType 要与实际 CPU 型号匹配,S71200/S71500/S7300 等 _plc = new Plc(CpuType.S71200, "192.168.0.1", 0, 1); _plc.Open(); if (_plc.IsConnected) Console.WriteLine("S7 连接已建立"); else Console.WriteLine("连接失败: " + _plc.LastErrorCode); } public void Disconnect() => _plc?.Close(); }构造函数里三个关键参数:IP地址、Rack(机架号)、Slot(插槽号)。CpuType决定库内部怎么组装TSAP握手信息,如果你传的型号和实际CPU不一致,Open很可能超时。S7.NET内部已经帮你处理了ISO-on-TCP的握手细节,这也是我建议直接用库、而不是自己拿Socket去拼TPKT报文的原因——自己写要维护的东西远比你想象的多。
2.3 Rack、Slot与PLC侧设置:Open失败先查这三个
Open失败时,先别怀疑代码,按表格里的顺序检查:
| CPU系列 | 常见Rack | 常见Slot | 备注 |
|---|---|---|---|
| S7-1200 | 0 | 1 | 默认优化块访问建议关闭 |
| S7-1500 | 0 | 1 | DB默认优化访问,偏移读取会失败 |
| S7-300 | 0 | 2 | 部分机架型号有差异 |
| S7-400 | 0 | 3 | 多机架按实际组态 |
还有一个几乎所有人都踩过的步骤:在TIA Portal里打开CPU属性,找到“防护与安全”下的“连接机制”,勾选“允许来自远程对象的PUT/GET通信访问”。不勾这一项,S7.NET的表现不是报“拒绝访问”,而是连接直接超时或建立后立刻被重置,很容易误判成网络问题。
提示:上位机IP和PLC IP必须在同一网段,跨网段访问需要提前配置路由,这不在S7协议的责任范围内。
3. C#读西门子PLC数据:DB偏移、Bool位与字符串的拆装
3.1 先分清“字节偏移”和“位偏移”
S7协议里的地址是偏移量,不是符号名。DB1.DBB0表示DB1的第0字节;DB1.DBW2表示从第2字节开始的16位数据;DB1.DBD4表示从第4字节开始的32位数据。如果你在TIA Portal里新建了一个变量叫“温度”,偏移显示是4,那么C#侧读它的地址就是DB1.DBD4。
Bool的地址稍微特殊:DBX0.3表示第0字节的第3位,也就是二进制里的0000_1000。这个“第3位”是从低位数还是从高位数?西门子习惯从低位开始编号,所以DBX0.3对应的是1 << 3。后面写位操作时,这个约定必须和PLC侧一致,否则读出来的状态永远是反的。
| S7符号地址 | 数据类型 | 说明 |
|---|---|---|
| DB1.DBD4 | Real | 第4字节开始的32位浮点数 |
| DB1.DBW2 | Int | 第2字节开始的16位有符号整数 |
| DB1.DBX0.3 | Bool | 第0字节的第3位 |
| DB1.DBB10 | Byte | 第10字节开始的单字节 |
字节序也要注意:西门子是大端,C#的BitConverter在绝大多数机器上是小端。S7.NET的字符串地址读写方法内部已经处理了大端转换,所以用Read/Write字符串地址时不用操心;但一旦改用ReadBytes自己拼数据,就必须手动反转字节序。
3.2 用Read读取Real、Int与Bool的最小代码
public void ReadDemo(Plc plc) { // 字符串寻址方式,返回 object,运行时类型由PLC侧变量类型决定 object rawReal = plc.Read("DB1.DBD4"); if (rawReal is float realValue) Console.WriteLine($"DB1.DBD4 -> {realValue}"); object rawInt = plc.Read("DB1.DBW2"); if (rawInt is short intValue) Console.WriteLine($"DB1.DBW2 -> {intValue}"); // Bool 用 ReadBytes 拼出来,避免库版本之间返回类型不一致 byte[] data = plc.ReadBytes(DataType.DataBlock, 1, 0, 1); bool bitValue = (data[0] & 0x08) != 0; Console.WriteLine($"DB1.DBX0.3 -> {bitValue}"); }第一个参数是DataType.DataBlock,第二个是DB号,第三个是起始字节偏移,第四个是读取长度。ReadBytes返回的字节数组里,第0字节就是DB1.DBB0的内容,按位与0x08取出第3位。为什么不直接读“DB1.DBX0.3”?因为不同版本的S7.NET对Bit类型的返回形式不完全一致,自己拼字节反而最稳定。
3.3 批量读取用ReadBytes,性能高一个数量级
逐个调用Read会每次发起一次S7请求,每请求都要做地址解析和报文封装。点位数超过几十个时,循环里这样写会很慢。常见的做法是连续区域一次读回来:
public void BatchRead(Plc plc) { // 从 DB1 第 0 字节开始,连续读 32 个字节 byte[] buffer = plc.ReadBytes(DataType.DataBlock, 1, 0, 32); // 取前4字节,手动换成小端再转 float Span<byte> span = stackalloc byte[4]; buffer.AsSpan(0, 4).CopyTo(span); span.Reverse(); float temperature = BitConverter.ToSingle(span); // 第4字节开始的16位整数,同样反转字节序 Span<byte> intSpan = stackalloc byte[2]; buffer.AsSpan(4, 2).CopyTo(intSpan); intSpan.Reverse(); short speed = BitConverter.ToInt16(intSpan); }一次ReadBytes对应一次S7请求,读32个字节和读1个字节的网络开销差别很小。只要PLC侧的数据在DB里排布足够紧凑,批量读永远是最优解。反过来,如果点位分散在不同DB甚至不同区域,强行凑成一段连续读取反而会把无关数据一起搬回来,可读性也会变差,这时折中方案是分区域读。
3.4 扫码枪触发采集:条码解析与DB偏移换算
超市储藏环境自动控制系统这类项目里,上位机要循环读温度、湿度、设备状态,用100毫秒周期轮询完全够用。但如果你的采集动作是由扫码枪触发的——扫到条码立刻读一次对应数据——就不需要高频轮询,而是监听串口事件。
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { string code = _serialPort.ReadExisting(); code = code.TrimEnd('\r', '\n'); if (string.IsNullOrEmpty(code)) return; // 条码前两位映射为 DB 号,后两位十六进制作为起始字节偏移 int db = int.Parse(code.Substring(0, 2)); int offset = int.Parse(code.Substring(2), NumberStyles.HexNumber); object raw = _plc.Read($"DB{db}.DBD{offset}"); Console.WriteLine($"条码 {code} 对应值: {raw}"); }这里Substring的用法就是C#截取字符串最常见的场景:先掐头去尾拿到纯条码内容,再按固定位切开映射到DB地址。扫码枪触发事件的频率远低于定时轮询,对PLC的通信负载压力小很多。需要注意的是,如果PLC侧DB启用了优化块访问,这种按偏移读的方式会直接失败,下一章写数据时同样受这个限制。
4. C#写西门子PLC数据:Write、批量写与Bool的读写特例
4.1 Write指令与它对应的类型映射表
写入比读取更依赖类型精确匹配。PLC里的Int是16位有符号整数,C#侧要写short;Real对应float;DInt对应int;LReal对应double。类型写错时S7.NET多半不会编译报错,而是运行时抛出异常,因为底层要按VarType去编码字节。
| PLC类型 | C#类型 | 示例地址 | 说明 |
|---|---|---|---|
| Real | float | DB2.DBD0 | 32位浮点 |
| Int | short | DB2.DBW4 | 16位有符号整数 |
| DInt | int | DB2.DBD6 | 32位有符号整数 |
| Bool | bool | DB2.DBX10.1 | 按位写入 |
| String | byte[] | DB2.DBB20 | 需自己拼长度头 |
public void WriteDemo(Plc plc, float setpoint, short speed) { // 写入设定值到 DB2.DBD0,写入速度到 DB2.DBW4 plc.Write("DB2.DBD0", setpoint); plc.Write("DB2.DBW4", speed); // 写完后立刻读回来做回读校验 object verify = plc.Read("DB2.DBD0"); Console.WriteLine($"回读验证: {verify}"); }写完后立刻回读是上位机联调时的好习惯,能及时发现PLC侧没有启用该DB、偏移写错或类型不匹配。比如把设定转速写到DB2.DBD0,PLC再通过PROFINET把值映射给ABB变频器的控制字,这个链路里C#只负责写DB,总线的数据交换由PLC完成,不要把通信对象搞混。
4.2 写Bool前先读整字节:避免同字节其他位被改
西门子的Bool在DB中按位存储。如果两个不同的C#线程分别写同一个字节的不同位,都执行“读整字节→改位→写整字节”,就可能出现后写的一方覆盖掉前写的一方。稳妥做法是手动做读-改-写,并加锁保护:
private readonly object _bitLock = new object(); public void WriteBit(Plc plc, int db, int byteOffset, int bitIndex, bool value) { lock (_bitLock) { // 读回整个字节 byte[] data = plc.ReadBytes(DataType.DataBlock, db, byteOffset, 1); byte b = data[0]; if (value) b = (byte)(b | (1 << bitIndex)); else b = (byte)(b & ~(1 << bitIndex)); // 写回整个字节 plc.WriteBytes(DataType.DataBlock, db, byteOffset, new[] { b }); } }bitIndex取值范围0到7,对应DBX地址里的最后一位数字。这个写法牺牲了一点性能,但保证了同一个字节上的多个位不会被互相覆盖。锁加在方法内部,调用方不需要关心并发问题。
4.3 批量写DataItem:一次请求下装多个参数
启动设备时经常要一次写入几十个参数,逐个Write会产生几十次S7请求,慢而且容易触发PLC通信负载过高。S7.NET提供了DataItem批量接口:
var items = new[] { new DataItem { DataType = DataType.DataBlock, DB = 2, StartByteAdr = 0, VarType = VarType.Real, Value = 26.5f }, new DataItem { DataType = DataType.DataBlock, DB = 2, StartByteAdr = 4, VarType = VarType.Int, Value = (short)1200 } }; plc.WriteMultipleVars(items);DataItem里的Value是object类型,库在内部会把C#值转成S7协议需要的大端字节序。WriteMultipleVars对应一次S7请求,几十个参数的写入耗时能从秒级降到几十毫秒级别。注意这个方法内部不会做回读,写入失败时它会抛异常,所以调用方要做好try-catch并记录是哪一批参数写入失败。
4.4 写S7 String要自己拼长度头
直接调用plc.Write("DB2.DBB20", "ABC")在部分固件版本里只能写入字符数据,不会更新S7 String结构里的长度字节。S7 String在内存里的布局是:第1字节是最大长度,第2字节是当前长度,从第3字节开始才是字符内容。正确写法:
public void WriteS7String(Plc plc, int db, int byteOffset, string text, int maxLen = 64) { byte[] bytes = Encoding.ASCII.GetBytes(text); if (bytes.Length > maxLen) throw new ArgumentException($"字符串长度超过 {maxLen}"); // 第0字节放最大长度,第1字节放当前长度,之后是数据 byte[] buf = new byte[maxLen + 2]; buf[0] = (byte)maxLen; buf[1] = (byte)bytes.Length; Array.Copy(bytes, 0, buf, 2, bytes.Length); plc.WriteBytes(DataType.DataBlock, db, byteOffset, buf); }如果你的PLC变量是WString而不是String,编码方式从ASCII换成Unicode,但西门子的WString长度头和String不一样,是4字节长度区。写字符串前先确认TIA Portal里变量类型,否则中文内容很容易出现乱码或错位。
注意:S7-1500新建DB块默认开启“优化的块访问”,开启后DB地址没有固定偏移,S7.NET按DB号和偏移读写会失败。联调前先在TIA里取消勾选并重新编译。
5. 循环采集、UI刷新卡顿与8180错误:并发读写的三个坎
5.1 为什么需要“采集循环 + 断线重连”而不只是读写
把上一章的API直接塞进WinForms的按钮事件里,界面会卡得没法看。PLC读取一次虽然只要几十毫秒,但UI线程画控件同时也在等网络,两者互相阻塞。这就是经典的“c# 循环数据采集和UI刷新卡顿”问题。常见做法是把采集放到后台任务,UI只负责订阅结果:
public class PlcMonitor : IDisposable { private readonly Plc _plc; private readonly CancellationTokenSource _cts = new(); public event Action<float> OnTemperature; public PlcMonitor(Plc plc) => _plc = plc; public void Start() { Task.Run(async () => { while (!_cts.Token.IsCancellationRequested) { try { // 断线时自动重连,PLC重启后能自己恢复 if (!_plc.IsConnected) { _plc.Open(); await Task.Delay(200, _cts.Token); } float temp = (float)_plc.Read("DB1.DBD4"); OnTemperature?.Invoke(temp); } catch (Exception ex) { Console.WriteLine($"采集异常: {ex.Message}"); } await Task.Delay(100, _cts.Token); } }); } public void Dispose() => _cts.Cancel(); }调用方用BeginInvoke把数据推到UI。100毫秒一次对UI来说频率偏高,实际项目建议采集周期设在200到500毫秒,人眼对温度的感知根本不需要那么快;如果是高速设备,就该考虑把数据丢进队列,UI定时器按自己的节拍去取,而不是每个数据都刷一次控件。
5.2 8180错误与连接恢复处理套路
8180这个错误码经常出现在西门子通信相关的讨论里,很多人把它当成一个固定的API异常编号,其实它更像一个“连接被底层重置”的代号。在C#侧,它可能表现为SocketException,也可能被S7.NET包装成“Unable to connect”。无论叫什么,排查顺序是固定的:
| 现象 | 优先检查 |
|---|---|
| Open超时 | 网段、防火墙、PLC是否允许PUT/GET |
| 连接建立后第一次Read报错 | DB是否开启优化块访问、偏移地址是否正确 |
| 运行一段时间后断开 | 长时间无通信被PLC关闭,需要周期读写心跳 |
针对运行期断开,写一个带重试的Open方法:
public void OpenWithRetry(Plc plc, int retryCount = 3) { for (int i = 0; i < retryCount; i++) { try { plc.Open(); if (plc.IsConnected) return; } catch (Exception ex) { Console.WriteLine($"第 {i + 1} 次连接失败: {ex.Message}"); } Thread.Sleep(1000); } throw new TimeoutException("S7 连接重试耗尽"); }重试之间等1秒,给PLC留出重启时间。如果循环采集中发现IsConnected为false,直接调用OpenWithRetry,比每次读前都尝试Open要节省大量无效握手。注意S7协议本身没有心跳机制,长时间不通信,PLC侧的连接资源会被回收,所以正常采集循环本身就是一种心跳,反而是那种“只在用户点按钮时才读写”的程序最容易掉线。
5.3 Unity与Sharp7:换引擎时的读写边界
如果项目从WinForms迁移到Unity做三维数字孪生,S7.NET在IL2CPP环境下容易出兼容问题,工程圈常见的替换方案是Sharp7。Sharp7的API风格更接近原生库,比如S7Client.ReadArea,需要你自己管理字节数组和字节序。建议在业务层抽一个IDataAccess接口,里面只暴露ReadReal、WriteReal这样带语义的方法,底层用S7.NET还是Sharp7实现,上层不关心。这样换渲染引擎或者换库时,只替换实现类,不需要重写业务逻辑。
6. 抓包验证S7读写结果,以及四个容易写歪的地方
6.1 用Wireshark确认读写真的到了PLC
排查“我写了但PLC没反应”时,Wireshark比任何日志都可靠。选择连接PLC的网卡,捕获过滤器填tcp.port == 102,然后依次执行连接、读一次、写一次三个动作。正常能看到三条S7 Communication报文:第一条是Job,第二步是Read Var请求和响应,第三步是Write Var请求。如果只能看到TCP握手但看不到S7协议内容,说明PLC侧没有正确响应S7请求,问题大概率在PUT/GET设置或CPU型号配置上。
6.2 对照TIA变量表做一次“对拍”验证
在TIA Portal里建一个监控表,把DB地址、类型、当前值列出来,C#程序读写后两边对照。Bool要写“1”再写“0”各验证一次;Real写一个带小数的值,比如25.36,防止整数被隐式截断。TIA里显示正确而C#显示错误,查字节序;C#正确而TIA错误,基本是偏移地址差了一个字节或两个字节,对照TIA的“偏移”列逐项核对。
6.3 四个容易写歪的地方
- 用int存S7 Int:C#的int是32位,S7的Int是16位,正确用short。
- 字符串地址丢掉DB前缀:S7.NET要求完整的“DB1.DBD4”格式,漏掉DB前缀会得到“1.DBD4”,这是运行时才暴露的错误。
- 忽略新建DB时的优化块访问勾选框:1500的DB默认开启优化,开启后S7.NET按偏移读写失败,而且TIA里看不到偏移量,只能在DB属性里关掉重新编译。
- 只在启动时Open一次:PLC断电重启后,旧连接已经失效,采集循环里每次读前判断IsConnected并自动重连,比在界面加一个“重连按钮”可靠得多。
调试时把Open、Read、Write三个动作分别计时打印,十有八九能定位瓶颈在哪一步。
本文还有配套的精品资源,点击获取