简介:面向工业自动化上位机开发者的汇川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.cs | ushort 数组与 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 TCP | Modbus RTU |
|---|---|---|
| 物理层 | 网口,RJ45 | RS485/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 侧软元件 |
|---|---|---|---|---|
| 温度 | 保持寄存器 | 0x03 | 0 | 双字,占用 2 个寄存器 |
| 运行状态 | 保持寄存器 | 0x03 | 10 | 单字 |
| 设定速度 | 保持寄存器 | 0x03 | 20 | 单字 |
| 启动命令 | 线圈 | 0x01 | 0 | 位 |
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 就是按这个流程攒出来的,把它跑通再改,比从零开始猜协议靠谱。希望帮到你。
本文还有配套的精品资源,点击获取