首页 / 资讯中心 / 文章详情

FX5U上位机源码:C# WinForm实现MC协议立体仓库通信

FX5U上位机源码:C# WinForm实现MC协议立体仓库通信 ★ FEATURED ARTICLE
简介面向从事工业自动化上位机开发的C#程序员及自动化立体仓库项目学习者这份压缩包聚焦三菱FX5U PLC的以太网通信围绕3E帧报文给出WinForm上位机程序的实现参考可用于理解连接建立、报文构造、数据收发、异常重试和界面交互等环节。包内共35个文件以10个cs源码文件为核心辅以config配置、resx资源、sln与csproj工程文件及exe可执行文件等整体约79KB目录结构便于对照通信模块与界面代码进行梳理。已有468人学习说明其在PLC以太网通信和立体仓库控制场景中具有持续关注度。读者可从中获得可运行的工程骨架、3E帧报文封装与解析思路以及库存管理、货位查询、物料搬运等业务指令下发到通信层的实现参考也能借鉴字节序转换、超时重发和错误恢复的处理方式适合用于排查报文格式与通信稳定性问题。1. 现场调试最怕的不是 PLC 不动作而是不知道上位机到底发了什么去年冬天在一个立体仓库现场堆垛机停在巷道口不动PLC 的 RUN 灯亮着网线插着ping 也通可上位机界面上所有库位都是灰的点了出库按钮没有任何反馈。现场三个人围着交换机折腾了两小时最后抓包才发现TCP 连接压根没建立成功上位机一直在对 5000 端口发起连接然后被 RST。这类问题在自动化立体仓库项目里非常典型——问题不在 PLC 逻辑而在上位机的以太网通信层。这份 FX5U 上位机源码包就是一套用C# WinForm写的、走 MC 协议SLMP与 FX5U 内置以太网口通信的完整工程它把报文组包、位/字软元件读写、库位状态轮询、入库出库任务握手、日志抓包这几件事串成了一条链。适合两类人一是手上有一台 FX5U、想用 PC 做上位机但卡在报文格式上的开发者二是接了个立体仓库小项目、需要一个能跑通的 WinForm 通信框架当底子的人。2. 通信骨架拆解一条请求从 TcpClient 到 MC 报文的全链路2.1 工程目录与分层别把组包代码写进按钮事件里拿到这类源码包我第一件事不是打开主窗体而是先看目录。因为一个能上现场的通信程序窗体层一定很薄真正的逻辑在通信层和业务层里。这个工程的结构大致是这样Fx5uHost/ ├── Comm/ │ ├── MelsecClient.cs # TCP 连接、组包、收发、重连 │ ├── MelsecFrame.cs # 帧头常量、软元件代码表 │ ├── MelsecException.cs # 结束代码异常 │ └── DeviceCode.cs # D/M/X/Y/B/W 枚举映射 ├── Model/ │ ├── WarehouseSlot.cs # 库位模型行、列、层、占用、托盘号 │ └── TaskOrder.cs # 入库/出库任务单 ├── Service/ │ ├── PollingService.cs # 定时轮询库位状态 │ ├── TaskService.cs # 任务下发与握手 │ └── LogService.cs # 报文与操作日志落盘 ├── Config/ │ └── plc.config.json # IP、端口、轮询周期、软元件起始地址 └── UI/ ├── MainForm.cs ├── SlotPanel.cs # 自绘货架面板 └── MonitorForm.cs # 报文监视窗口这个分层的价值在于换个现场要改 IP 和软元件起始地址只动plc.config.json要换 PLC 型号改帧格式只动Comm目录界面怎么画跟通信完全解耦。我见过太多项目把Socket.Send直接写在button1_Click里加第二个按钮就得复制一遍三个月后自己都不敢改。配置文件里的关键项长这样每一项都对应后面报文里的一个字段{ PlcIp: 192.168.1.10, PlcPort: 5000, FrameType: 3E-Binary, NetworkNo: 0, PlcNo: 255, DestModuleIo: 1023, DestModuleStation: 0, CpuWatchTimer: 16, PollingIntervalMs: 300 }DestModuleIo的 1023 就是十六进制的0x03FF在报文里按小端写成FF 03CpuWatchTimer为 16 表示 16 × 250ms 4 秒是 PLC 等待本次请求处理完成的超时时间PollingIntervalMs决定上位机多久扫一遍库位立体仓库一般 200500ms 就够太快会和堆垛机的位置刷新抢带宽。2.2 3E 帧逐字段拆副头部、监视定时器和请求数据长度FX5U 的以太网口支持 SLMP工程里用的是3E 帧、二进制码这是日系 PLC 上位机里最常见的一种组合。一个批量读请求的完整字节流如下十六进制50 00 副头部3E 帧二进制固定 0x5000 00 网络号 FF PLC 号站内通常 FF FF 03 请求目标模块 I/O 编号0x03FF 小端 00 请求目标模块站号 0C 00 请求数据长度 12 字节 10 00 CPU 监视定时器 16 04 01 指令批量读 00 00 子指令字单位 00 00 00 起始软元件号 D03 字节小端 A8 软元件代码D 10 00 读取点数 16注意两个容易翻车的地方。第一副头部是 0x5000 还是 0x5400 决定帧类型50 00是 3E 帧54 00是 4E 帧两种帧的差别在于 4E 帧把请求目标模块 I/O 编号和站号合成了一个序列号字段混用一定收到异常应答。第二请求数据长度是从 CPU 监视定时器开始算到帧尾的字节数不是整帧长度。上面这个例子监视定时器 2 指令 2 子指令 2 软元件号 3 软元件代码 1 点数 2 12对应0C 00。很多人在这里写死 12一旦把读取点数改成 32 点或换成位单位长度没变其实还是对的因为点数固定 2 字节但一旦换成批量写长度随写入点数线性增长写死就必然报错。应答帧的结构要对称着看D0 00 副头部应答固定 0xD000 00 网络号 FF PLC 号 FF 03 请求目标模块 I/O 编号 00 站号 22 00 应答数据长度 34 字节含结束代码 00 00 结束代码0000 表示正常 xx xx ... 数据区16 点字数据 32 字节应答数据长度 34 结束代码 2 字节 数据 32 字节。这个字段是收包时唯一的长度依据TCP 是字节流你不能指望一次Read就拿到完整一帧也不能指望下一帧不会粘在当前缓冲区后面。收包逻辑必须先读固定 9 字节帧头从第 8、9 字节解出长度再精确读剩余部分。2.3 组包与收包的 C# 实现把长度字段交给代码算通信类的核心是两个方法一个负责把读 D100 开始的 32 个字翻译成字节流一个负责把字节流翻译回来。先看读请求的组包// MelsecClient.cs private byte[] Send(Listbyte body, int timeoutMs 3000) { lock (_syncRoot) // MC 协议是一问一答同一连接不能并发请求 { int dataLen 2 body.Count; // 监视定时器 请求体 var frame new Listbyte(9 dataLen); frame.AddRange(new byte[] { 0x50, 0x00 }); // 副头部3E 帧二进制 frame.Add(0x00); // 网络号 frame.Add(0xFF); // PLC 号 frame.AddRange(new byte[] { 0xFF, 0x03 }); // 目标模块 I/O 编号小端 frame.Add(0x00); // 目标模块站号 frame.Add((byte)(dataLen 0xFF)); // 请求数据长度 低字节 frame.Add((byte)((dataLen 8) 0xFF)); // 请求数据长度 高字节 frame.AddRange(new byte[] { 0x10, 0x00 }); // CPU 监视定时器 16 frame.AddRange(body); _stream.Write(frame.ToArray(), 0, frame.Count); return ReadResponse(timeoutMs); } }这段代码里有三个决定成败的细节。lock (_syncRoot)保证同一时刻只有一个请求在途因为 MC 协议没有请求 ID两个请求交叉发出去你没法把应答和请求对上号。dataLen是算出来的不是写死的后面换成批量写 100 个字也不会错。0x10, 0x00是监视定时器如果现场网络抖动大可以调到0x2840 × 250ms 10 秒代价是 PLC 通信异常时上位机要等更久才判超时。读方法的 body 组装只是把指令和参数按顺序追加public short[] ReadWords(string device, int head, int points) { var body new Listbyte(); body.AddRange(new byte[] { 0x04, 0x01 }); // 指令 0401批量读 body.AddRange(new byte[] { 0x00, 0x00 }); // 子指令 0000字单位 body.Add((byte)(head 0xFF)); // 起始软元件号 低 body.Add((byte)((head 8) 0xFF)); // 中 body.Add((byte)((head 16) 0xFF)); // 高 body.Add(DeviceCode.Of(device)); // 软元件代码D 0xA8 body.Add((byte)(points 0xFF)); // 点数 低 body.Add((byte)((points 8) 0xFF)); // 点数 高 var data Send(body); var result new short[points]; for (int i 0; i points; i) { // 二进制帧为小端低字节在前 result[i] (short)(data[i * 2] | (data[i * 2 1] 8)); } return result; }DeviceCode.Of(D)返回 0xA8这是 MC 协议里 D 寄存器的固定编码不是随便定的。软元件代码表建议直接做成静态字典别用switch散在各处软元件代码说明典型用途D0xA8数据寄存器16 位托盘号、任务号、坐标值M0x90内部继电器位库位占用标志、握手信号X0x9C输入继电器位光电、限位只读Y0x9D输出继电器位指示灯、蜂鸣器B0xA0链接继电器位与其它站交换的位信号W0xB4链接寄存器16 位与其它站交换的字数据2.4 收包必须按长度精确读别指望一次 Read 拿全收包是整个通信层最容易埋雷的地方写成下面这样才算过关private byte[] ReadResponse(int timeoutMs) { _stream.ReadTimeout timeoutMs; // 第一步固定 9 字节帧头从中拿到应答数据长度 var head ReadExact(9); if (head[0] ! 0xD0 || head[1] ! 0x00) throw new MelsecException($副头部异常: {head[0]:X2}{head[1]:X2}); int dataLen head[7] | (head[8] 8); if (dataLen 2 || dataLen 2048) throw new MelsecException($应答长度异常: {dataLen}); // 第二步按长度读剩余部分含 2 字节结束代码 var rest ReadExact(dataLen); int endCode rest[0] | (rest[1] 8); if (endCode ! 0) throw new MelsecException($PLC 返回错误结束代码 0x{endCode:X4}); return rest.Skip(2).ToArray(); } // 循环读满 count 字节TCP 半包/粘包全靠它兜底 private byte[] ReadExact(int count) { var buf new byte[count]; int read 0; while (read count) { int n _stream.Read(buf, read, count - read); if (n 0) throw new IOException(对端关闭连接); read n; } return buf; }ReadExact是整个通信层的基石。NetworkStream.Read的返回值只代表这次读到了多少可能是 1 字节也可能是 100 字节跟你要的 9 字节毫无关系。我在一个项目上见过有人写_stream.Read(buf, 0, 1024)然后直接从buf[0]开始解析本地测试因为数据量小、一次就凑齐了到了现场网络一抖全部解析错位日志里全是副头部异常 D0 00——其实副头部是对的是他从错的位置开始看的。dataLen 2048这个上限判断也别省一旦帧错位长度字段可能是任意垃圾值没有上限就会去new byte[几万]。3. 报文解析与数据映射库位状态怎么从字节流变成界面上的格子3.1 位软元件和字软元件解析方式完全是两套立体仓库里两类数据必须分开看。库位有没有货是开关量用 M 区这个库位放的是哪盘货是数值用 D 区。这两种软元件在 MC 协议里的读写方式不一样混用就出错。批量读位软元件子指令01 00PLC 返回的数据区是每个点 1 字节值为0x00或0x01public bool[] ReadBits(string device, int head, int points) { var body new Listbyte(); body.AddRange(new byte[] { 0x04, 0x01 }); // 批量读 body.AddRange(new byte[] { 0x01, 0x00 }); // 子指令 0001位单位 body.Add((byte)(head 0xFF)); body.Add((byte)((head 8) 0xFF)); body.Add((byte)((head 16) 0xFF)); body.Add(DeviceCode.Of(device)); // M 0x90 body.Add((byte)(points 0xFF)); body.Add((byte)((points 8) 0xFF)); var data Send(body); var bits new bool[points]; for (int i 0; i points; i) bits[i] data[i] ! 0; // 每位占 1 字节非 0 即 ON return bits; }批量读字软元件子指令00 00数据区是每个点 2 字节小端。200 个库位的占用标志读回来是 200 字节200 个托盘号读回来是 400 字节相差一倍——如果按读字的方式去解析读位的结果第一个库位看着是对的第二个开始全部错位界面上就会出现隔一个亮一个的诡异现象。这里有个参数上的硬约束单次请求的点数上限。字单位一般不超过 960 点位单位不超过 7168 点具体跟 CPU 型号有关。200 个库位完全在范围内但如果你的仓库有 2000 个货位就得拆成多次请求或者把占用标志打包成字的位来读一个字装 16 个库位后者省带宽但解析麻烦小项目不值得。3.2 库位模型与报文地址的对应关系用一张映射表锁死通信层只认软元件地址界面只认行列层中间必须有一层模型把两者绑起来。工程里的WarehouseSlot大致是这样public class WarehouseSlot { public int Row { get; set; } // 排 public int Column { get; set; } // 列 public int Layer { get; set; } // 层 public bool Occupied { get; set; } // 对应 M 区某一位 public short PalletNo { get; set; } // 对应 D 区某个字0 表示空 public int BitIndex { get; set; } // 在 M 区里的偏移 public int WordIndex { get; set; } // 在 D 区里的偏移 }初始化时按固定规则建立映射这个规则一定要写进配置或注释否则半年后没人知道第 37 号货位对应哪个 M 点// 排、列、层 从 1 开始按 层 → 列 → 排 的顺序线性编号 int idx 0; for (int layer 1; layer layerCount; layer) for (int col 1; col columnCount; col) for (int row 1; row rowCount; row) { slots.Add(new WarehouseSlot { Row row, Column col, Layer layer, BitIndex idx, // M1000 idx WordIndex idx // D2000 idx }); idx; }映射顺序一旦定了就不能改因为 PLC 侧的程序是照着这个顺序写的。我吃过一次亏上位机按层→列→排编号PLC 那边按排→列→层编号两边的第 5 号库位根本不是同一个物理位置调试时出库指令把货送到了隔壁巷道堆垛机撞了一次限位才停下来。从那以后我要求在配置里显式写死SlotOrder: Layer-Col-Row并且上线前必须做一次单点校验——手动置位某一个 M 点看界面上亮的是不是同一个格子。3.3 轮询调度为什么要两次请求而不是一次按上面的设计一轮刷新需要两个请求读 M1000 起 200 点位读 D2000 起 200 点字。这不是设计失误而是 MC 协议的限制——一个请求只能读同一种软元件的连续区域没法在一个报文里同时读 M 和 D。轮询循环用TaskCancellationToken实现不要用 WinForm 的Timer直接在里面做同步 IOprivate async Task PollLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { var sw Stopwatch.StartNew(); try { // 位和字分两次读任意一次失败都不影响另一次的结果展示 var occupied _client.ReadBits(M, 1000, SlotCount); var palletNos _client.ReadWords(D, 2000, SlotCount); for (int i 0; i SlotCount; i) { _slots[i].Occupied occupied[i]; _slots[i].PalletNo palletNos[i]; } RaiseSlotsUpdated(); // 只发事件刷新交给 UI 层 } catch (MelsecException ex) { _log.Warn($轮询失败结束代码 0x{ex.EndCode:X4}); await _client.ReconnectAsync(token); // 通信异常触发重连 } int delay Math.Max(0, _intervalMs - (int)sw.ElapsedMilliseconds); await Task.Delay(delay, token); } }这里用了补偿式延时本轮耗时 80ms、周期设 300ms就只等 220ms保证轮询节奏稳定。如果直接Task.Delay(300)加上通信耗时实际周期会漂到 400ms 以上堆垛机跑得快的时候界面会明显滞后。RaiseSlotsUpdated只是抛事件真正刷界面在 UI 层做并且要做差异刷新200 个格子每 300ms 全刷一遍GDI 绘制会吃掉不少 CPU正确做法是只重绘状态发生变化的格子。4. WinForm 界面与任务流入库、出库和手动调试怎么串起来4.1 界面分区货架视图、任务列表、报文监视三块这类上位机的界面不需要花哨但必须让操作工一眼看懂。工程的主窗体分三块左侧是自绘的货架俯视图按层分 Tab每层画成网格右上是要执行的出库/入库任务列表右下是滚动日志。货架格子用Panel自绘而不是DataGridView因为 200 个格子的 DataGridView 刷新会闪自绘可以只Invalidate变化区域。格子的颜色约定要固定下来并且贴在墙上灰空位蓝有货黄作业中红故障。颜色不要用太接近的色系现场光线差、屏幕反光浅蓝浅绿根本分不清。4.2 入库出库的握手时序别用固定延时上位机和 PLC 之间传递任务最忌讳发完等 500ms 再读结果这种写法。工程里用的是标准的四步握手public async Taskbool DispatchTaskAsync(TaskOrder order, CancellationToken token) { // 1. 写任务号到 D1001xxxx 入库 / 2xxxx 出库 _client.WriteWords(D, 100, new short[] { order.TaskNo }); // 2. 写起点、终点库位编号到 D102、D103 _client.WriteWords(D, 102, new short[] { order.FromSlot, order.ToSlot }); // 3. 置位 M100 发起请求 _client.WriteBits(M, 100, new[] { true }); // 4. 等 PLC 回 M200接受或 M201拒绝超时 5 秒 var sw Stopwatch.StartNew(); while (sw.ElapsedMilliseconds 5000) { var ack _client.ReadBits(M, 200, 2); if (ack[0]) { _client.WriteBits(M, 100, new[] { false }); return true; } if (ack[1]) { _client.WriteBits(M, 100, new[] { false }); return false; } await Task.Delay(50, token); } _client.WriteBits(M, 100, new[] { false }); // 超时也要复位否则下次发不出去 throw new TimeoutException($任务 {order.TaskNo} 未在 5 秒内被 PLC 应答); }参数上有几个死规矩。M100是请求位M200是接受位M201是拒绝位这三个地址必须和 PLC 程序里完全一致写进配置而不是硬编码。发出请求后无论成功失败都必须复位 M100我见过最典型的事故是超时分支里忘了复位PLC 一直认为请求有效第二次任务发下去没反应操作工连点十几次最后 PLC 收到一堆重复任务。D100存任务号的设计也有讲究任务号用10000 序号表示入库20000 序号表示出库PLC 侧只要判断万位就知道该干什么省掉一个方向标志位。4.3 报文监视面板现场救命的东西MonitorForm是整个工程里我最看重的部分。它把每一次收发的原始字节按十六进制打出来并附带时间和解析结果public void Append(string direction, byte[] frame, string parsed) { var sb new StringBuilder(); sb.Append(DateTime.Now.ToString(HH:mm:ss.fff)).Append( ); sb.Append(direction TX ? → : ← ); sb.Append(BitConverter.ToString(frame).Replace(-, )); sb.Append( ).Append(parsed); if (_logBox.InvokeRequired) _logBox.BeginInvoke(new Action(() _logBox.AppendText(sb \r\n))); else _logBox.AppendText(sb \r\n); _fileLogger.Write(sb.ToString()); // 同时落盘现场没法实时看屏幕 }这个面板的价值在故障复盘时才体现出来。PLC 说没收到指令你把日志打开一看→ 50 00 00 FF FF 03 00 0C 00 10 00 14 01 ...明明白白发出去过那就是 PLC 侧的问题反过来日志里根本没有 TX 记录那就是上位机自己的线程卡死了。日志必须落盘并按天切分现场调试往往持续好几天光靠屏幕上的滚动窗口出了事一刷新就没了。4.4 配置与启动自检把低级错误挡在运行之前主窗体加载时应该做一次自检把能提前发现的问题全查一遍配置文件是否存在且字段齐全、IP 是否能 ping 通、5000 端口是否能建立 TCP 连接、连接后读一个已知的 D 寄存器比如 D0 的系统状态字验证协议是否正确。这四步全部通过才允许点亮开始运行按钮。private bool SelfCheck(out string message) { if (!File.Exists(Config/plc.config.json)) { message 配置文件缺失; return false; } if (!_client.Connect(2000)) { message TCP 连接失败检查 PLC 的 SLMP 连接配置; return false; } if (!_client.TryRead(D, 0, 1, out _)) { message 协议校验失败检查帧类型与软元件代码; return false; } message 自检通过; return true; }第二步和第三步的报错文案要写清楚因为这两种失败在现场太常见了而它们的表象都是连不上但原因完全不同。5. 现场避坑排查连不上、读到零、数据错位这几类问题5.1 现象TcpClient 连接超时或被立刻 RST原因FX5U 的以太网口不是插上就能当服务器用的。它需要在工程工具的以太网参数里配置对象设备连接配置为 SLMP 分配一个连接号并指定通信协议TCP/UDP、打开方式Active/Unpassive和端口号。如果这个配置没做或者连接号被其它设备占了PC 发起连接就会被拒绝。另外 FX5U 的 SLMP 连接数是有限的一般 8 个以内多台上位机同时连会互相挤掉。解决先在 PLC 侧确认连接配置已添加并下载端口号和程序里一致再用 telnet 或简单的 TCP 测试工具验证端口能建连。如果同一台 PLC 要接多台上位机每台占一个连接号并且给每台设置不同的端口。5.2 现象通讯正常但结束代码返回 0xC059 或 0xC05B原因这两个代码是 PLC 在告诉你请求本身有问题。0xC059通常是指令或软元件代码不被支持比如把 M 区当成字单位读、或者软元件代码字节填错了D 是 0xA8填成 0xA9 就是 SD。0xC05B是访问的软元件范围超出 CPU 允许的范围。解决抓包对照结束代码表逐字段核重点看软元件代码和设备号。另外注意子指令读位必须01 00读字必须00 00读 M 区用字单位会直接报错因为 M 是位软元件。5.3 现象读回来的数看着是高低字节反了值变成 256 倍原因二进制帧和 ASCII 帧的字节序规则不同。二进制帧的数据区是小端ASCII 帧是把每个字节表示成两个 ASCII 字符、按正序排列。有人从网上抄了一段 ASCII 模式的解析代码套到二进制帧上或者反过来就会出现0x0001读成0x0100的情况。另外 32 位数据两个字在二进制帧里是低字在前、高字在后的交换顺序不是简单的线性拼接。解决帧类型在配置里固定死解析代码分两套不要写兼容分支。32 位浮点或双字的拼接要单独写一个方法用BitConverter.ToSingle之前先把两个字的顺序倒过来。5.4 现象界面卡住几秒钟鼠标点不动原因在 UI 线程里做了同步的NetworkStream.Read。PLC 响应慢一点、或者网络丢包重传Read就阻塞在那里消息循环停摆整个窗体未响应。用Timer做轮询也有同样的问题因为Timer的回调跑在 UI 线程上。解决所有通信操作放到Task或后台线程里跑结果通过事件 Invoke/BeginInvoke回到 UI 线程。回 UI 的时候也要注意节流别每个字节都Invoke一次攒一批再刷。5.5 现象跑得好好的几小时后突然连不上了原因这类长时间运行后断连最常见的是 PLC 侧的连接生存时间TCP Keep-Alive 或连接超时到了而上位机这边没有任何心跳。其次是 PC 的网卡电源管理把网卡休眠了或者是交换机端口协商降速导致的零星丢包累积。解决轮询本身其实就是心跳但要有失败后的自动重连机制重连间隔做退避1s、2s、4s、8s 封顶别用固定 100ms 疯狂重连把 PLC 的连接表打满。同时在 PC 侧关掉网卡的允许计算机关闭此设备以节约电源这个设置我每次部署必检查一遍。6. 二次开发改造换帧类型、扩库位、把同步 IO 改成异步手上的这套代码要真正用起来迟早要动三个地方。第一个是帧类型。如果现场换成了更新的 CPU 或者走的是别的网络层级可能要切到 4E 帧。改动集中在Send方法里副头部从50 00改成54 00然后把目标模块 I/O 编号 站号三字节换成一个两字节的序列号字段请求数据长度相应减 1。因为长度是算出来的改完不用再手数字节。第二个是扩库位。200 个货位扩到 2000 个单个请求点数超限必须分批。做法是在ReadBits/ReadWords外面包一层分片public short[] ReadWordsChunked(string device, int head, int points, int chunk 480) { var all new short[points]; for (int off 0; off points; off chunk) { int n Math.Min(chunk, points - off); var part ReadWords(device, head off, n); Array.Copy(part, 0, all, off, n); } return all; }chunk 480是个保守值480 点字数据对应 960 字节加上帧头不到 1KB绝大多数现场都能一次过。分片之后单轮耗时变长轮询周期要相应放宽或者把库位状态和托盘号拆成两个不同频率的任务——状态 200ms 刷一次托盘号 1 秒刷一次就够。第三个是通信模型的改造。现在用lock串行化简单可靠但入库任务下发和状态轮询会互相阻塞任务紧急时会感觉按钮响应慢。改造思路是给通信层加一个请求队列用SemaphoreSlim(1,1)控制任务请求优先入队轮询请求优先级低private readonly SemaphoreSlim _gate new SemaphoreSlim(1, 1); private async Taskbyte[] SendAsync(Listbyte body, CancellationToken token) { await _gate.WaitAsync(token); try { return await SendCoreAsync(body, token); } finally { _gate.Release(); } }改造完务必做回归验证连续下发 50 条任务检查每一条的 M100 是否都正确复位、PLC 侧是否出现重复任务号、界面在高频刷新下是否还掉帧。我的习惯是每改一处通信代码就在报文监视窗口里保存一份正常流程的日志作为基线改完对比字节差异一眼就能看出问题出在哪。这套代码不算复杂但它把 MC 协议里那些只可意会的细节都落成了可运行的字节。我踩过最疼的一次坑是照着别人的例子写请求帧长度字段写死 12本地读 16 个字一切正常上了现场改读 500 个字PLC 直接返回错误码排查了大半天才回到那两字节上。从那以后我每次动协议代码都强制把报文抓出来逐字段对一遍再跑一次单点读写验证。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站