
简介面向C#工业通讯开发者与自动化工程师的ModbusTcp实战资料包围绕C#通过ModbusTcp与西门子1200 PLC交换数据展开适合希望从协议理解走向工程落地的初中级读者。压缩包共46个文件、约13.02MB以cs源码、xml配置、exe程序及zip备份等为主并含Visual Studio解决方案与项目缓存文件便于直接打开调试、对照工程结构学习。内容覆盖八类常用功能码的读写思路包括线圈、离散输入、保持寄存器与输入寄存器访问以及西门子PLC的I/Q/IR/HR地址映射、Socket直连示例、NModbus调用封装、超时重试与连接失败处理等关键环节。资源还附带PLC测试与备份文件可用于验证读写流程和排查通讯问题已有4316人学习适合需要快速搭建实验环境、理解请求响应机制并积累排错经验的读者参考也可作为课程实验与项目联调时的对照材料。1. 一条包装线带来的选型C# 配 ModbusTcp 读西门子 1200 PLC 到底图什么去年接手一条包装线的上位机改造现场摆着一台 S7-1200管着启停、速度给定和十来个传感器界面是我用 C# 写的每 5 秒要把这批数据拉回来画曲线、落数据库还要把操作员改的配方下发回去。原本打算走 S7 协议结果授权、端口、防火墙一路折腾最后换成 ModbusTcp——PLC 侧不用额外买通信授权PC 侧不用装任何厂商组件一个 TcpClient 加一段字节拼装就能跑通。这套组合适合做上位机、MES 数据采集、设备联网的 C# 开发者尤其是现场只丢给你一个 IP 和一句「端口 502地址我发你」的情况。它不适合读符号表、访问优化块、要毫秒级同步的场景那些老实走 S7 或 OPC UA但凡「读一批寄存器、写一批寄存器」的活ModbusTcp 是最省事的一条路。2. 先动 PLC 侧S7-1200 的 MB_SERVER 组态与寄存器映射很多人拿到这个需求第一步就打开 Visual Studio 开始写 Socket。我的血泪经验是反着来先把 PLC 那边跑通用调试工具能读到数再去写代码。因为一旦不通你坐在电脑前根本分不清是 PLC 的服务端没起来还是自己的报文拼错了。2.1 硬件组态MB_SERVER 是服务端不是靠网口自带S7-1200 做 Modbus TCP 服务端靠的是 TIA Portal 指令库里的 MB_SERVER在「通信 → 其它 → MODBUS TCP」下面。它不需要额外授权CPU 自带的以太网口就能跑这是它比 S7 协议省事的地方。调用位置一般放在 OB1 里跟着主循环每扫描周期调用一次如果现场逻辑复杂、OB1 周期不稳定就单独开一个 20ms 的循环中断 OB 来调。MB_SERVER 是异步指令内部用状态机维持连接必须持续调用调用停了连接就断。引脚不多但每个都有用MB_HOLD_REG指向保持寄存器区的指针必须是一个 Word 数组而且通常要求建在标准非优化数据块里。这个数组的长度直接决定 4 区能访问到多少寄存器。CONNECT一个TCON_IP_v4结构描述本地端口和连接方式。DISCONNECT给个上升沿就把当前客户端踢掉调试时很好用。NDR/DR/ERROR/STATUS状态输出出错时 STATUS 里那个十六进制码是排查的第一手线索。CONNECT 结构里几个关键字段我一般这样填InterfaceId填 CPU 以太网口的硬件标识符在设备组态的「硬件标识符」里查ID随便给个 1ConnectionType填16#0B代表 TCPActiveEstablished填 FALSE因为是服务端、被动等别人来连RemoteAddress填0.0.0.0表示接受任意客户端RemotePort填 0LocalPort填 502。提示502 是 Modbus TCP 的约定端口现场如果还有别的设备占着它就必须改但改完客户端要同步改就不再是「标准配置」了接手的人容易踩坑。所以能不改就不改。2.2 数据块规划一张地址映射表能省下三天联调这一步比写代码重要得多。我一般会拉上电气工程师把「业务含义 → PLC 侧变量 → Modbus 地址 → 数据类型」四列定死谁改谁负责后面扯皮就少。做法是新建一个 DB比如叫DB_Modbus里面只放一个Array[0..199] of Word。所有要对外暴露的变量都通过 MOVE 指令搬进这个数组业务逻辑该咋写咋写两边解耦。为什么用数组而不是把各种零散变量直接堆在 DB 里因为 MB_HOLD_REG 要的是一段连续的 Word 区数组偏移量算起来最直观。定好的映射大概长这样业务含义PLC 侧变量Modbus 地址4 区报文偏移占用寄存器运行状态字Arr[0]4000101产线速度Arr[1..2]40002~400031~22REAL环境温度Arr[3..4]40004~400053~42REAL配方号Arr[5]4000651INT报警字Arr[6]4000761位打包这张表里有三个地方新手最容易翻车。第一报文里传的地址是 0 基的而大家嘴上说的 40001 是 1 基的两者差 1写代码时起始地址要传 0 而不是 1。第二REAL 占两个寄存器高低字谁在前要看 MB_SERVER 的具体映射不同固件版本下不一定一样必须实测不能照抄网上的结论。第三Bool 不要一个占一个寄存器八个打包进一个 Word 更省一次读回来用位运算拆通信次数直接少一个量级。2.3 别急着写代码先用调试工具把链路打通顺序应该是PLC 下载完 → 用 Modbus 调试工具读一遍 → 通了再动 C#。工具用 Modbus Poll 或者 QModMaster 都行填 IP、502 端口、Slave ID也就是单元标识符MB_SERVER 常见填 1 或者 255看组态怎么设的功能码选 03起始地址填 0数量填 10点读取。这一步花二十分钟能省掉后面几小时「到底是 PLC 还是代码」的互相甩锅。读回来之后对照 PLC 在线监控里的数组值逐个核对顺便把 REAL 的字序也确认掉——先在 PLC 里给速度写个 12.5看调试工具读回来是0x4148 0x0000还是0x0000 0x4148一目了然。如果压根读不到先 ping再看 CPU 的在线诊断MB_SERVER 的 STATUS 引脚会给出错误码比如连接资源不够、端口被占用这类。3. C# 通信层实现从 TcpClient 到可复用的 ModbusTcp 客户端PLC 侧通了剩下就是字节活。Modbus TCP 的报文结构非常规整规整到你把一帧拆开看一遍就再也不会忘。3.1 报文结构拆解MBAP 头加 PDU每一帧 7 字节 MBAP 头 PDU。MBAP 头的字段如下字段长度字节说明事务标识符2客户端自增响应必须原样带回协议标识符2固定0x0000长度2后续字节数包含单元标识符和 PDU单元标识符1站号 / Slave ID以功能码 03 读保持寄存器为例请求 PDU 是03 起始地址(2) 寄存器数量(2)总共 5 字节所以 MBAP 里的长度字段填 61 字节单元 ID 加 5 字节 PDU。响应 PDU 是03 字节数(1) 数据(N×2)长度字段随之变化。异常响应的判断很简单功能码最高位置 1比如0x83后面跟一个字节的异常码0x02是非法数据地址0x03是非法数据值0x04是从站设备故障。看到这几个码基本不用怀疑网络直接回去查地址和数量。3.2 连接管理长连接、关 Nagle、加锁TcpClient 建连接有几个参数值得认真设。NoDelay true关掉 Nagle 算法Modbus 的报文都是几十字节的小包不关的话可能被攒着发延迟忽高忽低。ReceiveTimeout和SendTimeout一定要设我一般给 2000 到 3000 毫秒网络一抖动同步读会一直挂着界面就成黑匣子了。还有一点一个客户端实例内部必须用 lock 保护「发一帧收一帧」的完整过程否则多线程一起发事务 ID 和响应就串了。事务标识符用一个 ushort 自增字段维护收到响应先校验它是否和请求一致。不一致通常意味着上一帧没读干净缓冲区里还有残留这种情况别硬解析直接断开重连最省心。3.3 读保持寄存器的完整实现public sealed class ModbusTcpClient : IDisposable { private readonly TcpClient _tcp new TcpClient(); private NetworkStream _stream; private ushort _tid; private readonly object _sync new object(); public void Connect(string ip, int port 502, int timeoutMs 2000) { _tcp.NoDelay true; // 关掉 Nagle小包立即发出 _tcp.ReceiveTimeout timeoutMs; // 防止网络抖动时无限等待 _tcp.SendTimeout timeoutMs; _tcp.Connect(ip, port); _stream _tcp.GetStream(); } // start 为 0 基地址40001 对应 0 public ushort[] ReadHoldingRegisters(byte unitId, ushort start, ushort count) { lock (_sync) { ushort tid _tid; byte[] req new byte[12]; req[0] (byte)(tid 8); req[1] (byte)(tid 0xFF); req[2] 0x00; req[3] 0x00; // 协议标识固定 0 req[4] 0x00; req[5] 0x06; // 后续长度 单元ID 1 PDU 5 req[6] unitId; req[7] 0x03; // 功能码读保持寄存器 req[8] (byte)(start 8); req[9] (byte)(start 0xFF); req[10] (byte)(count 8); req[11] (byte)(count 0xFF); _stream.Write(req, 0, req.Length); byte[] mbap ReadExactly(7); ushort rTid (ushort)((mbap[0] 8) | mbap[1]); if (rTid ! tid) throw new IOException($事务ID不匹配期望 {tid}实际 {rTid}); int len (mbap[4] 8) | mbap[5]; // 单元ID PDU 的总长 byte[] pdu ReadExactly(len - 1); // 减去单元ID那一个字节 if ((pdu[0] 0x80) ! 0) // 最高位为 1 表示异常响应 throw new ModbusException(pdu[0], pdu[1]); int byteCount pdu[1]; if (byteCount ! count * 2) throw new IOException($返回字节数不符期望 {count * 2}实际 {byteCount}); ushort[] result new ushort[count]; for (int i 0; i count; i) result[i] (ushort)((pdu[2 i * 2] 8) | pdu[3 i * 2]); return result; } } // TCP 会拆包必须循环读满指定长度 private byte[] ReadExactly(int n) { byte[] buf new byte[n]; int off 0; while (off n) { int r _stream.Read(buf, off, n - off); if (r 0) throw new IOException(连接被对端关闭); off r; } return buf; } public void Dispose() { _stream?.Dispose(); _tcp?.Close(); } }这段代码有几个地方值得单独说。MBAP 的长度字段包含单元标识符所以算 PDU 长度时要减 1这是第一个容易算错的地方。异常响应的判断放在字节数校验之前因为异常帧里根本没有字节数那个字段顺序反了会数组越界。ReadExactly必须循环因为NetworkStream.Read只保证返回至少一个字节不保证一次读满现场网络稍差一点就会遇到响应被拆成两段到达。参数上unitId填 PLC 组态里的单元标识符常见是 1 或 255start是 0 基地址40001 传 0count一次别超过 120 个寄存器一是响应帧别太大二是 PLC 那边的处理能力有限分批读更稳。3.4 写寄存器06 和 16 两个功能码够用写单个寄存器用功能码 06请求 PDU 是06 地址(2) 值(2)帧长同样是 12 字节。正常响应会把请求原样回显所以校验一下回显内容就能确认写成功了。public void WriteSingleRegister(byte unitId, ushort address, ushort value) { lock (_sync) { ushort tid _tid; byte[] req new byte[12]; req[0] (byte)(tid 8); req[1] (byte)(tid 0xFF); req[4] 0x00; req[5] 0x06; req[6] unitId; req[7] 0x06; // 功能码写单个寄存器 req[8] (byte)(address 8); req[9] (byte)(address 0xFF); req[10] (byte)(value 8); req[11] (byte)(value 0xFF); _stream.Write(req, 0, req.Length); byte[] resp ReadExactly(12); // 回显帧长度固定 if ((resp[7] 0x80) ! 0) throw new ModbusException(resp[7], resp[8]); } }写多个寄存器用功能码 16适合一次下发一批配方参数。请求 PDU 变成10 起始地址(2) 数量(2) 字节数(1) 数据(2N)MBAP 的长度字段跟着变成7 2N。响应比请求短只回显前 6 个字节的 PDU读的时候按这个长度收别傻等整个请求帧那么长。这里有个细节写操作我习惯在业务层做一次合并。比如操作员一次性改了五个配方参数别发五次 06攒起来发一次 16既快又不容易被 PLC 的扫描周期打断。4. 数据类型转换两个寄存器怎么还原成 float、int、bool通信层通了之后真正折磨人的是数据。读回来一串 ushort业务代码要的是 float、int、bool中间这层转换写不清楚后面全是玄学问题。4.1 字序同一个 REAL两种排法都合法Modbus 协议本身规定字节按大端传但 32 位数据占两个寄存器哪个是高字、哪个是低字协议没管得看设备实现。S7-1200 的 MB_SERVER 在多数组态下是高字在前但不同固件、不同数据块优化设置下确实遇到过反过来的情况。与其赌不如做成可配置。public enum WordOrder { HighWordFirst, // 高字在低地址如 0x4148 0x0000 LowWordFirst // 低字在低地址如 0x0000 0x4148 }判断方法很土但有效在 PLC 里给目标变量写一个特征值比如 1.0它的 IEEE754 表示是0x3F800000两个寄存器读回来如果是0x3F80和0x0000那就是高字在前如果读成0x0000和0x3F80就得切到另一个模式。写一次、读一次比看任何文档都快。4.2 一个够用的转换工具类public static class ModbusConvert { // 两个寄存器合成 floatorder 决定高低字顺序 public static float ToSingle(ushort[] regs, int index, WordOrder order) { ushort hi order WordOrder.HighWordFirst ? regs[index] : regs[index 1]; ushort lo order WordOrder.HighWordFirst ? regs[index 1] : regs[index]; int bits (hi 16) | lo; return BitConverter.Int32BitsToSingle(bits); } public static short ToInt16(ushort reg) unchecked((short)reg); public static int ToInt32(ushort[] regs, int index, WordOrder order) { ushort hi order WordOrder.HighWordFirst ? regs[index] : regs[index 1]; ushort lo order WordOrder.HighWordFirst ? regs[index 1] : regs[index]; return (hi 16) | lo; } public static bool GetBit(ushort reg, int bit) (reg (1 bit)) ! 0; public static ushort SetBit(ushort reg, int bit, bool on) on ? (ushort)(reg | (1 bit)) : (ushort)(reg ~(1 bit)); }BitConverter.Int32BitsToSingle是 .NET Core 3.0 之后才有的如果项目还挂在 .NET Framework 4.x 上就得用BitConverter.GetBytes再ToSingle那会多一次数组分配追求极致的话可以用unsafe指针直接转但这个场景下真没必要5 秒轮询一次分配那点内存连零头都算不上。ToInt16用了unchecked因为 PLC 的 INT 是有符号的读回来的 ushort 直接强转成 short 才是正确语义不加unchecked在某些编译配置下会抛溢出异常。4.3 位地址打包进 Word 的 Bool 怎么拆PLC 侧把八个 Bool 打包进一个字是最省通信次数的做法。C# 侧拿到 ushort 之后按位拆顺序要和 PLC 那边一一对上这就要靠映射表里写清楚「第 0 位是运行、第 15 位是急停」。var regs client.ReadHoldingRegisters(1, 0, 10); ushort status regs[0]; bool running ModbusConvert.GetBit(status, 0); bool alarm ModbusConvert.GetBit(status, 15); float speed ModbusConvert.ToSingle(regs, 1, WordOrder.HighWordFirst); float temp ModbusConvert.ToSingle(regs, 3, WordOrder.HighWordFirst);写位的时候要特别小心。如果直接SetBit然后整字写回中间隔着一次读和一次写这期间 PLC 自己或者别的客户端改了这个字的其他位你写回去就把人家的改动覆盖了。我的做法是一个 Word 只让上位机写PLC 侧只读或者干脆把控制位单独放一个寄存器状态字和命令字彻底分开。这条规矩定下来之后现场再没出现过「按了启动另一个按钮自己弹起来」这种诡异现象。5. 避坑与排查现场联调最常见的五个翻车现场这些坑我基本每个都亲自踩过写出来省得你重走一遍。5.1 现象ping 得通502 端口连不上原因最常见的是 MB_SERVER 没在 OB1 里持续调用或者调用了一次就断了其次是端口被别的程序占着再就是 CPU 的保护等级设得太高拒绝了外部连接。解决先在 PLC 在线诊断里看 MB_SERVER 的 STATUS 输出连接类错误码会直接告诉你方向。确认指令在每个扫描周期都被调用别放在一个只执行一次的条件分支里。端口占用的话在 PC 上换个工具 telnet 一下 502通不通立刻清楚。5.2 现象能连上读回来全是 0原因九成是地址偏移错了一位。工具里填 40001代码里也传 1可报文要的是 0 基地址结果读到了数组外面的一块空地PLC 老老实实回 0。解决把起始地址改成 0 再试一次。另外确认 MB_HOLD_REG 指向的数组确实被业务逻辑写过值——有时候数组建好了MOVE 指令忘了加读回来当然是 0。5.3 现象REAL 读出来是 1e-38、NaN 或者一个离谱的大数原因字序反了。高低字一颠倒同样的四个字节解释出来完全是另一个数而且往往不会报错就那么安安静静地错着最坑。解决按 4.1 节的办法在 PLC 里写个 1.0 做特征值对比读回来的两个寄存器切一下WordOrder就好。别信网上抄来的固定结论不同现场真不一样。5.4 现象跑几个小时就连不上重启软件立刻恢复原因每次请求都 new 一个 TcpClient 且忘了关短时间内积累大量 TIME_WAIT 连接或者 PLC 侧的连接资源被占满了——S7-1200 的 MB_SERVER 能同时接受的连接数是有限的不是无限。解决改成一条长连接复用请求失败时在 finally 里显式关闭再重连轮询间隔别设得太激进5 秒一次对绝大多数产线数据足够了非要 100 毫秒一次的话先想清楚是不是真需要。5.5 现象界面点一下卡三秒像死机原因同步的Read放在了 UI 线程上网络稍微抖一下ReceiveTimeout没设或者设得太大整个界面就挂在那儿等。解决通信一律放后台线程或者定时器里跑结果写进一个快照对象UI 只负责定时取快照刷新永远不直接碰 Socket。ReceiveTimeout必须设这是最后一道保险。6. 二次开发把通信层抽成服务让业务代码不碰 Socket第一次写这类项目我把 TcpClient 直接 new 在了窗体代码里结果后来要加一个「数据同时推给 MES」的需求改得满目疮痍。后来学乖了通信层只暴露一个接口public interface IPlcReader { PlcSnapshot Read(); // 返回一次完整快照调用方不关心底层协议 } public sealed class PlcSnapshot { public bool Running { get; init; } public float Speed { get; init; } public float Temperature { get; init; } public DateTime Timestamp { get; init; } }实现类内部持有 ModbusTcpClient负责重连、重试、异常吞掉转成「上一次的有效值 失效标记」。上层用一个后台服务按固定周期调用Read()把结果塞进一个volatile字段界面定时器每 200 毫秒取一次渲染。这样通信断不断界面都不会卡MES 要数据也直接订阅同一个快照不用再开一条连接。断线重连我一般用指数退避失败后等 1 秒、2 秒、4 秒、8 秒封顶 30 秒同时把「通信中断」这个状态推给界面显示而不是默默重试让人以为数据还在更新。验证改造是否成功有个笨办法但很好用在交换机上把网线拔掉再插上看界面是立刻显示中断、几秒后自动恢复还是直接卡死——这一步走通了这套通信层才算真的能上现场。从那以后我每次接新的 PLC 通信项目都强制先做三件事先用调试工具把地址和字序确认死再把通信层封成接口不给业务代码碰 Socket最后一定拔一次网线看恢复。希望帮到你。本文还有配套的精品资源点击获取