简介:《三菱FX5U上位机以太网通信》是一份面向C#工控上位机开发者与自动化立体仓库项目学习者的实战代码包,重点解决FX5U PLC通过以太网3E帧报文进行通信的问题。压缩包共35个文件、约79KB,包含10个cs源码、6个cache缓存文件,以及config、settings、csproj、sln、resx、resources等工程配置与资源文件,并附带exe、pdb、suo、manifest和txt说明,可作为WinForm工程直接梳理目录与调试。已有468人学习,内容围绕连接建立、报文构造、数据收发、PLC协议解析、错误重试及界面展示展开,读者可从中了解上位机与PLC交互的代码组织方式,并参考其在库存管理、货位查询等立体仓库场景中的业务衔接思路,适合需要入门或对照排错的工控通信开发者。
1. 凌晨两点,堆垛机停在巷道口没动
现场第一反应是 PLC 死机,重启后发现 PLC 一切正常,问题出在上位机:它把一组货位状态按字的起始地址读了,高位地址偏移算错一格,界面上那一排货位全绿,实际早就该红了。这类问题在 FX5U 以太网通信里非常典型——MC 协议本身不复杂,主要难点集中在字节序、位软元件对齐和握手时序这三处。
这份资源是一套 C# WinForm 写的上位机工程,通过以太网以 MC 协议 3E 帧与 FX5U 系列 PLC 交换报文,把自动化立体仓库里最常用的三类数据——货位状态、堆垛机位置、出入库任务握手——完整跑通。适合正在做 WCS 上位机、产线数据采集,或者手上有一台 PLC 需要一个能直接改的 C# 通信底座的开发者。代码是能编译能连机的,并非只能看的 Demo。
2. 工程架构:WinForm 上位机怎么拆成通信层、业务层和界面
拿到一个上位机工程,最怕的是所有逻辑堆在一个 Form 里:按钮点击里拼报文、定时器里读 PLC、顺带更新界面,改一处全崩。这套工程的价值有一半在分层上,先看清结构再动手改,能少走很多弯路。
2.1 三层拆分与一条数据的完整流向
整个程序按职责切成四块,注意是四块而不是教科书上的三层:
通信层只干一件事——把「读 D100 起 20 个字」翻译成字节流发出去,再把回来的字节流翻译成ushort[]。它不认识货位,也不认识任务,只认软元件和点数。这一层封装成McProtocolClient,对外暴露ReadWordsAsync/WriteWordsAsync/ReadBitsAsync/WriteBitsAsync四个方法。
业务层才是懂仓库的部分。WarehouseService负责把「1 号巷道 3 排 5 列有货」翻译成「D1050 的 bit7 是 1」,TaskService负责入库出库的任务号、握手位、超时撤销。这一层不做任何字节操作。
界面层是 WinForm 的MainForm加几个UserControl,只负责展示和把用户操作转成业务层调用,绝不在按钮事件里直接调通信层。
基础设施层是日志、配置、异常类型,容易被忽略但联调时救命。
一条数据从 PLC 到界面的路径是这样的:
PLC(D 区) → McProtocolClient.ReadWordsAsync (字节流 → ushort[]) → WarehouseService.ParseSlotStates (ushort[] → SlotState[]) → MainForm.BeginInvoke(UpdateGrid) (后台线程 → UI 线程)反过来下发任务时是:
用户点「入库」 → TaskService.CreateTask (生成任务号) → McProtocolClient.WriteWordsAsync (写 D100 区参数) → McProtocolClient.WriteBitAsync (置 M8010 任务有效) → 轮询 M8011 等 PLC 回执这个流向决定了后面所有排查思路:界面不对,先看业务层解析,再看通信层原始数据,最后怀疑网络。顺序反了就是白熬一晚。
提示:用 Wireshark 抓 5000 端口的包时,二进制帧看起来像乱码,这时候把通信层改成 ASCII 帧发一次,报文立刻可读,定位字段错误快得多。
2.2 目录结构与关键类职责
工程目录不深,但每层都留了扩展位:
Fx5uHost/ ├─ Communication/ │ ├─ McProtocolClient.cs // 连接、收发、帧拼装与解析的总入口 │ ├─ McFrameBuilder.cs // 请求帧构造,纯静态,方便单测 │ ├─ McFrameParser.cs // 响应帧解析 + 结束代码判定 │ ├─ DeviceCode.cs // 软元件代号表(D/M/X/Y/B/W/R) │ └─ McException.cs // 带结束代码的专用异常 ├─ Business/ │ ├─ WarehouseService.cs // 货位、堆垛机状态解析 │ ├─ TaskService.cs // 出入库任务下发与握手 │ └─ Models/ // InboundTask、SlotState、StackerStatus ├─ UI/ │ ├─ MainForm.cs // 主窗体,只做绑定 │ └─ Controls/ // 货位矩阵、堆垛机状态面板 ├─ Infrastructure/ │ ├─ PlcConfig.cs // IP、端口、超时、轮询周期 │ └─ LogHelper.cs // 按天分文件,带毫秒时间戳 └─ Program.cs几个类的职责边界值得说清楚。McFrameBuilder全是静态方法,输入软元件和点数,输出byte[],不碰 socket,这样你可以写一堆单元测试比对十六进制。McProtocolClient持有TcpClient和NetworkStream,管连接生命周期、断线重连、超时。McFrameParser里最重要的方法是检查结束代码,非零直接抛McException,把错误码带上去,这样上层日志能直接看到0xC059这种精确信息,而不是一句「读取失败」。
PlcConfig建议做成可配置项而不是写死常量。现场 IP 网段千奇百怪,端口号也未必是默认的 5000,写死的代码到现场第一件事就是重新编译。
2.3 开发环境与依赖清单
| 项目 | 建议版本 | 说明 |
|---|---|---|
| .NET | .NET Framework 4.7.2 或 .NET 6/8 WinForms | 老现场多,4.7.2 兼容性最好 |
| 开发工具 | Visual Studio 2022 | 需要 WinForms 工作负载 |
| PLC 侧 | FX5U 系列,固件支持 SLMP/3E 帧 | 需在工程软件里开放以太网端口 |
| 网络 | 上位机与 PLC 同网段,TCP 直连 | 端口常用 5000,以 PLC 侧设置为准 |
| 第三方库 | 无强制依赖,日志可用 NLog/Serilog | 工程自带轻量 LogHelper |
没有引入任何 PLC 厂商的通信组件,全部用System.Net.Sockets手搓。好处是部署时只要拷 exe,不用装一堆运行时;坏处是字节序、帧长这些细节全得自己兜住,这也正是第 3 章要拆的部分。
3. MC 协议 3E 帧拆解:一条读写报文从拼装到解析的完整链路
协议这层最容易出问题的地方是「以为对上了,其实差一个字节」。下面按请求、响应、位操作、连接四个角度逐个拆开,每段都能直接对照抓包结果。
3.1 请求帧字段逐字节对齐
3E 二进制帧的请求头固定 9 字节,结构如下:
| 偏移 | 长度 | 字段 | 常用值 |
|---|---|---|---|
| 0 | 2 | 副头部 | 50 00(二进制)/ ASCII 为500000FF03FF00 |
| 2 | 1 | 网络号 | 00 |
| 3 | 1 | PC 号 | FF |
| 4 | 2 | 请求目标模块 IO 号 | FF 03 |
| 6 | 1 | 请求目标模块站号 | 00 |
| 7 | 2 | 请求数据长度 | 从监视定时器到末尾的字节数 |
| 9 | 2 | CPU 监视定时器 | 00 10,单位 250ms,即 4 秒 |
| 11 | 2 | 指令 | 01 04= 批量读 0401 |
| 13 | 2 | 子指令 | 00 00字单位 /01 00位单位 |
| 15 | 3 | 起始软元件编号 | 低字节在前 |
| 18 | 1 | 软元件代码 | D=0xA8,M=0x90 |
| 19 | 2 | 点数 | 低字节在前 |
注意两点:整条帧是小端序,0401发出去是01 04;请求数据长度只算第 9 字节之后的部分,不含副头部和头 9 字节。
拼装代码:
// McFrameBuilder.cs public static byte[] BuildReadWords(string device, int address, ushort points) { var body = new List<byte>(); body.AddRange(new byte[] { 0x00, 0x10 }); // CPU 监视定时器 4s body.AddRange(new byte[] { 0x01, 0x04 }); // 指令 0401:批量读 body.AddRange(new byte[] { 0x00, 0x00 }); // 子指令 0000:字单位 body.Add((byte)(address & 0xFF)); // 起始软元件编号,低字节在前 body.Add((byte)((address >> 8) & 0xFF)); body.Add((byte)((address >> 16) & 0xFF)); body.Add(DeviceCode.Of(device)); // 软元件代码 body.Add((byte)(points & 0xFF)); // 点数 body.Add((byte)(points >> 8)); int len = body.Count; // 长度只算监视定时器之后 var frame = new List<byte> { 0x50, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00, (byte)(len & 0xFF), (byte)(len >> 8) }; frame.AddRange(body); return frame.ToArray(); }参数说明:device传"D"、"M"这类代号,address传十进制地址值(对 X/Y/B/W 要传换算后的十六进制数值,见 5.3),points是点数。body.Count这里刚好是 12,所以发出去的长度字节是0C 00。如果哪天你换了带软元件扩展的指令,长度算错的表现是 PLC 直接回0xC051。
软元件代码单独抽成一个静态类,写错一个字节整个指令就废:
public static class DeviceCode { public static byte Of(string device) => device switch { "D" => 0xA8, // 数据寄存器 "M" => 0x90, // 内部继电器 "X" => 0x9C, // 输入 "Y" => 0x9D, // 输出 "B" => 0xA0, // 链接继电器 "W" => 0xB4, // 链接寄存器 "R" => 0xAF, // 文件寄存器 _ => throw new NotSupportedException($"软元件 {device} 未定义代号") }; }3.2 响应帧解析与结束代码
响应头同样是 9 字节,副头部变成D0 00,第 7、8 字节是响应数据长度(含 2 字节结束代码)。第 9、10 字节是结束代码,00 00表示正常,后面才是数据。
// McFrameParser.cs public static ushort[] ParseReadWords(byte[] resp, ushort points) { if (resp.Length < 11) throw new McException("响应不足 11 字节,可能被截断"); ushort endCode = (ushort)(resp[9] | (resp[10] << 8)); if (endCode != 0) throw new McException($"PLC 返回结束代码 0x{endCode:X4}", endCode); int need = points * 2; if (resp.Length < 11 + need) throw new McException($"数据区长度不足,期望 {need},实际 {resp.Length - 11}"); var result = new ushort[points]; for (int i = 0; i < points; i++) result[i] = (ushort)(resp[11 + i * 2] | (resp[12 + i * 2] << 8)); return result; }结束代码表不用背全,常用的几个记住就能救场:
| 结束代码 | 含义 | 多半是什么写错了 |
|---|---|---|
| 0xC051 | 请求数据长度错误 | 长度字段算漏了字节 |
| 0xC056 | 点数超范围 | 一次读超过 960 字 |
| 0xC058 | 起始地址超范围 | 软元件号超出 PLC 实际分配 |
| 0xC059 | 指令/子指令错误 | 0401 写成 1401,或位指令用了 0000 |
| 0xC05C | 请求内容无法处理 | 软元件代码与指令不匹配 |
| 0xC05D | 指定软元件无法执行 | 该软元件不支持这种访问方式 |
把结束代码原样塞进异常消息,比只把一个false传给上层要有用得多。联调时打开日志,看是0xC059还是0xC056,一眼就能分清是指令错还是点数错。
3.3 位软元件:16 点对齐与数据打包
位单位和字单位的响应格式不同:读 16 点 M 返回 8 字节,每字节装 2 点,高 4 位是前一个点。这个打包方式如果按字节顺序硬解,结果就是所有位都错开一位,也就是第 1 章开头那个问题的成因。
public static bool[] ParseReadBits(byte[] resp, int points) { ushort endCode = (ushort)(resp[9] | (resp[10] << 8)); if (endCode != 0) throw new McException($"结束代码 0x{endCode:X4}", endCode); var bits = new bool[points]; int offset = 11; for (int i = 0; i < points; i++) { byte b = resp[offset + i / 2]; // 偶数点取高 4 位的 bit0,奇数点取低 4 位的 bit0 bits[i] = (i % 2 == 0) ? (b & 0x10) != 0 : (b & 0x01) != 0; } return bits; }等等,这里容易写错。位数据是按半字节(nibble)放的,一个字节装两点:高 4 位是第 1 点,低 4 位是第 2 点,但每点只占 nibble 的最低位。所以判第 1 点看0x10,第 2 点看0x01,刚好对应。写回时的打包逻辑是反过来的:
public static byte[] PackBits(bool[] bits) { // 点数补到偶数,PLC 位写入要求成对 int count = (bits.Length + 1) / 2; var data = new byte[count]; for (int i = 0; i < bits.Length; i += 2) { int hi = bits[i] ? 1 : 0; int lo = (i + 1 < bits.Length && bits[i + 1]) ? 1 : 0; data[i / 2] = (byte)((hi << 4) | lo); } return data; }还有一条限制需要记住:位软元件批量读一次最多 7168 点,字软元件一次最多 960 字。仓库项目里货位状态动辄几千个,常见的做法是分段读,比如每 512 字一批,用一个for循环拼起来。一次性扔 2000 个点过去,PLC 回你0xC056,然后你会花两小时怀疑网络。
3.4 粘包、超时与断线重连
TCP 是流式的,一次Receive拿到的可能是半个响应,也可能是两个响应黏在一起。如果不先按头部长度切帧,接两三百次之后一定会翻车——表现为某次解析出的货位数据突然错乱一大批,下一次又自己好了。
正确做法是先读 9 字节头,从第 7、8 字节拿到 body 长度,再精确读这么多字节:
private async Task<byte[]> ReceiveFrameAsync(NetworkStream ns, CancellationToken ct) { var head = new byte[9]; await ReadExactAsync(ns, head, 0, 9, ct); if (head[0] != 0xD0 || head[1] != 0x00) throw new McException($"副头部异常: {head[0]:X2} {head[1]:X2}"); int bodyLen = head[7] | (head[8] << 8); // 结束代码 2 字节 + 数据 var body = new byte[bodyLen]; await ReadExactAsync(ns, body, 0, bodyLen, ct); var frame = new byte[9 + bodyLen]; Buffer.BlockCopy(head, 0, frame, 0, 9); Buffer.BlockCopy(body, 0, frame, 9, bodyLen); return frame; } private static async Task ReadExactAsync(NetworkStream ns, byte[] buf, int offset, int count, CancellationToken ct) { int read = 0; while (read < count) { int n = await ns.ReadAsync(buf, offset + read, count - read, ct); if (n == 0) throw new McException("连接被对端关闭"); read += n; } }ReadExactAsync里的循环是必须的,ReadAsync不保证一次读满。超时靠CancellationTokenSource.CancelAfter控制,单次请求建议 2 到 3 秒,比 PLC 的监视定时器略短一点,这样超时先由上位机发现,日志里能明确写「本机超时」而不是「连接断开」。
断线重连不要写成 while(true) 立刻重试,那样 PLC 侧连接数还没释放,重连会一直失败。常见做法是退避 1 秒、2 秒、5 秒,重建TcpClient之前先Close旧的。
4. 立体仓库业务落地:货位、任务与报文怎么映射到软元件
协议通了之后真正花时间的是地址规划。地址表定得乱,后面每加一个功能都要改三处代码;地址表定得清楚,界面加一列只改一个枚举。
4.1 软元件地址规划表
工程里用的是一张集中定义的地址表,业务层全部引用常量,不出现裸数字:
| 区域 | 软元件 | 地址范围 | 用途 |
|---|---|---|---|
| 心跳 | M | M8000 / M8001 | 上位机翻转、PLC 回执翻转 |
| 任务握手 | M | M8010 - M8031 | 任务有效、已接收、完成、故障 |
| 任务参数 | D | D100 - D119 | 任务类型、货位号、托盘号高低字 |
| 堆垛机状态 | D | D200 - D259 | 排/列/层、动作状态字、故障码 |
| 报警字 | D | D300 - D317 | 按位对应 16 类报警 |
| 货位状态 | D | D1000 - D1255 | 每字 16 个货位,256 字 = 4096 个货位 |
货位这块值得单独说:4096 个货位如果一货位一个字,要占 4096 个 D 寄存器,PLC 的 D 区扛不住。按位打包是最常见的做法——一个字装 16 个货位的「有货/无货」,4096 个货位只要 256 字。读取时一次读 256 字完全在 960 字的上限内,一次请求就能把全库状态拉回来。
4.2 入库任务下发与握手的完整代码
PLC 侧的任务处理通常是扫描式的:检测到「任务有效」位上升沿,读取参数区,回一个「已接收」,执行完置「完成」。上位机要做的就是写参数、置位、等回执、复位,四步一步都不能省。
public async Task<bool> SendInboundTaskAsync(InboundTask task, CancellationToken ct) { // 1) 参数区:D100 任务类型,D101 货位号,D102/D103 托盘号高低字 var buf = new ushort[20]; buf[0] = 0x0001; // 1 = 入库 buf[1] = (ushort)task.SlotNo; buf[2] = (ushort)(task.TrayNo & 0xFFFF); buf[3] = (ushort)(task.TrayNo >> 16); await _plc.WriteWordsAsync("D", 100, buf, ct); // 2) 置任务有效位,等 PLC 回执 await _plc.WriteBitAsync("M", 8010, true, ct); bool acked; try { acked = await _plc.WaitBitAsync("M", 8011, true, TimeSpan.FromSeconds(3), ct); } catch { await _plc.WriteBitAsync("M", 8010, false, ct); // 异常也要清位 throw; } if (!acked) { await _plc.WriteBitAsync("M", 8010, false, ct); throw new McException($"任务 {task.TaskNo} 未被 PLC 接收,已撤销"); } // 3) PLC 已锁存,复位请求位,避免下次误触发 await _plc.WriteBitAsync("M", 8010, false, ct); LogHelper.Info($"任务 {task.TaskNo} 下发成功,货位 {task.SlotNo}"); return true; }这段代码里两处WriteBitAsync(false)是血泪经验:任何一个异常分支没清请求位,PLC 那边的上升沿检测就再也触发不了,现场表现是「第一单能做,第二单没反应」,然后你会去查网络、查 PLC 程序,最后发现是上位机自己把位一直举着。
WaitBitAsync的实现是轮询,间隔 50 到 100 毫秒,别用 10 毫秒——请求打太密,PLC 的以太网处理能力会被吃满,反而拖慢整条线。
4.3 货位状态解析与界面刷新线程模型
拿到 256 个字的原始数据后,解析成货位状态只要几行:
// WarehouseService.cs public static SlotState GetSlotState(ushort[] words, int slotNo) { int idx = slotNo - 1; // 货位号从 1 开始 int wordIdx = idx / 16; int bitIdx = idx % 16; if (wordIdx >= words.Length) throw new ArgumentOutOfRangeException(nameof(slotNo)); bool occupied = (words[wordIdx] & (1 << bitIdx)) != 0; return occupied ? SlotState.Occupied : SlotState.Empty; }货位号是从 1 开始还是从 0 开始,必须和 PLC 程序严格一致。这个问题在联调时最容易被忽略,因为错一位的表现是「整体看起来对,个别货位不对」,很容易被当成偶发。
刷新界面是另一个坑。轮询跑在后台线程,直接给 DataGridView 赋值会抛跨线程异常,而在Invoke里做全量 4096 行刷新又会把界面卡住。工程里的做法是后台线程算好差异,只把变化的货位号列表扔回 UI:
private async Task PollLoopAsync(CancellationToken ct) { var last = new ushort[256]; while (!ct.IsCancellationRequested) { try { var words = await _plc.ReadWordsAsync("D", 1000, 256, ct); var changed = DiffChangedSlots(last, words); // 后台算差异 if (changed.Count > 0) { last = words; BeginInvoke(new Action(() => ApplyChanged(changed))); } await Task.Delay(200, ct); } catch (OperationCanceledException) { break; } catch (Exception ex) { LogHelper.Error("轮询失败", ex); await Task.Delay(1000, ct); // 失败退避 } } }Task.Delay(200)是轮询周期,仓库场景 200 毫秒完全够用,堆垛机的动作节拍在秒级。把周期调到 20 毫秒不会让界面更实时,只会让 PLC 的连接更早出问题。
5. 常见问题排查:联调时最容易翻车的五件事
下面五条基本覆盖了这套工程在现场 90% 的故障,每条按现象、原因、解决写,遇到问题时可以对号入座。
5.1 现象:能 ping 通 PLC,但 5000 端口连不上
现象很典型:命令行 ping 有回包,程序里TcpClient.Connect抛「目标计算机积极拒绝」或者一直卡在连接超时。
原因通常不在网络,而在 PLC 侧的以太网配置。FX5U 的以太网端口需要显式配置「对象设备连接」,把上位机的 IP 和端口登记进去,PLC 才会在这个端口上监听。只配了 IP 地址没配连接,ping 是通的,端口是关的。另一种情况是 PLC 侧配置的是 UDP,上位机用 TCP 去连,自然连不上。
解决办法分三步走:先在 PLC 工程软件里确认以太网端口设置里存在一条指向本机 IP 的 TCP 连接,端口号记下来;再确认 PLC 和目标 IP 在同一网段,子网掩码一致;最后用telnet PLC地址 端口号验证端口是否真的打开。这一步确认完再回头看代码,能省掉大量怀疑人生的时间。
还有一种隐蔽情况:PLC 侧允许的连接数用满了。多个上位机软件或者上一次调试残留的连接没释放,新连接会被拒。把 PLC 断电重启一次,如果立刻能连上,就是这个原因。
5.2 现象:读回来的数值大得离谱,或者全是 0
现象是读 100 个字,界面上显示 65535、43981 这种明显不合理的数字,或者干脆全是 0,但 PLC 监视器里明明有值。
原因八成分两种。第一种是字节序搞反了:MC 协议是低字节在前,如果你用BitConverter.ToUInt16处理整个响应数组却没考虑偏移,或者中间某处做了大小端翻转,数值就会变成0xABCD读成0xCDAB。第二种是指令号写错,把批量读 0401 写成了 1401(批量写),发出去的是写指令,PLC 会把它当写操作处理,返回的数据区自然不对。
定位方法:抓一次报文,把前 11 字节按表逐个字段核对,尤其是第 11、12 字节的指令字段。再用 PLC 监视器核对首地址的值,比如 D100 里是 1234(0x04D2),响应数据区应该是D2 04。对不上就是偏移算错,对上了但数值乱就是解析时翻转了字节序。
顺带说一句,D 区里如果存的是 32 位数据,两个字要按低字在前、高字在后拼,跟字节序是两回事,容易混。
5.3 现象:位读写错位,读到的货位状态整体偏一格
这就是第 1 章开头那个问题的原貌:货位状态看起来对,但每个都往前或往后错了一位,边界处的货位状态完全对不上。
原因在于 X/Y/B/W 这几类软元件的地址是十六进制表示的,而 D/M 是十进制。代码里int address如果直接拿字符串"X10"解析成 10 传进去,PLC 收到的是 X 的第 10 个点(十六进制的 0x0A),而你要的是 X10(十六进制的 0x10,即十进制 16)。差 6 个点,整排货位全错。
解决方式是地址解析统一走一个转换函数,明确区分十进制和十六进制软元件:
public static int ParseAddress(string device, string addr) { // X/Y/B/W 按十六进制解析,D/M/R 按十进制 bool isHex = device is "X" or "Y" or "B" or "W"; return isHex ? Convert.ToInt32(addr, 16) : int.Parse(addr); }另外位读取时点数最好是 16 的整数倍。虽然协议允许任意点数,但起始地址不对齐到 16 点边界时,某些型号返回的数据排列会跟预期不一致,稳妥起见读位时把起始地址向下取整到 16 的倍数,读回来再截取需要的部分。
5.4 现象:界面卡顿、点按钮没反应,日志里一堆跨线程异常
现象是程序跑起来后界面发涩,切换页面有明显延迟,严重时直接抛InvalidOperationException: 线程间操作无效。
原因很简单:在非 UI 线程上碰了控件。轮询回调、Task.Run里的续体、await之后的代码,执行线程都不一定是 UI 线程。WinForms 不像 WPF 有调度器,跨线程访问控件默认直接抛异常,某些情况下(比如只改属性不改集合)甚至会静默通过,然后在别处炸得更莫名其妙。
解决办法是把所有 UI 更新收敛到一个入口,统一用BeginInvoke而不是Invoke。Invoke是同步的,后台线程会阻塞等 UI 线程处理完,如果 UI 线程正好在等某个锁,就是死锁。BeginInvoke投递完就走,不会阻塞采集。
另一个容易忽略的点是DataGridView的刷新粒度。4096 行全量重绑在低配工控机上要几百毫秒,看起来就是界面卡死。改成只更新变化的行,或者干脆换成自绘的货位矩阵控件,性能差距是数量级的。
5.5 现象:连续跑几个小时后连接自己断了
现象是刚启动一切正常,跑三五个小时之后读取开始报超时,断开重连又能好一阵,周而复始。
常见原因有三个。第一是 PLC 侧的连接空闲超时,长时间没有数据往来会被动关闭,所以哪怕没有业务数据,也要有周期性心跳——用一个 M 位每 2 秒翻转一次,PLC 程序里检测不到翻转就报警,同时这个读操作本身也维持了连接活跃。
第二个原因是异常处理里没关 socket。请求超时后TcpClient没Dispose,句柄泄漏,跑久了本机端口耗尽。所有创建连接的地方都要配using或者try/finally。
第三个原因是并发。轮询线程和按钮点击同时往一个NetworkStream写数据,报文会互相穿插,PLC 收到半截帧直接回错误或者干脆不回。解决办法是给通信层加一个SemaphoreSlim(1,1),所有请求串行化,排队执行。仓库这种场景对吞吐要求不高,串行完全够用,比处理并发冲突省心得多。
6. 二次开发:先把通信层抽成接口,再准备一个假 PLC
真要在这个工程上继续做,我建议第一步不是加功能,而是给通信层抽一个接口。原因很实际:只要代码里到处是new McProtocolClient(...),你就永远没法在没有 PLC 的工位上开发和测试,每次改个解析逻辑都得跑现场或者远程连机,效率低到离谱。
接口定义尽量窄:
public interface IPlcClient : IDisposable { Task<ushort[]> ReadWordsAsync(string device, int address, ushort points, CancellationToken ct); Task WriteWordsAsync(string device, int address, ushort[] values, CancellationToken ct); Task<bool[]> ReadBitsAsync(string device, int address, ushort points, CancellationToken ct); Task WriteBitAsync(string device, int address, bool value, CancellationToken ct); bool IsConnected { get; } }然后用一个SimulatedPlcClient实现它,内部就是一个Dictionary<string, ushort[]>,按device + address存数据。这个模拟器只要三十行代码,但它能让你在没硬件的情况下跑通整个界面、任务流程和异常分支。更实用的是,模拟器可以故意制造故障——比如让某次读超时、让结束代码返回0xC059、让货位状态在某次轮询后突然变化,用来验证界面和告警逻辑。这些场景在真机上很难复现,在模拟器里就是改一行代码的事。
切换方式放在配置里,不要在代码里 if-else 满天飞:
| 配置项 | 真机 | 模拟 |
|---|---|---|
Plc:Mode | Real | Sim |
Plc:Ip | 现场 PLC 地址 | 忽略 |
Plc:Port | 5000 | 忽略 |
Plc:PollIntervalMs | 200 | 200 |
第二个建议是把地址表从常量改成配置。工程里现在是一堆const int SlotBase = 1000;,换个项目地址全变,就得重新编译。抽成一个 JSON,加载成AddressMap对象,业务层通过名字取值。这么做还有个附带好处:现场调试时不用改代码,改配置文件重启就行,PLC 工程师临时挪了地址也能应付。
第三个建议是加一个报文日志开关。通信层在Send和Receive之后各打一行十六进制字符串,默认关闭,出问题时打开。这比抓包方便,因为它带上了业务上下文,能直接看到「下发任务 12 时发出的帧」和「PLC 回的结束代码」。日志按天切割,单文件超过 20MB 就滚动。
验证改造是否成功的方法也很朴素:把模拟器跑起来,让它连续跑两小时,中途手动断开一次、制造五次随机错误,看界面有没有卡死、任务有没有卡在中间态、重连之后数据是不是自动恢复。这套流程走通了,再去现场。
从那以后我每次接新的 PLC 通信项目,都强制先用模拟器把业务逻辑跑完一轮再上机,真机上只留协议字段和网络配置这两类问题——把现场时间留给真正需要现场的事。希望帮到你。
本文还有配套的精品资源,点击获取