ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

.NET上位机PLC Modbus通讯Demo:组帧、编解码与轮询避坑

.NET上位机PLC Modbus通讯Demo:组帧、编解码与轮询避坑

简介:面向工业自动化上位机开发者的汇川PLC Modbus TCP通讯示例工程,采用VB.NET编写,演示如何通过Modbus TCP读写PLC寄存器与线圈,适合已具备.NET基础、希望打通上位机与现场PLC数据交互的开发者参考。压缩包共33个文件、约141KB,以vb源码为主体,辅以sln解决方案、vbproj工程文件、exe可执行程序、dll类库及resx/resources界面资源等,构成可直接编译调试的完整工程。已有1148人学习下载。读者可从中了解Modbus请求报文组织、功能码与地址映射、响应解析及通信异常处理思路,并借助现成的窗体界面与通讯类代码,快速验证连接参数、读写流程和调试方法,为后续项目移植与通讯稳定性优化提供可复用的参考。

1. 现场那盏指示灯亮着,读回来的温度却是 0

接手一个 .NET 上位机项目时,最容易被低估的往往不是业务逻辑,而是 PLC 和 Modbus 通讯这段看起来最枯燥的链路。上位机界面上的温度一直显示 0,指示灯却正常闪烁,抓了半小时报文才发现读的是一段空寄存器。这份 PLC Modbus 通讯 Demo 就是把这类零碎环节串起来的:连接建立、报文组帧、读写寄存器、类型转换、断线重连,全部用 .NET 代码写明白。它适合刚接手工业上位机、第一次跟 PLC 打交道的开发者照着跑通,也适合熟手拿它当调试基座——遇到对不上数据的时候,先拿这个 Demo 单点试一下,比在业务代码里边改边猜快得多。

2. 项目结构与通讯选型:Demo 里其实藏了三层

2.1 目录拆分:通讯层、映射层、业务层各管一摊

这个 Demo 的代码量不大,但结构是照着能扩产的思路切的。拿到压缩包先看目录,理解每个文件负责什么,后面改代码就不会乱塞。

路径职责改动频率
Modbus/ModbusTcpClient.cs建连、组帧、收发、超时、重连低,基本不碰
Modbus/ModbusFunction.cs功能码常量(0x01/0x03/0x04/0x06/0x10)低
Modbus/ModbusException.cs异常响应码封装低
Mapping/AddressMap.cs业务点位与寄存器地址的对照表高
Converters/RegisterCodec.csushort 数组与 int/float/string 的互转中
Services/DeviceService.cs按业务语义读写点位,对外只暴露强类型方法高
Services/Poller.cs定时轮询、批量刷新、失败退避中
appsettings.json设备 IP、端口、单元号、点位配置现场必改

层次划分的意思是:ModbusTcpClient不认识"温度"这个词,它只知道"第 0 号单元的第 40001 号地址起读 2 个寄存器";DeviceService反过来,对外只有ReadTemperatureAsync()这种签名,不暴露寄存器细节。中间靠AddressMap做映射。新人最容易犯的错是把地址写死在业务方法里,改一次点位要翻十个文件,这是我见过最多的返工来源。

AddressMap用什么形式组织,取决于点位数量。点位在 50 个以内,用静态字典加枚举就够;超过几百个,建议直接落库或者用配置文件驱动,代码里一个地址都不写。Demo 里给的是字典写法:

// Mapping/AddressMap.cs public static class AddressMap { // key 用业务名,value 是 (单元号, 起始地址(0基), 寄存器数量, 功能码) private static readonly Dictionary<string, PointDef> Map = new() { ["Temperature"] = new PointDef(UnitId: 1, Start: 0, Count: 2, Func: 0x03), ["RunState"] = new PointDef(UnitId: 1, Start: 10, Count: 1, Func: 0x03), ["SetSpeed"] = new PointDef(UnitId: 1, Start: 20, Count: 1, Func: 0x03), ["StartCmd"] = new PointDef(UnitId: 1, Start: 0, Count: 1, Func: 0x01), // 线圈 }; public static PointDef Get(string name) => Map.TryGetValue(name, out var def) ? def : throw new KeyNotFoundException($"点位 {name} 未在 AddressMap 中登记"); } public record PointDef(byte UnitId, ushort Start, ushort Count, byte Func);

Start这里必须强调是0 基地址。上位机界面上写的40001,落到报文里发给从站的是0x0000;40101发的是0x0064。这个偏移量差一位,读回来的数据要么报非法地址,要么读到隔壁点位,排查起来相当玄学。我在PointDef里干脆只允许填 0 基地址,注释写死"填 40001 请自行减 1",就是为了掐掉这个坑。

Func字段区分功能码很关键。保持寄存器走0x03,输入寄存器走0x04,线圈走0x01。PLC 侧同一个"速度"值,工程师可能挂在保持寄存器区,也可能挂在输入寄存器区,功能码和区域必须对应,否则从站回一个0x02非法数据地址,报文层面看起来毫无异常。

2.2 传输层选型:为什么 Demo 默认走 Modbus TCP

Demo 里同时留了串口和以太网两条路的代码,但主流程走 TCP。选型不是随便定的,现场条件差别很大。

维度Modbus TCPModbus RTU
物理层网口,RJ45RS485/RS232 双绞线
一台上位机能带几台设备理论上几十台并发一条总线串几十台,轮询排队
单帧响应毫秒级受波特率限制,9600 下一来一回 20ms 起步
抓包调试网卡抓包直接看明文需要串口监听,接线容易反
抗干扰依赖交换机与布线长距离总线抗共模干扰较好,但要终端电阻
上云/跨网段天然支持需要串口服务器转换

车间里新上的设备基本都带网口,直接IP:502就能连,省掉串口服务器和驱动安装。串口那条路留着是为了兼容老设备:SerialPort打开后按 RTU 帧格式(地址 + 功能码 + 数据 + CRC16)收发,结构上跟 TCP 的差别只在帧头和校验,ModbusTcpClient抽象出的ITransport接口换一个实现就行。

有一点容易被忽略:Modbus TCP 的单元标识(Unit ID)在纯以太网直连时通常填 1 或者 0xFF 都行,从站不一定校验;但只要中间挂了串口服务器或者网关,这个字段就变成了下游 RTU 的站号,填错就一个字节都读不回来。Demo 把它做成配置项而不是常量,就是被这个坑教过一次。

2.3 地址映射核对:先确认区域,再确认偏移

调试期最有价值的动作不是写代码,是拿一张 Excel 把点位对清楚。Demo 的注释里留了一份示例对照表,格式是这样的:

业务点Modbus 区域功能码从站 0 基地址PLC 侧软元件
温度保持寄存器0x030双字,占用 2 个寄存器
运行状态保持寄存器0x0310单字
设定速度保持寄存器0x0320单字
启动命令线圈0x010位

PLC 编程侧给出的地址往往不是 Modbus 地址,而是它自己的软元件编号。这中间的换算关系取决于 PLC 型号和通讯模块的映射规则,Demo 不做自动换算,只在AddressMap里固化最终结果。我一般的做法是:先让 PLC 侧工程师在程序里把一个已知常量(比如 1234)写进目标地址,上位机用 Demo 单点读一次,读出来是 1234 就说明映射对了,再去接业务。

提示:不要拿"读出来不报错"当成映射正确的证据。读到一个合法但错误的寄存器,从站同样会正常返回,只是数据不对。必须用已知值做点对点验证。

3. 通讯主链路:一帧 Modbus TCP 报文是怎么拼出来又拆开的

3.1 MBAP 头加 PDU:组帧的每个字节都要对得上

Modbus TCP 的完整报文叫 ADU = MBAP(7 字节)+ PDU。PDU 里再分功能码和数据段。组帧代码看起来机械,但只要错一个字节,从站要么不回,要么回一个异常码,现场很难靠肉眼判断。Demo 把组帧拆成两个方法,头和数据分开拼。

// Modbus/ModbusTcpClient.cs /// <summary> /// 组装一条读寄存器请求的完整 ADU /// </summary> /// <param name="tid">事务号,一问一答靠它配对</param> /// <param name="unitId">单元标识,网关场景下等于 RTU 站号</param> /// <param name="func">功能码,0x03 读保持寄存器 / 0x04 读输入寄存器</param> /// <param name="start">起始地址,0 基</param> /// <param name="count">读取数量,1~125</param> private static byte[] BuildReadRequest(ushort tid, byte unitId, byte func, ushort start, ushort count) { // PDU 长度 = 功能码1 + 起始2 + 数量2 = 5 var frame = new byte[7 + 5]; // ---- MBAP ---- frame[0] = (byte)(tid >> 8); // 事务标识高字节 frame[1] = (byte)(tid & 0xFF); // 事务标识低字节 frame[2] = 0x00; // 协议标识固定 0 frame[3] = 0x00; frame[4] = 0x00; // 后续长度高字节 frame[5] = 0x06; // 后续长度 = 单元标识1 + PDU 5 frame[6] = unitId; // 单元标识 // ---- PDU ---- frame[7] = func; // 功能码 frame[8] = (byte)(start >> 8); // 起始地址高字节,大端 frame[9] = (byte)(start & 0xFF); // 起始地址低字节 frame[10] = (byte)(count >> 8); // 数量高字节 frame[11] = (byte)(count & 0xFF); // 数量低字节 return frame; }

几个参数值得单独说。tid事务号从 1 递增到 65535 再回绕,作用是在并发或者延迟响应时把请求和响应配对——如果从站回了一个事务号对不上的包,直接丢弃,别解析。frame[5]这个长度字段写的是"单元标识 + PDU"的字节数,读请求固定是 6,写多个寄存器时会变,所以不能写死成常量,得按 PDU 实际长度算。start和count都是大端序,C# 里BitConverter默认小端,手工移位比调BitConverter.GetBytes安全,省得在大小端问题上翻车。

3.2 读保持寄存器:收包、校验、拆数据

发出去只是开头,收包才是真正容易出问题的地方。TCP 是流式的,一次Read不保证拿到完整一帧,尤其在高频轮询或者交换机负载高的时候。Demo 用循环读满长度字段声明的字节数,再校验事务号。

public ushort[] ReadHoldingRegisters(ushort start, ushort count) { if (count < 1 || count > 125) throw new ArgumentOutOfRangeException(nameof(count), "单次读取 1~125 个寄存器"); lock (_sync) // 一条连接同时只允许一问一答 { EnsureConnected(); ushort tid = NextTid(); var req = BuildReadRequest(tid, _unitId, 0x03, start, count); _stream.Write(req, 0, req.Length); // 先读 7 字节 MBAP 头,拿到长度字段 var header = ReadExactly(7, _timeoutMs); ushort respTid = (ushort)((header[0] << 8) | header[1]); if (respTid != tid) throw new InvalidDataException($"事务号不匹配,期望 {tid},收到 {respTid}"); int bodyLen = (header[4] << 8) | header[5]; // 含单元标识 var body = ReadExactly(bodyLen, _timeoutMs); byte func = body[1]; if ((func & 0x80) != 0) // 最高位为 1 表示异常响应 { byte code = body[2]; throw new ModbusException(code, DescribeException(code)); } int byteCount = body[2]; // 后续数据字节数 var result = new ushort[count]; for (int i = 0; i < count; i++) { // body[3] 起是数据区,每寄存器 2 字节,大端 result[i] = (ushort)((body[3 + i * 2] << 8) | body[4 + i * 2]); } return result; } }

NextTid()做自增回绕,EnsureConnected()在连接断开时重建 Socket。lock (_sync)保证同一条 TCP 连接上不会出现两条请求交错——Modbus 是严格的一问一答协议,多线程直接往同一个流里写,返回的报文会对不上号,这是新手最常见的翻车点之一。真要提升吞吐量,正确做法是开多个连接,而不是在一条连接上去掉锁。

ReadExactly的写法是循环读直到凑够字节数或者超时,内部用Socket.Poll或者NetworkStream.ReadTimeout都行,关键是超时要能中断,不能让线程永久挂死:

private byte[] ReadExactly(int length, int timeoutMs) { var buf = new byte[length]; int offset = 0; while (offset < length) { _stream.ReadTimeout = timeoutMs; int n = _stream.Read(buf, offset, length - offset); if (n <= 0) throw new IOException("对端关闭连接"); offset += n; } return buf; }

异常码处理列成表更直观。从站回0x02的时候不要把整条连接关掉——那是地址错了,连接是好的,改成正确地址重试就行;回0x04或者0x05才考虑降级重连。

异常码含义处理方式
0x01非法功能码检查该设备是否支持此功能码,换 0x03/0x04
0x02非法数据地址检查起始地址与数量是否越界
0x03非法数据值检查写入的数量或值是否在从站允许范围
0x04从站设备故障记录日志,退避重试,持续失败则告警
0x05确认(正在处理)等待后重发同一请求
0x06从站设备忙拉长轮询间隔,别高频重试压垮从站

3.3 写寄存器与写线圈:为什么写完必须回读

写操作的报文结构和读不大一样。写单个寄存器用0x06,参数是地址加值;写多个寄存器用0x10,参数是地址、数量、字节数和数据区。写线圈用0x05,值0xFF00表示 ON,0x0000表示 OFF——注意不是 1 和 0,这个值填错从站会当成非法数据值直接拒绝。

public void WriteSingleRegister(ushort address, ushort value) { lock (_sync) { EnsureConnected(); ushort tid = NextTid(); var frame = new byte[7 + 5]; frame[0] = (byte)(tid >> 8); frame[1] = (byte)(tid & 0xFF); frame[4] = 0x00; frame[5] = 0x06; // 单元标识1 + PDU 5 frame[6] = _unitId; frame[7] = 0x06; // 功能码:写单个寄存器 frame[8] = (byte)(address >> 8); frame[9] = (byte)(address & 0xFF); frame[10] = (byte)(value >> 8); frame[11] = (byte)(value & 0xFF); _stream.Write(frame, 0, frame.Length); // 正常响应是请求原样回显:MBAP 7 字节 + 5 字节 PDU var resp = ReadExactly(12, _timeoutMs); if ((resp[7] & 0x80) != 0) throw new ModbusException(resp[8], DescribeException(resp[8])); ushort echo = (ushort)((resp[10] << 8) | resp[11]); if (echo != value) throw new InvalidDataException($"写入值回显不一致:发出 {value},回显 {echo}"); } }

写入的响应基本是请求的复读机,因此校验回显是零成本的健壮性检查。但即便如此,报文层的写入成功不等于设备真的动了。PLC 程序里可能有互锁条件、可能有上升沿触发逻辑、可能这个地址只是中间变量而不是真正下发给变频器的通道。我见过太多"写入返回正常但电机不转"的情况,最后发现写的是只读的镜像寄存器。

所以 Demo 在DeviceService里做了一个约定:所有写操作走"写—回读—比对"三段式。写完立刻按同一地址读一次,比对一致才算成功;点位如果是从站侧计算出来的(写进去会被程序立刻覆盖),就在AddressMap里给它打一个EchoVerify=false标记跳过回读,改成读另一个"执行结果"点位来确认。这个习惯在联调期能省掉大量扯皮。

3.4 轮询调度:别让一个点位拖垮整条链路

真实产线上不会只读一个点。Demo 的Poller用单线程定时循环加批量读取,核心思路是把地址相邻的点位合并成一次请求——读 10 个连续寄存器只要一帧,比逐个读发 10 帧快一个数量级,对从站的负载也小得多。

private async Task PollLoopAsync(CancellationToken ct) { int interval = _options.PollIntervalMs; // 默认 200ms int failCount = 0; while (!ct.IsCancellationRequested) { var sw = Stopwatch.StartNew(); try { foreach (var block in _blocks) // 已按地址连续性合并好的块 { var data = _client.ReadHoldingRegisters(block.Start, block.Count); _cache.Update(block.Key, data); } failCount = 0; interval = _options.PollIntervalMs; } catch (Exception ex) when (ex is IOException or SocketException) { failCount++; // 指数退避,上限 5 秒,避免把断线的从站刷成雪崩 interval = Math.Min(_options.PollIntervalMs * (1 << Math.Min(failCount, 5)), 5000); _logger.LogWarning("轮询失败第 {n} 次,{ms}ms 后重试:{msg}", failCount, interval, ex.Message); } int delay = interval - (int)sw.ElapsedMilliseconds; if (delay > 0) await Task.Delay(delay, ct).ConfigureAwait(false); } }

interval是"周期"而不是"间隔",代码里用Stopwatch扣掉本轮实际耗时,避免固定Delay(200)导致实际周期越来越长。failCount配合位移做退避,连续失败时把间隔拉长到最多 5 秒,这样从站重启或者网络抖动期间不会产生大量无意义的连接尝试。成功后立刻把failCount归零,恢复原始周期。

注意:轮询周期不是越小越好。200ms 对大多数工艺量足够,压到 50ms 以下时很多中小型 PLC 的通讯任务会挤占扫描周期,反而导致控制逻辑抖动。周期该定多少,问 PLC 侧工程师比看代码有用。

4. 数据交互层:.NET 侧的编解码才是出错重灾区

4.1 字序与字节序:同一个浮点数能有四种排列

报文层读回来的永远是一串ushort。要变成业务能用的float、int、string,中间隔着字序和字节序两道关。32 位浮点占两个寄存器,问题就在于"高字在前还是低字在前",不同厂商、不同型号、甚至同一型号的不同固件都可能不一样,只有四种常见排列:

排列寄存器 0寄存器 1说明
ABCD高字低字大端字序,最常见
CDAB低字高字字交换,也很常见
BADC高字(字节交换)低字(字节交换)单寄存器内字节反序
DCBA低字(字节交换)高字(字节交换)全反序,较少见

Demo 把这层做成可配置的枚举,而不是在代码里写死:

// Converters/RegisterCodec.cs public enum WordOrder { BigEndianABCD, SwapCDAB, ByteSwapBADC, LittleEndianDCBA } public static float ToSingle(ushort[] regs, int offset, WordOrder order) { ushort hi = regs[offset]; ushort lo = regs[offset + 1]; // 处理字序:把高字和低字放回正确位置 if (order is WordOrder.SwapCDAB or WordOrder.LittleEndianDCBA) (hi, lo) = (lo, hi); // 处理字节序:拼成 32 位无符号整数 uint raw = order is WordOrder.ByteSwapBADC or WordOrder.LittleEndianDCBA ? ((uint)SwapBytes(hi) << 16) | SwapBytes(lo) : ((uint)hi << 16) | lo; var bytes = BitConverter.GetBytes(raw); if (!BitConverter.IsLittleEndian) Array.Reverse(bytes); // 统一成小端再交给 BitConverter return BitConverter.ToSingle(bytes, 0); } private static ushort SwapBytes(ushort v) => (ushort)((v >> 8) | (v << 8));

offset是这套浮点在ushort数组里的起始下标,调用方必须保证offset + 1不越界。BitConverter.ToSingle依赖运行平台的大小端,所以先手动把字节排成小端,避免代码从 x86 服务器搬到 ARM 边缘盒子上出问题——这种移植翻车在没有日志的情况下极难定位。

排错时最快的办法不是改代码猜,而是让 PLC 侧写一个已知值,比如把 25.5 写进目标地址,然后上位机把读到的两个ushort原样打印出来。25.5 的 IEEE754 是0x41CC0000,如果读到的是[0x41CC, 0x0000]就是 ABCD,读到[0x0000, 0x41CC]就是 CDAB,一眼就能定。这招比改四次枚举试出来还快,我基本每次都这么干。

4.2 批量读取与缓存:把寄存器数组翻译成业务对象

读取频率和业务刷新频率往往不一致。界面上 1 秒刷一次就够,但循环里可能 200ms 采一轮。Demo 用一层内存缓存解耦:轮询线程只负责把最新寄存器值写进缓存,业务侧从缓存读快照,两边互不阻塞。

public sealed class DataCache { private readonly ConcurrentDictionary<string, ushort[]> _store = new(); public void Update(string key, ushort[] values) => _store[key] = values; public bool TryGet(string key, out ushort[] values) => _store.TryGetValue(key, out values!); // 直接给出强类型视图,业务层不用关心寄存器 public float GetFloat(string key, WordOrder order) { if (!_store.TryGetValue(key, out var regs) || regs.Length < 2) throw new InvalidOperationException($"点位 {key} 尚未采到有效值"); return RegisterCodec.ToSingle(regs, 0, order); } }

ConcurrentDictionary换数组引用是原子的,读侧永远拿到一个完整数组,不会出现读到一半被改写的情况。TryGetFloat在首次启动、还没采到数据时抛异常而不是返回 0——这点很重要,返回 0 会让界面显示"温度 0 度"这种看起来正常但完全错误的数值,比报错危险得多。

4.3 点位增加后的合并策略

项目扩产时点位从 20 个涨到 200 个,逐个读的耗时会线性增长。Demo 里留了一个BlockBuilder,把地址相近的点位自动合并成读取块:

// 按起始地址排序后,把间隙小于 gap 的点位并进同一块 private static List<ReadBlock> BuildBlocks(IEnumerable<PointDef> points, int gap = 8) { var ordered = points.Where(p => p.Func is 0x03 or 0x04) .OrderBy(p => p.Start).ToList(); var blocks = new List<ReadBlock>(); if (ordered.Count == 0) return blocks; var cur = new ReadBlock(ordered[0].Start, ordered[0].Count); foreach (var p in ordered.Skip(1)) { int end = cur.Start + cur.Count; if (p.Start <= end + gap) // 间隙在容忍范围内,合并 cur.Count = (ushort)(Math.Max(end, p.Start + p.Count) - cur.Start); else { blocks.Add(cur); cur = new ReadBlock(p.Start, p.Count); } } blocks.Add(cur); return blocks; }

gap = 8的意思是两块之间空闲寄存器不超过 8 个时,宁可多读几个也要合并成一帧——多读 8 个寄存器只多 16 字节,比多发一帧节省的往返时间划算得多。但Count不能超过 125(协议上限),合并后要加一道截断检查,否则从站直接回0x03非法数据值。

5. 避坑与排查:现场调试最容易翻车的五件事

5.1 能连上 TCP 但一个寄存器都读不出来

现象:Telnet IP 502能通,程序里 Socket 也连上了,但第一次读就抛超时或者0x02非法地址。

原因:TCP 端口通只代表从站的通讯服务在监听,不代表单元标识和地址是对的。最常见的三种情况是单元标识填错(网关模式下必须等于下游站号)、起始地址用了 1 基而协议要 0 基、读的是输入寄存器却用了0x03。

解决:先用一个读请求打通最小区块——从地址 0 读 1 个寄存器,功能码0x03,单元标识先试 1,不行再试 0xFF。通了再逐步扩到真实点位。如果所有单元标识都试过还是0x02,去问 PLC 侧要一份寄存器映射表,别自己猜。

5.2 读回来的浮点数是个天文数字

现象:温度字段读出来是1.6E+38或者-2.3E-41这种明显不合理的值。

原因:字序排列选错了。ABCD 和 CDAB 反过来,指数位和尾数位全部错位,结果就是完全随机的浮点数。看起来像"数据跳变"或者"干扰",实际上纯粹是解码问题。

解决:让 PLC 侧写入一个已知常量(推荐 25.5 或 1.0),上位机把两个ushort原始值打印出来对照,确定排列后再配置WordOrder。确定之后把这个值写进注释和配置,别指望下次还能记住。

5.3 运行几分钟稳定断一次,重连就好

现象:轮询跑得好好的,每隔三五分钟抛一次IOException,重连之后又正常,周而复始。

原因:多半是交换机或者防火墙清理了空闲连接,也可能是从站侧的连接数限制到了——有些小型 PLC 只允许 4~8 个并发连接,调试期开了好几个工具同时连,把额度占满了。

解决:先关掉所有其他调试工具和抓包软件再试。仍然是定时断的话,在轮询循环里加一个轻量的心跳(定期读一个已知地址),让连接保持活跃;同时在重连逻辑里做指数退避,别在断线后立刻狂连,那会把从站的连接表彻底打满。

5.4 写入返回成功,但设备毫无反应

现象:WriteSingleRegister返回正常,回显也对,但现场设备不动。

原因:写进去的地址不是最终执行通道。PLC 程序里这个寄存器可能只是 HMI 的设定值,还需要一个"确认"位或者上升沿才真正下发;也可能写的是只读镜像区,程序每周期都会覆盖掉。

解决:确认点位表里写的是"命令区"而不是"状态区"。如果是需要触发的,检查是否要先置位再复位;如果写完立刻被覆盖,读一次就知道(回读值和写入值不一致就是被程序覆盖了)。这类问题只能靠跟 PLC 侧对齐时序,代码层面无解。

5.5 偶发事务号不匹配或报文解析错位

现象:大部分时候正常,偶尔抛"事务号不匹配"或者数组越界,重启程序就好了。

原因:多线程共用了一条连接,或者上一次超时的响应姗姗来迟,被下一次请求当成了自己的回包。TCP 是流式的,超时后缓冲区里残留的字节会污染下一帧。

解决:一是保证一条连接同时只有一问一答,用锁或者单线程队列;二是在超时或者解析失败后,直接销毁连接重建,不要试图从半截报文里"跳过"错误继续读。Modbus 没有帧同步标记,跳字节这种玩法必然翻车。

6. 二次开发:把 Demo 改成能长期跑的数据采集服务

Demo 是控制台程序加同步阻塞调用,放到产线上 7×24 跑还不够格。真正改造成采集服务,我认为有三件事必须做,按优先级排:异步化、配置驱动、可验证。

第一件,把同步阻塞改成异步。NetworkStream本身支持ReadAsync,改造成本很低,但收益很大:轮询线程不会被网络 IO 卡死,CancellationToken能贯穿到每个await,进程退出时不用再靠Environment.Exit硬杀。

public async Task<ushort[]> ReadHoldingRegistersAsync( ushort start, ushort count, CancellationToken ct) { if (count is < 1 or > 125) throw new ArgumentOutOfRangeException(nameof(count)); // 用 SemaphoreSlim 替代 lock,因为 lock 里不能 await await _gate.WaitAsync(ct).ConfigureAwait(false); try { await EnsureConnectedAsync(ct).ConfigureAwait(false); ushort tid = NextTid(); var req = BuildReadRequest(tid, _unitId, 0x03, start, count); await _stream.WriteAsync(req, ct).ConfigureAwait(false); var header = await ReadExactlyAsync(7, ct).ConfigureAwait(false); int bodyLen = (header[4] << 8) | header[5]; var body = await ReadExactlyAsync(bodyLen, ct).ConfigureAwait(false); if ((body[1] & 0x80) != 0) throw new ModbusException(body[2], DescribeException(body[2])); var result = new ushort[count]; for (int i = 0; i < count; i++) result[i] = (ushort)((body[3 + i * 2] << 8) | body[4 + i * 2]); return result; } finally { _gate.Release(); } }

_gate是SemaphoreSlim(1, 1),作用等同于原来的lock,但允许在内部await。ct要一路透传到ReadExactlyAsync,配合ReadAsync的取消支持,能做到进程收到停止信号后几秒内干净退出。这里有个细节:取消导致的OperationCanceledException不要被当成通讯故障记进失败计数,否则退出时日志会被刷满。

第二件,用配置驱动设备与点位。现场多点位部署时,每改一个地址就重新编译发版,运维会疯掉。Demo 的appsettings.json结构可以照着这个路子扩:

{ "Devices": [ { "Name": "1号工位", "Host": "192.168.1.10", "Port": 502, "UnitId": 1, "PollIntervalMs": 200, "TimeoutMs": 1000, "Points": [ { "Name": "Temperature", "Start": 0, "Count": 2, "Func": 3, "Type": "float", "Order": "SwapCDAB" }, { "Name": "RunState", "Start": 10, "Count": 1, "Func": 3, "Type": "ushort" }, { "Name": "SetSpeed", "Start": 20, "Count": 1, "Func": 3, "Type": "ushort", "Writable": true } ] } ] }

启动时把Points反序列化成PointDef列表喂给BlockBuilder,地址合并、周期、超时全部可配。Type和Order决定走哪个解码分支,省掉为每个点位写一行代码。做成热加载更好——监听配置文件变更,重建轮询计划,不用重启进程。

第三件,验证要分三层做。光靠"现场能读到数"这一个标准,出问题的时候没有任何抓手。

层级做法能发现什么
单元测试起一个本地回环的模拟从站,按协议回固定报文组帧、拆包、异常码处理、字节序
仿真联调用模拟器按真实点位表回应,跑满 24 小时内存增长、连接泄漏、轮询漂移
现场对点每个点位让 PLC 侧写已知值,逐点比对地址映射、字序、写入通道是否正确

模拟从站那层我建议一定要自己写一个,几十行代码的事:监听端口、收请求、按功能码回数据。有了它,字节序和异常码这些纯逻辑问题在办公室里就能测干净,不用等产线停机窗口。

提示:给采集服务加一个"通讯健康度"指标——最近一分钟成功率、平均响应时延、当前重连次数。这些数字在故障复盘时比日志有用得多,也能提前预警从站负载过高。

最后说一个我自己的习惯。从那以后我每次接新设备,不管对方给的点位表看起来多完整,都强制先走一遍"写已知值、读原始寄存器、逐点对表"的流程,绝不直接往业务代码里填地址。这个流程慢半天,但省下来的返工时间通常是好几倍。这份 Demo 就是按这个流程攒出来的,把它跑通再改,比从零开始猜协议靠谱。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进