资讯动态

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

发布时间:2026/10/23 1:44:28 来源:尧图企业网站定制
简介面向工业自动化上位机开发者的汇川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 Dictionarystring, 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落到报文里发给从站的是0x000040101发的是0x0064。这个偏移量差一位读回来的数据要么报非法地址要么读到隔壁点位排查起来相当玄学。我在PointDef里干脆只允许填 0 基地址注释写死填 40001 请自行减 1就是为了掐掉这个坑。Func字段区分功能码很关键。保持寄存器走0x03输入寄存器走0x04线圈走0x01。PLC 侧同一个速度值工程师可能挂在保持寄存器区也可能挂在输入寄存器区功能码和区域必须对应否则从站回一个0x02非法数据地址报文层面看起来毫无异常。2.2 传输层选型为什么 Demo 默认走 Modbus TCPDemo 里同时留了串口和以太网两条路的代码但主流程走 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 MBAP7 字节 PDU。PDU 里再分功能码和数据段。组帧代码看起来机械但只要错一个字节从站要么不回要么回一个异常码现场很难靠肉眼判断。Demo 把组帧拆成两个方法头和数据分开拼。// Modbus/ModbusTcpClient.cs /// summary /// 组装一条读寄存器请求的完整 ADU /// /summary /// param nametid事务号一问一答靠它配对/param /// param nameunitId单元标识网关场景下等于 RTU 站号/param /// param namefunc功能码0x03 读保持寄存器 / 0x04 读输入寄存器/param /// param namestart起始地址0 基/param /// param namecount读取数量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/0x040x02非法数据地址检查起始地址与数量是否越界0x03非法数据值检查写入的数量或值是否在从站允许范围0x04从站设备故障记录日志退避重试持续失败则告警0x05确认正在处理等待后重发同一请求0x06从站设备忙拉长轮询间隔别高频重试压垮从站3.3 写寄存器与写线圈为什么写完必须回读写操作的报文结构和读不大一样。写单个寄存器用0x06参数是地址加值写多个寄存器用0x10参数是地址、数量、字节数和数据区。写线圈用0x05值0xFF00表示 ON0x0000表示 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里给它打一个EchoVerifyfalse标记跳过回读改成读另一个执行结果点位来确认。这个习惯在联调期能省掉大量扯皮。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 ConcurrentDictionarystring, 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 ListReadBlock BuildBlocks(IEnumerablePointDef points, int gap 8) { var ordered points.Where(p p.Func is 0x03 or 0x04) .OrderBy(p p.Start).ToList(); var blocks new ListReadBlock(); 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.6E38或者-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 Taskushort[] 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 就是按这个流程攒出来的把它跑通再改比从零开始猜协议靠谱。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑