简介:该资源是一套面向C#开发人员与工业自动化工程师的ModbusTcp通信实战工程,以西门子1200 PLC为对象,演示了从Socket底层通信到NModbus库调用的完整路径。压缩包共46个文件,13个cs源码文件与sln/csproj工程文件构成主体,配合exe可执行程序、pdb调试符号及xml配置,便于直接编译运行与二次修改;另有3个zip压缩包存放辅助测试内容,整体13.02MB,结构清晰。目前已有4313人学习下载。通过这份资料,读者可以掌握ModbusTcp八种常用功能码的实际用法,理清PLC的I/Q/寄存器地址映射关系,并了解超时、断线重连等网络异常处理思路,适合需要快速落地工业以太网通信项目或准备相关自动化开发工作的技术人员。
1. 用 C# 走 ModbusTcp 读西门子 S7-1200PLC:一份能直接改的上位机通讯方案
做产线数据采集的人大概率遇到这种场景:上位机要实时读 S7-1200 PLC 里的电机电流、温控仪回传温度、当班产量,数据量不大但要求刷新快、部署简单。对面设备不支持 OPC UA,走 S7 协议又要引入底层驱动,这时候 ModbusTcp 就成了性价比最高的方案——协议公开、实现简单、PLC 侧只需调用两个指令块。这份 C# 工程提供的就是一个基于 ModbusTcp 协议的客户端封装,覆盖读保持寄存器、写线圈、写多个寄存器这类常用操作,专为西门子 S7-1200 PLC 场景设计。适合两类人:刚接手上位机开发、需要把 PLC 数据接进 WinForm 或 Web 的新手;以及已经在用第三方商业控件、想换成开源方案省掉授权费的熟手。
2. 整体方案:从 MBAP 报文到工程分层,先把通讯链路看清
2.1 技术选型:为什么选 ModbusTcp 而不是 OPC UA 或 S7 协议
做 PLC 通讯选型时,OPC UA、S7 协议、ModbusTcp 这三条路我都走过,简单对比如下:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| OPC UA | 跨平台、安全模型完善、信息建模能力强 | 配置复杂,客户端 SDK 体积大,服务端和客户端版本匹配常出问题 | 大型自动化系统、跨厂区数据整合 |
| S7 协议 | 原生西门子协议,能访问全部数据区,效率高 | 协议不公开,通常依赖第三方驱动,许可证是不小的开销 | 西门子生态内深度集成 |
| ModbusTcp | 协议完全公开,任意语言几十行就能实现,无授权 | 数据类型简单,复杂结构需要自己拼装解析 | 轻量采集、MES 对接、快速交付 |
我在这份工程里选 ModbusTcp,核心理由是它把复杂度控制在了"能看懂、能改、能排错"的范围内。PLC 侧只需要调用 TIA Portal 指令库里的 MB_SERVER 块把数据区映射出来,上位机这边不管是 C#、Python 还是 Node.js,套同一个报文格式就能通讯。相比 OPC UA 那套 UA 模型和证书体系,ModbusTcp 的排障链路短得多——出了问题抓包一看就知道是哪一端的毛病。
至于实现方式,我见过两种主流做法:一种是直接用社区维护的开源库 NModbus4,把 TCP 连接和功能码封装都省掉;另一种是自己用 TcpClient 拼报文。这份工程走的是后者,因为自己拼报文能把协议细节完全握在自己手里,PLC 型号换了、字节序改了,改的都是看得见的代码,而不是去翻库的源码。后面章节的报文拆解和核心代码都是基于裸报文实现,理解了这套,用 NModbus4 也只是换个 API 壳的事。
2.2 ModbusTcp 报文:MBAP 头加 PDU,一条请求就 12 个字节
ModbusTcp 请求帧由 MBAP 头(7 字节)和 PDU(功能码加数据)组成。MBAP 头固定四段:
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务元标识符 | 2 字节 | 客户端自增编号,用于匹配请求和响应 |
| 协议标识符 | 2 字节 | 固定 0x0000,表示 Modbus 协议 |
| 长度 | 2 字节 | 后面所有字节数,即单元标识符加 PDU 的长度 |
| 单元标识符 | 1 字节 | 相当于从站地址,多设备组网时区分目标,默认 1 |
PDU 里最关键的是功能码。读写 S7-1200 最常用的几个:03 读保持寄存器、06 写单个寄存器、16 写多个寄存器、05 写单个线圈。拿"从地址 0 开始读 2 个保持寄存器"举例,完整请求是:
00 01 00 00 00 06 01 03 00 00 00 02逐个字段拆:00 01是事务标识符,00 00是协议标识符,00 06表示后面还有 6 个字节,01是单元标识符,03是功能码,00 00是起始地址,00 02是寄存器数量。对端正常响应会原样带回事务标识符,数据区跟在功能码后面。这个 12 字节的请求格式几乎包含了后续所有代码的拼装逻辑,理解它再看核心模块会快很多。
2.3 工程目录与分层:协议、转换、点位三层各管各的
ModbusTcpDemo/ ├─ Program.cs // 控制台入口,演示读写流程 ├─ ModbusTcpClient.cs // TCP 连接建立、报文收发、读写封装 ├─ ModbusDataConverter.cs // 字节序转换、寄存器到浮点/整型的解析 ├─ PlcPoint.cs // 点位模型:名称、地址、数据类型、缩放系数 └─ appsettings.json // PLC 的 IP、端口、从站号、超时时间这个分层是我反复改过之后的稳定结构。ModbusTcpClient 只管协议层:拼请求、收响应、处理事务 ID。ModbusDataConverter 单独拎出来是因为字节序是个大坑——S7-1200 的数据在 Modbus 报文里是 Big-Endian,而 .NET 的 BitConverter 默认按小端解析,这两个必须在一个独立类里做统一转换。PlcPoint 是点位抽象,把"地址 4 是温度"这类业务从协议代码里剥离出来,后面改点位表不用动通讯代码。跑的时候从 appsettings.json 读配置,换 PLC 改配置文件就行,不用重新编译。
这套结构的好处是边界清楚:协议出问题查 ModbusTcpClient,数值不对查 ModbusDataConverter,点位对不上查配置文件。我做排障的时候基本都是按这个顺序查,比堆在一个类里快得多。
3. 核心模块拆解:一次读请求从发出到拿到真实工程量
3.1 连接管理与超时:不能让 UI 卡在等待上
PLC 不在线是常态,连接这一步必须做超时控制。常见做法是直接用 TcpClient.Connect,但那会阻塞当前线程,严重时界面无响应。我一般用 BeginConnect 加 WaitOne 的组合:
public class ModbusTcpClient : IDisposable { private TcpClient _tcpClient; private readonly string _ip; private readonly int _port; private readonly int _timeoutMs; public ModbusTcpClient(string ip, int port, int timeoutMs = 2000) { _ip = ip; _port = port; _timeoutMs = timeoutMs; } public bool Connect() { _tcpClient = new TcpClient(); var asyncResult = _tcpClient.BeginConnect(_ip, _port, null, null); if (!asyncResult.AsyncWaitHandle.WaitOne(_timeoutMs)) { _tcpClient.Close(); throw new TimeoutException($"连接 {_ip}:{_port} 超时"); } _tcpClient.EndConnect(asyncResult); return _tcpClient.Connected; } public bool IsConnected => _tcpClient?.Connected ?? false; public void Dispose() { _tcpClient?.Close(); _tcpClient?.Dispose(); } }BeginConnect 发起异步连接后,WaitOne 最多等 _timeoutMs 毫秒,超时直接抛异常并关闭资源。这里的思路是:上层调用方只关心"连上了"或者"超时了"两个结果,不需要知道底层是同步还是异步。timeoutMs 默认 2 秒,现场如果 PLC 响应慢,我一般调到 5 秒,太短会误判,太长又影响轮询节奏。
3.2 读保持寄存器:手动拼 12 字节请求,再手动拆响应
读操作是上位机和 PLC 打交道最频繁的动作。功能码 03 读保持寄存器对应 S7-1200 里 MB_SERVER 组态的保持区,通常指向一个 DB 块。下面这段是手拼报文的完整实现:
public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort count) { var request = new byte[12]; request[0] = 0x00; // 事务标识符高字节 request[1] = 0x01; // 事务标识符低字节,简单场景固定 1 request[2] = 0x00; // 协议标识符 request[3] = 0x00; request[4] = 0x00; // 长度高字节 request[5] = 0x06; // 长度 = 单元标识符1 + 功能码1 + 起始地址2 + 数量2 request[6] = slaveId; request[7] = 0x03; // 功能码 03 读保持寄存器 request[8] = (byte)(startAddress >> 8); request[9] = (byte)(startAddress & 0xFF); request[10] = (byte)(count >> 8); request[11] = (byte)(count & 0xFF); Send(request); var response = Receive(); // 响应结构:MBAP 7字节 + 功能码1 + 字节数1 + 数据N if (response[7] != 0x03) throw new InvalidOperationException($"功能码异常: 0x{response[7]:X2}"); var byteCount = response[8]; var data = new ushort[count]; for (int i = 0; i < count; i++) { // 大端拼装:每个寄存器两个字节,高字节在前 data[i] = (ushort)((response[9 + i * 2] << 8) | response[10 + i * 2]); } return data; }事务标识符在单线程请求里可以固定,但多线程轮询时必须每次自增,否则响应匹配不上。起始地址和数量都是大端序,用移位和按位与来拼装比 BitConverter 更直观。count 有协议上限:一次最多读 125 个寄存器,超过了 PLC 会回异常码,这也是后面避坑章节要讲的重点之一。响应数组里第 7 个字节是功能码,第 8 个是数据字节数,真正的数据从第 9 字节开始,按每两个字节一个寄存器解析。
3.3 类型转换:两个寄存器拼一个浮点,字节序在这里翻车率最高
Modbus 寄存器是 16 位无符号整型,工厂里的温度、压力、流量基本都是 32 位浮点,一个浮点要占两个寄存器。S7-1200 在 Modbus 映射里遵循 Big-Endian,即高字在前。这个转换函数是踩坑后的标准写法:
public static float ReadFloat(ushort[] registers, int offset) { // 西门子 Big-Endian 顺序:registers[offset] 是高字 byte[] bytes = new byte[4]; bytes[0] = (byte)(registers[offset] >> 8); bytes[1] = (byte)(registers[offset] & 0xFF); bytes[2] = (byte)(registers[offset + 1] >> 8); bytes[3] = (byte)(registers[offset + 1] & 0xFF); // .NET 运行在小端机器上,手动整体反转 if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); }先按大端把两个寄存器铺成四个字节,再检测当前机器字节序,小端就整体反转,最后交给 BitConverter。这个"先拼大端再反转"的写法比逐字节交换清晰得多。如果读出来的浮点值是 3.7e-38 这种天文数字,十有八九是字节序没处理,把反转去掉试试就能验证。同理还有 ReadInt32、ReadInt16,思路一致,只是字节宽度不同。
3.4 写操作:写线圈和写多个寄存器的报文细节
写操作比读操作更容易在细节上翻车。写单个线圈时,协议规定 ON 必须是 0xFF00,OFF 是 0x0000,写成 0x01 会被 PLC 拒绝。写多个寄存器时,长度字段要自己算,这是新手最容易算错的地方:
public void WriteSingleCoil(byte slaveId, ushort address, bool value) { var request = new byte[12]; request[0] = 0x00; request[1] = 0x02; request[2] = 0x00; request[3] = 0x00; request[4] = 0x00; request[5] = 0x06; request[6] = slaveId; request[7] = 0x05; // 功能码 05 request[8] = (byte)(address >> 8); request[9] = (byte)(address & 0xFF); request[10] = value ? (byte)0xFF : (byte)0x00; // 必须用 FF00/0000 request[11] = 0x00; Send(request); var response = Receive(); } public void WriteMultipleRegisters(byte slaveId, ushort startAddress, ushort[] values) { int byteCount = values.Length * 2; var request = new byte[9 + byteCount]; request[0] = 0x00; request[1] = 0x03; request[2] = 0x00; request[3] = 0x00; request[4] = (byte)((5 + byteCount) >> 8); // 长度 = 单元1 + 功能码1 + 地址2 + 数量2 + 字节数1 + 数据 request[5] = (byte)((5 + byteCount) & 0xFF); // 即 5 + byteCount request[6] = slaveId; request[7] = 0x10; // 功能码 16 request[8] = (byte)(startAddress >> 8); request[9] = (byte)(startAddress & 0xFF); request[10] = (byte)(values.Length >> 8); request[11] = (byte)(values.Length & 0xFF); request[12] = (byte)byteCount; for (int i = 0; i < values.Length; i++) { request[13 + i * 2] = (byte)(values[i] >> 8); request[14 + i * 2] = (byte)(values[i] & 0xFF); } Send(request); var response = Receive(); }写多个寄存器时,报文长度是 9 加数据字节数,其中"5"是单元标识符、功能码、起始地址、数量、字节数这 5 个固定字节。数据区里每个寄存器同样是高字节在前。写完最好读回校验,尤其写的是设备参数而不是临时控制位时,写错会直接影响现场设备动作。
4. 跑起来:从 NuGet 还原到 PLC 侧组态的一次完整联调
4.1 工程环境与依赖引入
这份工程是 .NET 控制台项目,用命令行就能跑:
dotnet new console -n ModbusTcpDemo cd ModbusTcpDemo dotnet add package NModbus4 dotnet restore我自己调试时首选控制台而不是 WinForm,原因是控制台启动快、日志输出直接,适合先验证连通性和数据正确性。通讯逻辑在控制台里跑通后,再把它搬进 WinForm 或 Web API 也不迟。如果你跟着这份工程的裸报文实现来,连 NModbus4 都不需要引,一个 TcpClient 加一个 Socket 收发就够了,NuGet 依赖反而更少。
4.2 PLC 侧需要完成的两处配置
C# 代码再正确,PLC 侧没放行也是白搭。S7-1200 对接 ModbusTcp 有两个必做配置:
第一,在 CPU 属性的保护设置里勾选"允许来自远程对象的 PUT/GET 通信访问"。不勾这个,上位机的 TCP 连接能建立,但读写请求会被 CPU 静默丢弃,表现就是超时无响应。
第二,在 OB1 里调用 TIA Portal 指令库的 MB_SERVER 指令块。关键参数如下:
| 参数 | 配置值 | 说明 |
|---|---|---|
| DISCONNECT | 常亮 FALSE | 使能服务器监听 |
| IP_PORT | 502 | ModbusTcp 标准端口 |
| MB_HOLD_REG | 指向 DB 块 | 保持寄存器映射的数据区,推荐单独建一个 DB |
| MB_HOLD_REG_SYM | 对应 DB 符号名 | 勾选符号访问后自动绑定 |
MB_SERVER 块会把 PLC 里的 DB 数据映射成 Modbus 保持寄存器地址,地址偏移从 0 开始。比如把 DB10 映射到 MB_HOLD_REG,上位机读保持寄存器起始地址 0,读到的就是 DB10 的第一个字。这个映射关系是联调时最容易对不上的点,最好在 PLC 程序里加个注释写明"地址 0 对应 DB10.DBW0"。
4.3 最小可运行示例:读一个浮点数到控制台
配置完成后,一段最小代码就能验证整条链路:
var client = new ModbusTcpClient("192.168.0.1", 502, 3000); client.Connect(); // 从保持寄存器地址 0 开始读 2 个寄存器,拼成一个浮点 ushort[] regs = client.ReadHoldingRegisters(1, 0, 2); float temperature = ModbusDataConverter.ReadFloat(regs, 0); Console.WriteLine($"温度 = {temperature:F2} ℃"); client.Dispose();slaveId 传 1,对应 MB_SERVER 默认的单元标识符,如果 PLC 侧组态里改过,这里必须同步。startAddress 传 0,对应映射 DB 的起始偏移。读 2 个寄存器是因为浮点占 32 位。执行后如果控制台能打印出和 PLC 监控表里一致的温度值,说明连接、报文、字节序三关都过了;打印出天文数字,直接去检查字节序;超时,先去查 PLC 的 PUT/GET 和 MB_SERVER 配置。
5. 避坑指南:字节序、单元标识符、连接复用,一次讲完
5.1 浮点数读出来是天文数字
现象:读回来的浮点值是 3.7e-38 或 1.2e-41 这种明显不合理的数,而 PLC 监控表里显示的是正常的 25.3。
原因:字节序反了。S7-1200 的浮点在 Modbus 报文里是高字在前,而 .NET 的 BitConverter 默认按小端解析。如果转换函数没有做字节反转,两个寄存器的字节顺序就是反的,解析出的浮点自然离谱。
解决:转换函数里先按大端把寄存器拼成字节数组,再检测BitConverter.IsLittleEndian,为 true 就执行Array.Reverse。改完之后用 PLC 监控表里的已知值验证,确认没问题再往下写业务逻辑。
5.2 请求发出后一直超时无响应
现象:TCP 连接建立成功,但 Send 后 Receive 一直卡到超时。
原因:最常见的是 PLC 侧没勾选"允许来自远程对象的 PUT/GET 通信访问",CPU 直接丢弃了请求;其次是单元标识符不匹配,MB_SERVER 里组态的从站号和上位机请求里的 slaveId 不一致。
解决:先在电脑上 ping PLC 的 IP 确认网络通;再确认 CPU 属性里 PUT/GET 已勾选;最后用 Modbus 调试工具(比如 Modbus Poll)读同样的地址,如果工具能读而上位机不能,对比工具发报文的单元标识符和功能码,大概率能定位差异。
5.3 读多几次后 PLC 拒绝连接
现象:程序前三轮读写正常,第四轮开始 Connect 超时或直接报异常,重启程序后又正常。
原因:TcpClient 没有释放,Socket 资源被耗尽,S7-1200 的 Modbus 服务器连接数有上限,连接不关闭就占着名额。
解决:用完调用Dispose()关闭底层 Socket。更稳的做法是设计成长连接,整个程序生命周期只建一次连接,配合心跳检测,断开才重连,而不是每次读写都新建和销毁连接。
5.4 地址对不上:人机界面显示 40001,代码里却是 0
现象:PLC 监控表或触摸屏显示保持寄存器地址 40001,代码里填的也是 40001,但读回来的数据是错的。
原因:Modbus 协议地址从 0 开始编号,而工业 HMI 习惯从 1 显示,40001 在协议里实际是地址 0。二者差了一个偏移量。
解决:代码里统一使用协议地址,也就是界面显示值减 1。在点位配置文件里注释清楚"界面 40001 = 协议 0",避免后续维护的人被这个偏移量搞晕。
5.5 一次读超过 125 个寄存器被 PLC 拒绝
现象:功能码 03 的请求返回异常码 02(Illegal Data Address),单个请求读 200 个寄存器时必现。
原因:Modbus 协议规定读保持寄存器单次最多 125 个,读线圈单次最多 2000 个。超出上限,PLC 直接拒绝。
解决:把大块读取拆成多个小请求,循环读取再拼接。读取前先算好总寄存器数,按 125 一个批次分片。这个逻辑建议封装成ReadHoldingRegistersAll方法,内部循环调ReadHoldingRegisters,调用方不需要关心分片细节。
提示:以上五条是我在真实联调里踩过的坑,前三条几乎每个做 ModbusTcp 对接的人都躲不掉。调试阶段建议用抓包工具看一眼实际收发的报文,比盯着代码猜快得多。
6. 二次开发:把通讯代码重构成带点位表的数据采集服务
6.1 点位表驱动:不硬编码地址,配置说话
原始代码里读温度是ReadHoldingRegisters(1, 0, 2),读压力又是ReadHoldingRegisters(1, 4, 2),地址散落在各处。我一般会引入点位表模型:
public class PlcPoint { public string Name { get; set; } // 点位名,如 "一号炉温" public int Address { get; set; } // 协议地址,从 0 开始 public string DataType { get; set; } // Float / Int16 / UInt16 / Bool public double Scale { get; set; } = 1.0; // 工程量缩放系数 } // 从 JSON 读取点位表 var points = JsonSerializer.Deserialize<List<PlcPoint>>(File.ReadAllText("points.json")); foreach (var point in points) { ushort[] regs = client.ReadHoldingRegisters(1, (ushort)point.Address, 2); float raw = ModbusDataConverter.ReadFloat(regs, 0); double value = raw * point.Scale; Console.WriteLine($"{point.Name} = {value}"); }点位表的收益是新增点位只改 JSON,不动代码。设备参数、量程、报警阈值都可以塞进 PlcPoint 里扩展。我接 MES 的时候就是这么干的,MES 要什么点位,配置里加一行就行。
6.2 异步轮询与断线重连
采集服务不能跑一次就退出,需要一个循环。我常用的模式是异步轮询加异常重连:
private async Task PollLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { if (!client.IsConnected) client.Connect(); ReadAllPoints(); // 按点位表循环读取 } catch (Exception ex) { Log.Error(ex, "轮询异常,5 秒后重连"); client.Dispose(); await Task.Delay(5000, token); } await Task.Delay(1000, token); // 1 秒轮询间隔 } }Try 住整个读取循环,异常说明连接断了,直接 Dispose 旧连接,等 5 秒重新 Connect。轮询间隔按现场要求调,一般设备参数 1 秒足够,温度这类慢变量 2 到 3 秒也行。
6.3 值变化通知,让 UI 和 MES 订阅
轮询拿到的值每次都抛给上层会显得太吵,常见做法是缓存上次值,变化了才触发:
public event EventHandler<PlcPointValue> ValueChanged; private Dictionary<string, double> _lastValues = new(); public void ReadAllPoints() { foreach (var point in _points) { double value = ReadPoint(point); if (Math.Abs(value - _lastValues.GetValueOrDefault(point.Name)) > 0.001) { _lastValues[point.Name] = value; ValueChanged?.Invoke(this, new PlcPointValue(point.Name, value)); } } }UI 层只订阅ValueChanged事件,每收到一次就刷新对应文本框;MES 集成时把事件转成消息推送,架构也不用变。这套从裸报文到事件驱动的完整重构,让通讯代码从"能跑的 Demo"变成了"能用进产线的采集服务"。
做这个项目最大的教训是:协议层的代码看着简单,但字节序、地址偏移、单元标识符这些细节每一个都能让人排查半天。从那以后我每次联调 PLC 都会先抓包确认报文格式,再逐条对照协议文档验证,最后才写业务逻辑,这个习惯帮我避开了不少暗坑。希望帮到你。
本文还有配套的精品资源,点击获取