简介:面向工业自动化上位机开发者的C#通信实践包,围绕ModbusTcp协议与西门子1200 PLC的数据交互展开,适合已具备C#基础、希望打通以太网读写线圈、离散输入、保持寄存器与输入寄存器的开发者参考。压缩包共46个文件、约13.02MB,以13个C#源码文件为核心,配合XML配置、解决方案与项目文件、可执行程序及调试缓存等,构成可直接运行和改造的示例工程;其中还包含Socket直连与NModbus库两种实现思路的素材,便于对照协议细节。已有4316人学习下载。读者可从中获取地址映射、功能码调用、异常处理与连接重试等排错线索,涵盖线圈、离散输入、保持寄存器等常见读写场景,并借助完整项目结构快速搭建测试环境、验证读写流程,减少从协议理解到代码落地之间的试错成本,也能为排查超时、连接失败等问题提供参照。
1. 一根网线连通上位机与 S7-1200:这套组合能替你省掉哪些活
产线停了三十分钟,最后查出来是上位机读 PLC 的一个 REAL 变量时高低字没换。这类场景的共同点很一致:设备在跑,你在办公室,手上只有一根网线和一台装着 Visual Studio 的笔记本。C# 用 Modbus TCP 跟 S7-1200 通讯,就是这种条件下最省事的一条路——不用装厂商的 OPC 中间件,不用配 DCOM,不用买授权,Socket 一开,保持寄存器就能读能写。
它适合谁?做上位机、做设备数据采集、做产线看板、做实验室测试台的开发者。不适合谁?需要读写 PLC 的符号名、需要上传下载程序、需要访问复杂结构体的人,那得上 S7 协议或者 OPC UA。
后面按「报文怎么长 → PLC 怎么配 → C# 怎么写 → 哪儿会翻车」的顺序走一遍,代码可以直接抄。
2. 报文拆到字节:MBAP 头、功能码与一次读事务的完整回放
Modbus TCP 的报文结构简单得有点不像工业协议:一个固定 7 字节的头,加一段变长的数据区。正因为简单,出问题的时候往往不是「协议不懂」,而是「字节对不上」。把报文拆到字节层面看一遍,后面所有的玄学问题都会变得有迹可循。
2.1 MBAP 七字节头:四个字段各自的活
每一帧 Modbus TCP 都以 7 字节的 MBAP(Modbus Application Protocol)头开始,紧接着是 PDU。头部四个字段的分工很明确:
| 偏移 | 字段 | 长度 | 取值 | 作用 |
|---|---|---|---|---|
| 0 | 事务标识符 Transaction ID | 2 字节 | 自增,如 0x0001 | 请求与响应配对,客户端自己维护 |
| 2 | 协议标识符 Protocol ID | 2 字节 | 固定 0x0000 | 标识这是 Modbus,其他值直接丢弃 |
| 4 | 长度 Length | 2 字节 | 后续字节数 | 等于「单元标识符 1 字节 + PDU 长度」 |
| 6 | 单元标识符 Unit ID | 1 字节 | 通常 1 | 网关场景下用于区分从站,直连时一般不参与寻址 |
有两点是新手最容易搞错的。第一,长度字段不包含这 7 字节头本身,也不包含它自己那 2 字节,它统计的是从第 7 字节(单元标识符)开始往后的所有字节。第二,事务标识符可以随便填,但必须和响应对上;如果客户端是单线程一问一答,用自增计数器最省事,一旦出现并发就会出问题,我在第 5 章会讲这个坑。
结构体定义上,很多人喜欢用[StructLayout]去映射整个头,这不推荐。手工拼字节数组反而更清楚,也避开了大小端和对齐的暧昧地带。
2.2 一次 0x03 读事务的完整字节回放
假设 C# 侧要读保持寄存器,起始地址 0,读 2 个寄存器(对应 PLC 里 DB1 从偏移 0 开始的两个 WORD),单元标识符填 1。请求帧长这样:
# 请求(共 12 字节:7 字节 MBAP + 5 字节 PDU) 00 01 00 00 00 06 01 03 00 00 00 02 |TID | |PID | |LEN| |U| |FC| |起始地址| |数量| # 响应(共 13 字节:7 字节 MBAP + 6 字节 PDU) 00 01 00 00 00 07 01 03 04 12 34 56 78 |TID | |PID | |LEN| |U| |FC| |字节数| 两个寄存器的值 |几个数字必须一眼能验算出来:请求的 LEN = 6,因为单元标识符 1 字节 + PDU 5 字节(功能码 1 + 起始地址 2 + 数量 2);响应的 LEN = 7,因为单元标识符 1 + 功能码 1 + 字节数 1 + 数据 4。先把这条算术验算清楚,再去看代码,能省掉一半调试时间。
响应里的数据12 34 56 78就是两个寄存器的原始值,第一个寄存器是 0x1234,第二个是 0x5678。注意这里的字节顺序是大端——高字节在前。而 S7-1200 的 DB 块里存的 WORD 也是大端,所以寄存器层面的字节序是一致的,真正会出错的是把两个寄存器拼成 32 位数值的那一步,这个放到第 4 章展开。
异常响应也要认识。当功能码最高位被置 1(比如 0x83),说明 PLC 拒绝了这次请求,第 2 个数据字节是异常码:0x01 非法功能码、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。现场遇到 0x02,八成是地址超出了 MB_HOLD_REG 指向的区域。
2.3 功能码选型:四种数据区与 S7-1200 的对应关系
Modbus 有四张表,对应的功能码不一样。S7-1200 用 MB_SERVER 做从站时,实际只实现了保持寄存器这一张表的读写,其他几张要么是空实现,要么在指令里另做映射,这一点必须提前知道,否则写了一堆 0x01 读线圈的代码,最后发现 PLC 那边根本没数据。
| 数据区 | 读功能码 | 写功能码 | 单位 | S7-1200 侧对应 |
|---|---|---|---|---|
| 线圈 Coils | 0x01 | 0x05(单个)/ 0x0F(多个) | 位 | MB_SERVER 的 MB_HOLD_REG 区内可按位访问 |
| 离散输入 Discrete Inputs | 0x02 | 不可写 | 位 | 通常不做映射 |
| 保持寄存器 Holding Registers | 0x03 | 0x06(单个)/ 0x10(多个) | 16 位 | MB_HOLD_REG 指向的 DB 区 |
| 输入寄存器 Input Registers | 0x04 | 不可写 | 16 位 | 通常不做映射 |
实际工程里我用得最多的就三个:0x03 读、0x06 写单个、0x10 写多个。0x06 的好处是响应是原样回显,不用解析数据,确认写成功很直接;0x10 适合一次写一串配方参数或者一批设定值。
还有一条数量约束得记住:单次 0x03 读的寄存器数量上限是 125 个(PDU 长度限制导致),0x10 单次写的上限是 123 个。超过就拆包。这个限制不是 PLC 决定的,是协议本身定的,所以在 C# 侧做封装的时候就要把边界卡住,别等到现场才崩。
3. PLC 侧配置:MB_SERVER 块、TCON_IP_v4 与保持寄存器区
上位机代码写得再漂亮,PLC 那边没开对,一样连不上。而且 PLC 侧的配置错误往往表现得很「安静」——不是报错,而是连接能建立、请求发出去、回来的数据全是 0。所以这一章的顺序是先确认前提条件,再把 MB_SERVER 的参数一个个填明白。
3.1 动手前先确认三件事:IP 网段、固件、端口占用
第一件,IP 网段。PLC 的 PROFINET 接口地址和笔记本的网卡地址必须在同一网段,比如 PLC 是 192.168.0.1/24,笔记本就得配 192.168.0.x。这一步听起来废话,但我在现场见过太多次「ping 不通怀疑网线」的情况,最后还是网段不对。配完先用 ping 验一下,ping 通了再往下走。
第二件,固件与指令可用性。MB_SERVER 属于开放式用户通信指令,在较新的 CPU 固件里才内置。翻一下设备手册确认你的 CPU 型号和固件版本支持,如果不支持,就只能靠 CPU 上的其他通信资源另想办法,别在最开始就选错路线。
第三件,端口占用。Modbus TCP 的标准端口是 502,这个端口号在部分操作系统上属于保留端口范围,且 PLC 侧的 MB_SERVER 会独占它。同一个 CPU 上不能有两个实例抢 502,同一台笔记本上也不能有两个程序同时连同一条连接(除非 PLC 侧允许多连接)。确认方式很简单:先用一个已知好用的调试工具连一次,能通再写代码,这是最省时间的做法。
3.2 MB_SERVER 的引脚与 TCON_IP_v4 参数表
MB_SERVER 在指令树里位于「扩展指令 → 通信」分类下,不同版本的组态软件归类位置有细微差别,找不到就在搜索框里直接搜指令名。它必须放在循环执行的块里调用——OB1 或者循环中断 OB 都行,绝对不能放在启动 OB 里,放启动 OB 只会执行一次,连接建立完就没人管了。
关键引脚有三个:CONNECT、MB_HOLD_REG、以及输出侧的NDR/DR/ERROR/STATUS。CONNECT接的是一个TCON_IP_v4结构体,参数填法如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| InterfaceId | 64(以实际硬件标识符为准) | 在设备组态里点开 PROFINET 接口,属性里能看到硬件标识符 |
| ID | 1 | 连接号,同一 CPU 内不重复即可,多个 MB_SERVER 实例要错开 |
| ConnectionType | 16#0B | TCP 连接 |
| ActiveEstablished | FALSE | 被动建立,PLC 当服务器 |
| RemoteAddress | 0.0.0.0 | 被动连接不限定对端地址 |
| RemotePort | 0 | 被动连接时无意义,填 0 |
| LocalPort | 502 | Modbus TCP 标准端口 |
ActiveEstablished这一项填错是最典型的翻车点。填成 TRUE,PLC 会反过来去连别人,结果就是上位机这边一直连不上,PLC 那边一直在重试,两边都觉得自己没错。填 FALSE 之后,PLC 进入监听状态,等上位机来连。
NDR和DR是诊断输出,第一次联调的时候建议把它们接到一个监控表里看着:NDR置位说明有新的写请求到达,DR置位说明有新的读请求到达。如果连上了但这两个位从来不动,说明报文根本没进到指令里,问题在更下层(网络或者端口)。ERROR和STATUS组合起来能定位到具体错误码,比盲猜快得多。
3.3 保持寄存器区:DB 必须非优化访问,指针怎么给
MB_HOLD_REG接的是一个指针,指向一块数据区,这块区域就是上位机能通过 0x03/0x06/0x10 访问的全部空间。常见做法是单独建一个全局 DB,里面定义一个 WORD 数组或者若干个具体变量。
这里有个硬性要求:这个 DB 必须关掉「优化的块访问」。优化的块访问下,变量没有固定偏移,指针寻址没有意义,指令要么报错要么访问到错误的位置。在 DB 的属性里把「优化的块访问」取消勾选,编译后每个变量才会显示偏移量。
指针的写法有两种,效果一样:
// 写法一:指向数组首元素 "DataBuffer".HoldReg // 写法二:直接写地址和长度 P#DB1.DBX0.0 WORD 100写法二里的100是字(WORD)的数量,也就是这块区域能覆盖 100 个保持寄存器。我一般会把这块区域开得比实际需要大一些,留出扩展余量,但也不会开到几千——区域越大,上位机一次误读波及的范围越大。
地址映射关系必须在这里定死,并且写进文档。如果MB_HOLD_REG指向DB1.DBW0,那么上位机读起始地址 0 拿到的就是 DB1.DBW0,读地址 1 拿到 DB1.DBW2,以此类推。如果指针指向的是DB1.DBW100,那么上位机地址 0 对应的是 DB1.DBW100,而不是 DBW0。这个偏移量是最容易在后期维护时被忘掉的东西,我习惯在 DB 的注释里直接写上「Modbus 地址 0 对应本变量」。
数据类型上,建议把整个区域定义成Array[0..99] of Word,然后在 PLC 程序里自己做拆分和类型转换,而不是在 DB 里混着放 REAL、DINT、INT。混着放不是不行,但每个变量占几个寄存器、地址怎么算,全靠人工数,改一次结构就全乱。用统一的 WORD 数组当缓冲区,PLC 内部再搬运,是抗变更能力最强的做法。
3.4 下载后的第一次验证:不看代码先看灯
配置完下载到 PLC,先别急着开 Visual Studio。用任意一个通用的 Modbus 调试工具,填上 PLC 的 IP、端口 502、单元号 1,读起始地址 0、数量 10。如果能读到数据,说明 PLC 侧全对,后面所有问题都在 C# 侧;如果读不到,就别浪费时间看 C# 代码了。
这一步的判断价值极高,因为它把「两端的问题」硬生生切成了「一端的问题」。我在现场的血泪经验是:一定要在最开始花五分钟做这个二分,否则很容易在 C# 里改两小时代码,最后发现是 PLC 的 DB 忘了取消优化访问。
顺带看一眼 PLC 的连接资源占用。S7-1200 的开放式用户通信连接数是有限的,不同型号不一样,一个 MB_SERVER 实例占一条。如果上位机程序反复建连断连而不释放,连接资源会被耗尽,表现就是「跑一段时间之后再也连不上,重启程序又好了」。这个问题在第 5 章和第 6 章都会提到。
4. C# 侧实现:手搓 MBAP 客户端与读写封装
这里不走第三方库,直接从TcpClient手搓。理由有两个:一是 Modbus TCP 的报文简单到不值得为它引入一个依赖,二是手搓之后,任何异常报文你都能一眼看出是哪几个字节不对。第三方库当然可以用,把这一章当作理解底层的过程,看完再去用库会顺手很多。
4.1 连接:TcpClient 的三个参数决定后面稳不稳
连接部分有三行代码值得单独拎出来说,它们直接决定了长时间运行的稳定性。
public sealed class ModbusTcpClient : IDisposable { private readonly TcpClient _tcp; private readonly NetworkStream _stream; private ushort _tid; // 事务号,每发一帧自增 private const int IoTimeoutMs = 1000; // 单次收发超时,按现场网络调 public ModbusTcpClient(string ip, int port = 502, int connectTimeoutMs = 2000) { _tcp = new TcpClient(); var task = _tcp.ConnectAsync(ip, port); if (!task.Wait(connectTimeoutMs)) throw new TimeoutException($"连接 {ip}:{port} 超过 {connectTimeoutMs}ms"); _tcp.NoDelay = true; // 关掉 Nagle,几十字节的小报文别攒 _tcp.ReceiveTimeout = IoTimeoutMs; _tcp.SendTimeout = IoTimeoutMs; _stream = _tcp.GetStream(); } }NoDelay = true是关键。Modbus 报文普遍只有十几字节,Nagle 算法会把它们攒着等确认,在局域网里表现为「偶尔慢 200 毫秒」,很难复现也很难查。工业现场对时延敏感,直接关掉。
ReceiveTimeout是兜底。如果 PLC 断电或者网线拔了,Read会一直阻塞,没有超时的话整个采集线程就死在那儿。设成 1 秒是个折中值,远小于常见轮询周期,又不至于因为一次网络抖动就误判。
connectTimeoutMs单独设,因为ConnectAsync不受ReceiveTimeout影响。默认的 TCP 连接超时可能长达二十秒,不加这个,程序启动时 PLC 没上电,界面就卡在那儿不动。
另外提醒一句:ConnectAsync失败时会抛AggregateException,里面包着真实的SocketException,取值的时候记得Flatten()或者取InnerException,否则日志里只有一句没用的「One or more errors occurred」。
4.2 组包与收包:MBAP 封装与半包处理
组包就是一个固定格式的字节数组填充,重点是长度字段的算法:
// pdu 传的是「功能码之后的数据」,不含功能码本身 private static byte[] BuildFrame(ushort tid, byte unitId, byte fc, byte[] pdu) { var frame = new byte[8 + pdu.Length]; frame[0] = (byte)(tid >> 8); // 事务号高字节 frame[1] = (byte)(tid & 0xFF); // 事务号低字节 frame[2] = 0x00; // 协议号固定 0 frame[3] = 0x00; int len = pdu.Length + 2; // 单元标识符 1 + 功能码 1 + 数据 n frame[4] = (byte)(len >> 8); frame[5] = (byte)(len & 0xFF); frame[6] = unitId; frame[7] = fc; Buffer.BlockCopy(pdu, 0, frame, 8, pdu.Length); return frame; }收包比组包更容易出事。TCP 是字节流,一次Read拿到的可能只有半个报文,也可能是两个报文粘在一起。正确的做法是先读定长的 7 字节头,从头里解析出长度,再按长度读剩下的部分:
private byte[] ReadExactly(int count) { var buf = new byte[count]; int off = 0; while (off < count) { int n = _stream.Read(buf, off, count - off); if (n <= 0) throw new IOException("对端关闭了连接"); off += n; } return buf; }这个循环看着平平无奇,但它是「粘包/半包」问题的唯一正解。所有「偶尔解析出错」「偶尔超时」的偶发问题,八成都能追溯到某处用了单次Read而不是循环读满。这段代码我一般是复制粘贴到每个新项目里的,属于后悔药类型的基础设施。
_tid用自增计数器即可,但要注意它的语义:它只在一个连接内有效,重连之后可以从任意值开始,不需要和 PLC 协商。真正需要保证的是同一个连接上不要并发发请求,这点在第 6 章讲。
4.3 读保持寄存器:0x03 的完整实现
把组包、收包、异常判断串起来,读保持寄存器的方法就完整了:
public ushort[] ReadHoldingRegisters(ushort startAddress, ushort quantity, byte unitId = 1) { if (quantity < 1 || quantity > 125) throw new ArgumentOutOfRangeException(nameof(quantity), "单次读数量必须在 1~125 之间"); ushort tid = ++_tid; var pdu = new byte[4]; pdu[0] = (byte)(startAddress >> 8); pdu[1] = (byte)(startAddress & 0xFF); pdu[2] = (byte)(quantity >> 8); pdu[3] = (byte)(quantity & 0xFF); var frame = BuildFrame(tid, unitId, 0x03, pdu); _stream.Write(frame, 0, frame.Length); var header = ReadExactly(7); // 先拿 MBAP 头 int len = (header[4] << 8) | header[5]; var body = ReadExactly(len); // body[0]=单元号 body[1]=功能码 if ((body[1] & 0x80) != 0) // 最高位置 1 表示异常响应 throw new InvalidOperationException( $"Modbus 异常:功能码 0x{body[1]:X2},异常码 0x{body[2]:X2}"); int byteCount = body[2]; var result = new ushort[byteCount / 2]; for (int i = 0; i < result.Length; i++) result[i] = (ushort)((body[3 + i * 2] << 8) | body[3 + i * 2 + 1]); return result; }几个参数的含义值得抠一下。startAddress是从零开始的偏移,不是 40001 那种人读地址;如果 PLC 那边文档写的是 40001,这里就填 0。quantity上限 125 是协议约束,超了 PLC 会直接回异常码 0x03。unitId在直连场景下填 1,不要试图用它来区分多台 PLC——多台 PLC 靠 IP 区分,单元号在直连时不参与寻址。
异常分支不要偷懒写成catch吞掉。Modbus 的异常码信息量很大:0x02 是地址越界,说明你要读的范围超出了MB_HOLD_REG指向的区域;0x03 是数量或数值非法;0x04 是从站内部故障。把异常码原样抛到日志里,现场排查能省几十分钟。
4.4 写入:0x06 与 0x10 的分工
写单个寄存器用 0x06,响应是请求的原样回显,所以只要确认收到 8 字节、功能码没被置高位,就算写成功:
public void WriteSingleRegister(ushort address, ushort value, byte unitId = 1) { ushort tid = ++_tid; var pdu = new byte[4]; pdu[0] = (byte)(address >> 8); pdu[1] = (byte)(address & 0xFF); pdu[2] = (byte)(value >> 8); pdu[3] = (byte)(value & 0xFF); var frame = BuildFrame(tid, unitId, 0x06, pdu); _stream.Write(frame, 0, frame.Length); var header = ReadExactly(7); var body = ReadExactly((header[4] << 8) | header[5]); if ((body[1] & 0x80) != 0) throw new InvalidOperationException($"写寄存器失败,异常码 0x{body[2]:X2}"); }写多个寄存器用 0x10,PDU 结构多一个字节数字段:
public void WriteMultipleRegisters(ushort startAddress, ushort[] values, byte unitId = 1) { if (values.Length < 1 || values.Length > 123) throw new ArgumentOutOfRangeException(nameof(values), "单次写数量必须在 1~123 之间"); ushort tid = ++_tid; int byteCount = values.Length * 2; var pdu = new byte[5 + byteCount]; pdu[0] = (byte)(startAddress >> 8); pdu[1] = (byte)(startAddress & 0xFF); pdu[2] = (byte)(values.Length >> 8); pdu[3] = (byte)(values.Length & 0xFF); pdu[4] = (byte)byteCount; // 字节数 = 寄存器数 × 2 for (int i = 0; i < values.Length; i++) { pdu[5 + i * 2] = (byte)(values[i] >> 8); pdu[6 + i * 2] = (byte)(values[i] & 0xFF); } // 发送与接收部分与 0x06 相同,响应只回显地址和数量,不含数据 }选择逻辑很直白:改一个设定值用 0x06,改一组配方参数用 0x10。不要为了「统一」把所有写操作都用 0x10 包一层数组,多出来的十几个字节在低频场景无所谓,但在几十毫秒级的轮询里是纯浪费。
还有一个实际使用中的细节:写操作后如果立刻读同一个地址,可能读回旧值。原因是 PLC 的 MB_SERVER 在循环 OB 里执行,写请求被接收后要等下一个扫描周期才反映到 DB 上。加一个扫描周期的延时再读,或者干脆信任写响应、不做回读,都比「写了立刻读然后怀疑人生」要好。
4.5 类型转换:REAL、DINT 与字符串的落地写法
寄存器是 16 位的,业务数据是 32 位浮点、32 位整型、字符串,中间必然要做拼接。核心原则只有一条:先把两个寄存器还原成大端字节序列,整体反转一次,再交给BitConverter。
public static float ToSingle(ushort[] regs, int index) { var be = new byte[4]; be[0] = (byte)(regs[index] >> 8); // 第一个寄存器的高字节 be[1] = (byte)(regs[index] & 0xFF); // 第一个寄存器的低字节 be[2] = (byte)(regs[index + 1] >> 8); // 第二个寄存器的高字节 be[3] = (byte)(regs[index + 1] & 0xFF); Array.Reverse(be); // 大端转小端,喂给 BitConverter return BitConverter.ToSingle(be, 0); } public static int ToInt32(ushort[] regs, int index) { var be = new byte[4]; be[0] = (byte)(regs[index] >> 8); be[1] = (byte)(regs[index] & 0xFF); be[2] = (byte)(regs[index + 1] >> 8); be[3] = (byte)(regs[index + 1] & 0xFF); Array.Reverse(be); return BitConverter.ToInt32(be, 0); }写回去就是反过来:BitConverter.GetBytes(value)得到小端,Array.Reverse成大端,再按顺序拆成两个 ushort 塞进寄存器数组。
这里有个流传很广的错误写法:先交换两个 ushort 的顺序,再对每个 ushort 做BitConverter。这样等于反转了两次,结果又错回去了。很多人在这一步绕了很久,就是因为抄了一份不知来源的代码,没做验算。验算方法很简单:拿一个已知值,比如 1.0f 的 IEEE 754 是0x3F800000,那么两个寄存器应该分别是0x3F80和0x0000。用这个值跑一遍你的转换函数,对得上就对了。
有符号 16 位整数要注意符号位。寄存器里拿到的ushort如果是0xFFFF,业务上可能代表 -1,直接强转(short)就能拿到正确值,别用Convert.ToInt16去绕。
字符串的处理建议改结构。PLC 里用STRING类型,前两个字节是最大长度和实际长度,用寄存器搬运很不划算。常见做法是在 DB 里定义Array[0..31] of Char,每个字符占一个字节,两个字符挤一个寄存器,C# 侧按字节拼接后按编码转字符串,并注意去掉尾部的空字符。控制字符和中文不要放进这条通道,编码问题够你调一下午。
5. 避坑清单:六个把联调拖到深夜的现场问题
下面这几条都是我在不同现场真实撞过的,按「现象 → 原因 → 解决」写,遇到对得上的直接照方抓药。
5.1 连接类问题:ping 得通却连不上
现象一:笔记本能 ping 通 PLC,但 C# 一连接就抛超时,调试工具也连不上。
原因通常是三个之一:PLC 侧 MB_SERVER 没下载或没在循环 OB 里调用;ActiveEstablished填成了 TRUE,PLC 在主动往外连而不是监听;端口不是 502 或者被别的实例占了。还有一个容易被忽略的——某些安全软件会拦截非常用端口的出站连接,日志里看不到任何异常,只是连接超时。
解决顺序是固定的:先在 PLC 在线监控里确认 MB_SERVER 的STATUS输出没有报错,再确认ActiveEstablished = FALSE,然后用调试工具从同一台机器连一次。三步都过了还不行,临时关掉安全软件再试一次。我一般会把这个顺序贴在工位上,因为慌乱的时候人容易跳步。
现象二:单次连接正常,但程序跑了几十次重连之后再也连不上,重启程序又好了。
原因是连接没释放。TcpClient不Dispose,底层 socket 会停留在某种中间状态,PLC 侧的连接资源也一直被占着。S7-1200 的开放式用户通信连接数是有限的,占满之后新的连接请求直接被拒。
解决办法是给ModbusTcpClient实现IDisposable,Dispose里依次_stream?.Dispose()、_tcp?.Close(),并且用using包住每次短连接。更好的做法是根本不做短连接,全程序复用一个长连接,这就是第 6 章的内容。
5.2 数据类问题:地址、字节序与符号
现象三:读回来的值全是 0,或者一直不跟着 PLC 里的变量变。
最常见的原因是MB_HOLD_REG指向的那块 DB 开了「优化的块访问」。优化访问下变量没有固定偏移,指针寻址拿到的是一片空区域,指令不报错,返回全 0。解决就是把 DB 属性里的「优化的块访问」取消勾选,重新编译下载。
第二个可能是指针和上位机地址的偏移没对上。如果MB_HOLD_REG指向的是DB1.DBW100,上位机读地址 0 拿到的是 DBW100,而不是 DBW0。这种情况的表现是「有数据但不对」,值看着像另一个变量的。解决办法是把映射关系写死在注释里,改结构的时候同步改文档。
现象四:读 REAL 变量得到天文数字、极小值,或者 -0.00。
这就是字节序问题,具体分析在第 4.5 节。三个检查点:是不是把两个 ushort 交换了顺序又反转了字节(双重反转);是不是只反转了字节没考虑寄存器顺序;是不是在 PLC 侧变量本身就不是 REAL 而是 DINT。用 1.0f 对应的0x3F80 0x0000做基准值验算,一次就能定位。
现象五:写进去的值跑到了隔壁变量上。
地址差一位。Modbus 的人读地址从 40001 开始,协议地址从 0 开始,很多文档写的是人读地址。如果文档写「40001 对应一号设定值」,C# 里应该填 0 而不是 1 或者 40001。这类问题在有多段地址映射的 PLC 程序里特别容易出,我的习惯是在 C# 侧定义一个常量类,把每个业务量对应的协议地址写成具名常量,禁止在业务代码里出现裸数字。
5.3 时序与资源类问题:跑一段时间就断
现象六:程序白天跑得好好的,晚上或者第二天早上发现采集停了,日志里全是超时。
原因通常是并发。多个线程共用一个TcpClient,两个请求的字节交错写进同一个流,PLC 那边收到的报文是残片,返回的响应也就对不上事务号,客户端在等一个永远不来的响应。表现是偶发超时,频率随并发度上升。
解决办法是加一把互斥锁,把「发请求 + 收响应」这一整段串行化,用SemaphoreSlim(1, 1)或者lock都行。Modbus TCP 理论上支持流水线,但很多 PLC 的从站实现不处理乱序响应,串行化是最稳的选择。串行化之后单次往返的成本是固定的,要提升吞吐就靠合并读(第 6 章),而不是靠并发。
还有一种情况是网络设备导致的空闲断连。交换机的会话表、防火墙的空闲超时,都可能把一条长时间没流量的 TCP 连接悄悄清掉,而双方都不知道。表现是「隔几分钟第一次请求必超时,之后就好了」。解决靠心跳:即使没有数据变化,也每 10~30 秒读一次固定寄存器,保持连接活跃。
6. 长连接与批量读:把偶发超时压到可接受范围
采集程序的稳定性,八成取决于连接管理和请求合并这两件事,跟代码写得多优雅关系不大。
6.1 连接复用与断线重连
我的做法是全程序只维护一个ModbusTcpClient实例,配一个心跳线程和一个重连循环。所有业务读写都通过同一个入口,内部用SemaphoreSlim(1, 1)串行化。断线检测不靠额外的探测,靠请求本身:任何一次收发抛异常,就把连接标记为不可用,交给重连线程处理。
重连用退避策略,1 秒、2 秒、5 秒、10 秒,上限 30 秒。不做固定间隔的疯狂重试,那样在 PLC 断电期间会刷满日志,也会让 PLC 侧的连接资源反复处于半开状态。重连成功后把退避计数清零。
心跳很轻,读 1 个寄存器就够了,但要读一个真实存在的地址,不要读边界外的地址然后忽略异常——那样连异常处理都被训练成「吞掉」,真出问题时你就看不见了。
6.2 批量读的合并策略与收益估算
假设一个采集周期要读 40 个量,其中 30 个集中在地址 0~29,另外 10 个在地址 100~109。两种做法的差距很直观:
| 做法 | 事务数 | 局域网往返耗时(按单次 2ms 估算) | PLC 侧 MB_SERVER 调用次数 |
|---|---|---|---|
| 逐个读 40 次 | 40 | 约 80ms | 40 |
| 合并为两段读 | 2 | 约 4ms | 2 |
合并的原则是:地址连续或间隔很小的一律合并,中间夹着的无用寄存器读回来丢掉,成本远低于多一次往返。间隔大到什么程度就不值得合并?我的经验阈值是间隔超过 20 个寄存器就拆段,因为多读的字节数开始接近一次往返的开销。当然这个阈值跟网络质量强相关,跨网段、走无线的场景可以放宽一些。
合并之后业务代码不能直接拿裸数组到处索引,那样改一个地址全盘崩。加一层映射:用常量类定义每个量的地址和类型,读取时按段批量拉,再按映射解析成强类型对象。这一层的价值在后期改 PLC 地址表的时候体现得淋漓尽致。
6.3 上线前的三层验证
第一层,用通用调试工具手工读一次,确认 PLC 侧通、地址对、值合理。第二层,用 C# 写一个最小可运行的控制台程序,只做一件事:连上、读一段、打印十六进制和解析值。这一层要打印原始字节,不要只打印解析后的值,否则字节序错了你也不知道错在哪一层。第三层才是接业务代码。
第三层跑起来之后,我还会做两个破坏性测试:一是拔网线三十
本文还有配套的精品资源,点击获取