ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

简介:面向从事工业自动化上位机开发的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 03;CpuWatchTimer为 16 表示 16 × 250ms = 4 秒,是 PLC 等待本次请求处理完成的超时时间;PollingIntervalMs决定上位机多久扫一遍库位,立体仓库一般 200~500ms 就够,太快会和堆垛机的位置刷新抢带宽。

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 起始软元件号 D0,3 字节小端 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(List<byte> body, int timeoutMs = 3000) { lock (_syncRoot) // MC 协议是一问一答,同一连接不能并发请求 { int dataLen = 2 + body.Count; // 监视定时器 + 请求体 var frame = new List<byte>(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是监视定时器,如果现场网络抖动大,可以调到0x28(40 × 250ms = 10 秒),代价是 PLC 通信异常时上位机要等更久才判超时。

读方法的 body 组装只是把指令和参数按顺序追加:

public short[] ReadWords(string device, int head, int points) { var body = new List<byte>(); 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 00),PLC 返回的数据区是每个点 1 字节,值为0x00或0x01:

public bool[] ReadBits(string device, int head, int points) { var body = new List<byte>(); 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。

轮询循环用Task+CancellationToken实现,不要用 WinForm 的Timer直接在里面做同步 IO:

private 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 Task<bool> DispatchTaskAsync(TaskOrder order, CancellationToken token) { // 1. 写任务号到 D100,1xxxx 入库 / 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 Task<byte[]> SendAsync(List<byte> body, CancellationToken token) { await _gate.WaitAsync(token); try { return await SendCoreAsync(body, token); } finally { _gate.Release(); } }

改造完务必做回归验证:连续下发 50 条任务,检查每一条的 M100 是否都正确复位、PLC 侧是否出现重复任务号、界面在高频刷新下是否还掉帧。我的习惯是每改一处通信代码,就在报文监视窗口里保存一份正常流程的日志作为基线,改完对比字节差异,一眼就能看出问题出在哪。

这套代码不算复杂,但它把 MC 协议里那些只可意会的细节都落成了可运行的字节。我踩过最疼的一次坑,是照着别人的例子写请求帧,长度字段写死 12,本地读 16 个字一切正常,上了现场改读 500 个字,PLC 直接返回错误码,排查了大半天才回到那两字节上。从那以后我每次动协议代码,都强制把报文抓出来逐字段对一遍,再跑一次单点读写验证。希望帮到你。

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

RELATED READING

延伸阅读

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