简介:针对C#开发者与工业自动化工程师,该压缩包提供一份完整工程示例,演示通过ModbusTCP协议与西门子S7-1200 PLC进行数据读写;项目基于Socket手动封装Modbus请求,覆盖读线圈、读离散输入、读保持寄存器及写单个/多个线圈和寄存器等常用功能码,核心代码集中在ModbusTcpTest项目中,可在Visual Studio中直接编译运行。压缩包共46个文件,主要为C#源文件、解决方案与工程配置、XML配置、resources及resx资源文件,另有zip与exe辅助工具、pdb调试符号、txt说明及少量缓存文件,总大小约13.02MB,已有4313人学习下载。资源内含基于Socket的ModbusTCP通讯实现、VS工程配置及PLC测试相关文件,有助于理解TCP报文组装、地址映射和异常处理,适合自动化项目二次开发或协议学习。
1. 先解决一个尴尬:C# 读西门子 S7-1200,我劝你先走 ModbusTcp
做上位机采集的兄弟大概率经历过这个场景:产线上摆着一台西门子 S7-1200 PLC,要用 C# 走 ModbusTcp 把电流、温度、产量这些数据读上来。你的第一直觉是上 S7 原生协议,结果要么库的版本兼容性看运气,要么 PLC 侧权限被安全策略挡死,两三天都在连不上。这个项目换了一条更稳的路:C# 自己实现 ModbusTcp 客户端,PLC 侧只需调用官方标准指令 MB_SERVER,几百行代码就能稳定跑起来。它适合做设备数据采集、MES 对接、SCADA 上位机的开发者,也适合想彻底搞懂 ModbusTcp 报文结构、不再依赖第三方库黑匣子的人。
2. 项目定位与技术选型:ModbusTcp 在 S7-1200 上的落地姿势
2.1 为什么选 ModbusTcp 而不是 S7 原生协议
先看 S7 原生协议。它功能强大,能直接读写 DB 块、M 区、I/O 映像区,甚至能触发 PUT/GET 远程通讯,这也是很多第三方库(比如常见的 s7netplus)默认走的路。但它的短板在工程现场非常明显:
- 协议没有公开文档,第三方库基本都是逆向实现的,PLC 固件一升级、或者库的版本换一换,行为就变了,纯属黑匣子;
- PLC 侧需要开放 PUT/GET 通讯权限,很多现场出于安全考虑直接关闭,你连握手都做不了;
- S7 协议的 TCP 层有 PDU 分包、序号协商,自己实现容易翻车,抓包都难定位问题。
反过来看 ModbusTcp 的优势。协议完全公开,报文结构固定到字节级,一个 MBAP 头加一段 PDU,抓包调试非常舒服;PLC 侧只需要调用西门子官方标准指令 MB_SERVER,不需要额外的授权或者特殊配置;C# 侧自己构造请求帧、解析响应帧,完全可控,不依赖第三方库的版本运气;端口固定走 502,现场防火墙规则、交换机端口配置都好跟网络管理员沟通。
选 ModbusTcp 也要付出代价。它不能像 S7 协议那样直接访问任意 DB 地址,必须在 PLC 侧把要采集的数据映射到保持寄存器区或者线圈区,这个工作在 TIA Portal 里做;它的数据类型表达弱,32 位浮点要占两个保持寄存器,字符串要按字切分;它没有主动上报机制,上位机只能按固定周期轮询。
选型结论很务实:当数据量不大、采集周期在百毫秒以上、PLC 侧数据已经整理到固定数据区的时候,ModbusTcp 是性价比最高的方案。这个项目就是按这个思路组织的——C# 侧手写协议层,把对第三方库的依赖降到零。
2.2 整体通信链路与工程结构
整套系统的通信链路就是一条直线:C# 上位机程序通过 TCP/IP 走 502 端口把 ModbusTcp 报文发给 S7-1200 CPU 里的 MB_SERVER 指令块,MB_SERVER 解析收到的请求帧后,按照功能码和偏移地址直接读写 PLC 数据区里映射好的保持寄存器数组和线圈数组,再组织响应帧返回给上位机。
C# 侧工程按「协议底层 → 业务封装 → 配置驱动」三层来拆,常见的目录结构长这样:
ModbusTcpDemo/ ├── ModbusTcpClient.cs # TCP 长连接管理、收发与超时控制 ├── ModbusMessage.cs # MBAP 头构造、请求帧/响应帧的编解码 ├── PlcDataService.cs # 上层业务封装:读浮点、读开关、写控制字 ├── app.config # PLC IP、端口、点位地址映射表 └── Program.cs # 演示入口:轮询采集并打印结果这个结构的核心思想是:把 ModbusTcp 协议细节锁死在底层两个类里,上层 PlcDataService 只面向「读哪个位置、转换成什么类型」这种业务语言。后面要换 PLC、换协议,或者加点位,都不用动协议层。这个分层思路直接决定了二次开发的成本。
演示程序 Program.cs 的流程也简单:启动后读取 app.config 里的 PLC IP 和端口,连接成功后按点位表轮询——读一组保持寄存器、解析成浮点数打印,再读几个线圈状态,然后按固定周期循环。跑通这段流程,你基本就掌握了这个项目的所有核心逻辑。
3. 核心模块拆解:C# 侧 ModbusTcp 客户端的完整实现
3.1 连接管理:TCP 长连接与超时处理
ModbusTcp 是长连接协议,工程里最常见的错误是每次采集都 new 一个 TcpClient 去 connect,读完了就断开。S7-1200 同时允许的活动 TCP 连接数有限,频繁重连会导致 PLC 侧连接表被占满,后面新连接全失败。正确做法是持有一个 TcpClient 实例,配合 NetworkStream 同步读写,给读写都加上超时。
public class ModbusTcpClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private ushort _transactionId = 0; // 事务ID,每帧自增 private readonly object _lockObj = new object(); // 保证多线程下帧不交错 /// <summary> /// 建立与 PLC 的 TCP 长连接 /// </summary> public void Connect(string ip, int port = 502, int timeoutMs = 3000) { _tcpClient = new TcpClient(); IAsyncResult result = _tcpClient.BeginConnect(ip, port, null, null); if (!result.AsyncWaitHandle.WaitOne(timeoutMs)) { throw new TimeoutException($"连接 PLC {ip}:{port} 超时"); } _tcpClient.EndConnect(result); _stream = _tcpClient.GetStream(); _stream.ReadTimeout = timeoutMs; _stream.WriteTimeout = timeoutMs; } public void Dispose() { _stream?.Close(); _tcpClient?.Close(); } }逻辑说明:BeginConnect 配 WaitOne 是为了实现连接超时,直接用 TcpClient.Connect 在目标 IP 不可达时会阻塞十几秒甚至更久,采集程序会被直接拖死。ReadTimeout 和 WriteTimeout 是第二道保险,PLC 停机或者网线松动时不至于让读操作无限挂起。
参数说明:port 默认 502,这是 ModbusTcp 的标准端口;timeoutMs 我一般会分场景设置,同网段设备给 3000 毫秒,跨交换机走三层网络给 5000 毫秒。另外整个类要保证线程安全,我用 lock 把发包收包串行化,避免多个采集线程同时写流导致帧数据交错。
3.2 报文构造与解析:MBAP 头加功能码
ModbusTcp 的请求帧结构分为两部分:7 字节的 MBAP 头,加上功能码和数据区。MBAP 头包括事务 ID(2 字节)、协议 ID(2 字节,Modbus 固定为 0)、长度(2 字节,表示后续字节数)、单元 ID(1 字节,一般填 1)。
/// <summary> /// 构造读保持寄存器请求帧(功能码 0x03) /// </summary> public byte[] BuildReadHoldingRegistersRequest(ushort startAddr, ushort count, ushort transactionId) { byte[] frame = new byte[12]; frame[0] = (byte)(transactionId >> 8); // 事务ID高字节 frame[1] = (byte)(transactionId & 0xFF); // 事务ID低字节 frame[2] = 0x00; frame[3] = 0x00; // 协议ID:固定 0x0000 frame[4] = 0x00; frame[5] = 0x06; // 长度:单元ID(1) + 功能码(1) + 数据(4) = 6 frame[6] = 0x01; // 单元ID:S7-1200 默认 1 frame[7] = 0x03; // 功能码:0x03 读保持寄存器 frame[8] = (byte)(startAddr >> 8); // 起始地址高字节 frame[9] = (byte)(startAddr & 0xFF); // 起始地址低字节 frame[10] = (byte)(count >> 8); // 寄存器数量高字节 frame[11] = (byte)(count & 0xFF); // 寄存器数量低字节 return frame; }参数说明:startAddr 是协议报文里的偏移地址,从 0 开始计数。如果上位机界面上显示的是 40001,那报文里对应的偏移就是 0,这就是避坑章要说的「地址差一」。count 是寄存器个数,读一个 32 位浮点数必须传 2,因为 REAL 类型占两个保持寄存器。
响应帧的解析同样按字节来。响应结构是:MBAP 头 7 字节 + 功能码 1 字节 + 字节计数字段 1 字节 + 寄存器数据,数据从第 9 个字节开始取。构造请求时还要记住,事务 ID 每次请求要唯一,用来和响应做匹配;如果收到的响应事务 ID 和请求对不上,说明通信链路里有乱序或干扰,直接丢弃并告警。
3.3 读写操作的封装与数据类型换算
协议层只负责收发字节流,业务层要处理的是「读回来的字节到底代表什么」。这是整个项目里最值得细看的地方。
/// <summary> /// 从响应帧中解析出 float 数组(西门子 REAL 类型) /// </summary> public float[] ParseFloatArray(byte[] response, int floatCount) { int dataOffset = 9; // MBAP头(7) + 功能码(1) + 字节数(1) float[] values = new float[floatCount]; for (int i = 0; i < floatCount; i++) { int p = dataOffset + i * 4; // 西门子 REAL 在 Modbus 映射里:寄存器N存高字,寄存器N+1存低字 // 线上字节流为大端序:H1 L1 H2 L2,而 .NET 的 BitConverter 期望小端序 // 所以先把四个字节反转成 L2 H2 L1 H1 再转换 byte[] buf = new byte[4]; buf[0] = response[p + 3]; buf[1] = response[p + 2]; buf[2] = response[p + 1]; buf[3] = response[p]; values[i] = BitConverter.ToSingle(buf, 0); } return values; }逻辑说明:这一步就是很多人读浮点数据「读出来是天文数字」的根本原因。西门子 S7-1200 的 REAL 在内存中按大端存储,MB_SERVER 把它映射到两个保持寄存器时,低地址寄存器是高字,高地址寄存器是低字,组成了大端序的 4 字节。而 .NET 的 BitConverter.ToSingle 按小端读,中间还有半字交换,直接转换必然错。解决办法就是按上面代码手动反转字节序。这个换算规则同样适用于写入:先把 float 转成字节数组,反转后再按大端拆成两个寄存器。
写线圈的请求也是类似套路,但有个特别容易翻车的细节:
/// <summary> /// 构造写单个线圈请求帧(功能码 0x05) /// </summary> public byte[] BuildWriteSingleCoilRequest(ushort addr, bool state) { byte[] frame = new byte[12]; frame[0] = 0x00; frame[1] = 0x01; // 事务ID 示例 frame[2] = 0x00; frame[3] = 0x00; // 协议ID frame[4] = 0x00; frame[5] = 0x06; // 长度 frame[6] = 0x01; // 单元ID frame[7] = 0x05; // 功能码 0x05 frame[8] = (byte)(addr >> 8); // 线圈地址高字节 frame[9] = (byte)(addr & 0xFF); // 线圈地址低字节 frame[10] = state ? (byte)0xFF : (byte)0x00; // 关键:ON=0xFF00,OFF=0x0000 frame[11] = 0x00; return frame; }参数说明:功能码 0x05 的数据字段不是 0x01/0x00,而是固定写 0xFF00 表示线圈置位、0x0000 表示线圈复位。很多新手在这里按布尔 0/1 填,PLC 侧收到会返回异常码。另外要记住,S7-1200 的 MB_SERVER 只有在配置了 MB_COILS 指针之后才会处理线圈读写,否则功能码 0x05 会被直接拒绝。
4. PLC 侧配合:S7-1200 的 ModbusTCP 服务端配置
4.1 在 TIA Portal 里调用 MB_SERVER 指令块
C# 侧写得再好,PLC 侧不开服务也白搭。S7-1200 固件 4.0 及以上版本,在 TIA Portal 的「通信 → 开放式用户通信 → 其他 → ModbusTCP」路径下可以找到 MB_SERVER 指令块。它负责监听本地端口,解析收到的 ModbusTCP 报文,并读写指定的数据区。如果现场设备固件还在 3.x 时代,这个指令块是找不到的,要么升级固件,要么用西门子早期提供的旧版 ModbusTCP 库,建议直接升级,旧库的地址映射规则和现在差异很大。
调用方式是在 OB1 里拖入 MB_SERVER,系统会要求分配一个背景数据块用于保存连接状态。关键参数这样配置:
"MB_SERVER_DB"( DISCONNECT := "HMI_CTRL".disconnect, // 为TRUE时断开所有连接 CONNECT := "MB_CONNECT_DB".CONNECT, // TCON_IP_v4 连接描述结构 MB_HOLD_REG := "ModbusData".HoldRegs, // 保持寄存器映射区(INT数组) MB_HOLD_REG_LEN := 100, // 保持寄存器长度(字) MB_COILS := "ModbusData".Coils, // 线圈映射区(BOOL数组) MB_COILS_LEN := 16, // 线圈数量 DONE => "MB_STATUS".done, BUSY => "MB_STATUS".busy, ERROR => "MB_STATUS".error, STATUS => "MB_STATUS".status);参数说明:CONNECT 要指向一个 TCON_IP_v4 类型的 DB 结构,LocalPort 填 502,ActiveEstablished 置 FALSE,表示被动监听模式。MB_HOLD_REG 指向的数组就是 Modbus 保持寄存器区,数组每个元素是一个字。MB_COILS 指向布尔数组,对应 Modbus 线圈空间,不配置的话线圈读写功能码会返回异常。
这里有个特别关键的调度问题:MB_SERVER 属于「每个扫描周期都需要处理」的指令,必须放在 OB1 里。如果放到定时中断组织块里,上位机发来的请求只能在中断周期内得到响应,周期一长就会出现明显的偶发超时。
4.2 数据区映射与地址换算规则
PLC 侧的数据区如何对应到 Modbus 地址,是项目实施时最容易混乱的部分。以一个把 DB 块映射到保持寄存器的例子来说明:
| Modbus 地址 | 协议偏移 | 数据类型 | PLC 侧对应 |
|---|---|---|---|
| 40001 | 0 | INT | HoldRegs[0] |
| 40002 | 1 | INT | HoldRegs[1] |
| 40001-40002 | 0-1 | REAL | HoldRegs[0] 高字 + HoldRegs[1] 低字 |
| 线圈 0 | 0 | BOOL | Coils[0] |
| 线圈 1 | 1 | BOOL | Coils[1] |
注意两件事。第一,Modbus 行业习惯把保持寄存器地址从 40001 开始编号,但协议报文里的偏移量是从 0 开始的。C# 侧请求帧里起始地址写 0,对应的就是 40001,也就是 PLC 侧 MB_HOLD_REG 指向的第一个寄存器。很多第三方组件的中文文档把这两个概念混着写,代码里如果不统一,必然差一个位置。
第二,REAL 类型占用两个连续寄存器,而且是跨字存储——低地址寄存器存高字,高地址寄存器存低字。所以在 PLC 侧规划数据区的时候,建议把浮点数连续排放,中间不要夹 INT 或 BOOL,否则上位机侧的偏移计算会非常痛苦。我一般在 DB 块里先排所有 REAL,再排 INT,最后排 BOOL,并在注释里标明每个变量的 Modbus 偏移。
运行顺序也要讲清楚:先在 TIA Portal 里把带 MB_SERVER 的程序下载到 PLC 并切换到 RUN,再用网线把电脑和 PLC 连到同一网段,最后启动 C# 程序。程序启动后按 app.config 里的连接参数建立连接,然后周期性轮询点位表里的地址。如果 C# 侧报连接超时,先不要怀疑代码,用 ping 确认物理链路,再确认 PLC 是否在 RUN 状态,最后才查程序。
5. 避坑指南:PLC 通讯里最常见的五个翻车现场
这些坑不是理论推演,全是项目现场摔出来的。每一条我都按「现象 → 原因 → 解决」来写,你对照着排查能省一大半时间。
5.1 浮点数读出来全是天文数字
现象:用 C# 读 S7-1200 里的 REAL 类型,电流、温度、频率全部乱套,有时是几百亿的值,有时是接近 0 的噪声。
原因:字节序问题。西门子 REAL 在内存里是大端存储,MB_SERVER 把两个保持寄存器按大端发出,线上字节序是「高字高字节、高字低字节、低字高字节、低字低字节」。.NET 的 BitConverter.ToSingle 默认按小端读取,半字和字节序双重错位。
解决:解析时手动把四个字节反转再转换,写入时先 ToBytes 再反转后拆成两个寄存器。这个逻辑在第 3.3 节已经给出完整代码,建议直接封装成工具方法,不要散落在业务代码里。
5.2 连接偶发超时,采集线程被拖死
现象:程序跑一两个小时,某次读请求突然卡住,之后所有采集全部停摆,重启程序才恢复。
原因:S7-1200 同时接受的活动 TCP 连接数有限,TIA Portal 在线监视、另一个调试工具、加上你的程序同时挂着,连接表被占满;或者 PLC 在 STOP 模式下 MB_SERVER 不再响应请求。还有可能是请求帧构造错误导致 PLC 侧一直在等待,但 TCP 层感知不到。
解决:读写超时必须设置,这是第一道防线;采集端要做心跳检测,连续超时 N 次就强制断开重建连接。另外,排查现场时先把 TIA Portal 的在线连接关掉,再统计一下有几个设备在同时连这台 PLC。
5.3 寄存器地址差一:所有数据整体偏移
现象:上位机读到的数据和实际值对不上,所有位点整齐地错开一位,读 40001 出来的是 40002 的内容。
原因:Modbus 世界里有两套编号体系。行业习惯用 40001 起始的 1 基地址,协议报文里用的是 0 基偏移量。S7-1200 的 MB_SERVER 不帮你做任何地址转换,报文里写 0 就访问第一个寄存器。
解决:全工程统一使用协议偏移量,也就是 0 基地址。上位机界面如果要给操作员显示 40001 这种地址,只在展示层做加 1 转换,协议层、配置层、PLC 侧全部用偏移量。
5.4 写线圈返回正常但值没变
现象:C# 侧写线圈功能码 0x05 返回成功,PLC 侧布尔值却没有变化。
原因:三种常见原因。第一,MB_SERVER 的 MB_COILS 指针没有配置,指令块默认不处理线圈功能码;第二,线圈地址超出了 MB_COILS_LEN 定义的范围,写操作被丢弃;第三,PLC 主程序每个扫描周期里又把该布尔量重新赋值了,写入值被覆盖。
解决:先在 TIA Portal 里确认 MB_SERVER 的 MB_COILS 和 MB_COILS_LEN 已经配置;再检查写入地址是否在映射区范围内;最后在 OB1 里对目标变量加监视,看写操作瞬间值是否有变化,如果有变化又被重置,说明是程序覆盖问题,需要排查逻辑执行顺序。
5.5 PLC 侧数据区规划混乱导致整体错位
现象:上位机配置完全正确,但读出来的值是乱的,像是把数组里不相干的元素读到了一起。
原因:PLC 侧数据区规划混乱。REAL 本来占两个寄存器,如果规划时在 REAL 之间插了一个 INT 或 BOOL,所有后续变量的 Modbus 偏移全部错位,而上位机点位表还按「每个 REAL 占 2 个字」推算。
解决:把 PLC 侧数据区按类型分组连续排放,先排 REAL,再排 INT,最后排 BOOL;每个变量在注释里标注 Modbus 偏移。这个经验直接来自一次现场事故——我在 DB 块里把一个 INT 夹在两个 REAL 中间,C# 侧排查了大半天才发现偏移整体错位,从那以后每次规划数据区都先把偏移表写好再组态。
6. 二次开发思路:把通讯层拆成可复用的独立服务
这个项目跑通之后,下一步自然是往真正的产线采集程序上靠。我比较推荐先把 PlcDataService 抽象成接口,再让点位表完全配置化。
public interface IPlcGateway { float ReadFloat(int offset); bool ReadCoil(int offset); void WriteCoil(int offset, bool state); void WriteFloat(int offset, float value); }实现类内部持有 ModbusTcpClient,超时、重连、字节序换算三件套全部收敛到这一个类里。点位表用 JSON 配置驱动,每个点位包含名称、寄存器偏移、数据类型、读写权限,程序启动时统一加载到字典里,新增采集点只需要改配置文件,不用动代码。
没有真实 PLC 的调试期也有一条捷径:在开发机上装一个 Modbus Slave 模拟器,把模拟器监听在本机 502 端口,C# 侧直接连 127.0.0.1,先把报文构造、响应解析、字节序换算全部验证完,再拿着同一套程序去现场连真机。这样能省掉大量在产线上调试的时间,也方便复现那些偶发超时问题。
做这个项目时我最大的教训是:刚开始我把字节序换算和超时处理散落在各个业务方法里,结果每个新方法都在重复踩坑。后来把它们全部收敛到协议层,上层只面向业务,代码才真正稳住。从那以后我写任何设备通讯程序,第一件事就是定协议层边界——哪些字节处理归底层、哪些数据语义归上层——边界定清楚了,后面加协议、换设备都只是工作量问题,不是风险问题。希望帮到你。
本文还有配套的精品资源,点击获取