简介:这份资源面向具备一定C#基础、希望打通工业现场通信链路的开发者,聚焦三菱FX5U PLC与上位机之间的以太网通信实现,尤其贴合自动化立体仓库场景下的库存管理、货位查询与物料搬运指令交互需求。压缩包共35个文件、约79KB,以10个cs源码文件为核心,配合sln与csproj工程文件、config配置、resx与resources界面资源以及exe、pdb等编译产物,构成一套可直接打开运行的WinForm示例工程。内容围绕3E帧报文结构展开,涵盖连接建立、报文构造与字节序转换、数据收发、PLC私有协议解析以及超时重发等错误处理思路,界面部分则演示通信状态与读写结果的展示方式。已有468人学习,适合作为理解FX5U以太网通信流程、搭建上位机原型的参考样例。
1. 从一条报文说起:为什么这套方案值得照着做
做自动化立体仓库上位机,最容易被低估的不是界面,而是通信。堆垛机、输送线、WMS 三方数据一对接,现场调试时最常听到的一句话就是“报文发出去了,设备没动”。问题往往不在协议本身,而在字节序、站号、超时重发、状态机这些细节上。这套基于 C# WinForm 的三菱 FX5U 以太网通信上位机方案,解决的就是“上位机如何稳定读写 PLC 软元件、并把仓库任务指令可靠下发”这件事。它适合正在做自动化立体仓库毕设、工控上位机课程设计,或者需要交付一套可运行通信 Demo 的开发者。整套方案围绕 MC 协议(MELSEC Communication Protocol)的 TCP 报文收发展开,把连接管理、软元件读写、任务调度、异常重连拆成可独立验证的模块,读完你能直接对照着搭出自己的通信层。
2. 需求与选型:为什么是 FX5U 加 MC 协议 TCP
2.1 现场需求拆解成四条硬指标
自动化立体仓库的上位机,本质是一个“任务翻译器”:把入库、出库、移库的业务指令翻译成 PLC 能执行的软元件写入,再把货位状态、设备报警读回来展示。落到通信层,需求可以拆成四条硬指标。第一是实时性,堆垛机取放货有节拍要求,指令下发延迟通常要控制在百毫秒级,轮询读取状态不能把 UI 线程拖死。第二是可靠性,现场网络抖动、PLC 断电重启是常态,上位机必须能自动重连而不是让人手动点按钮。第三是可追溯,每一条下发指令和每一条回读数据都要能落日志,出问题能查“黑匣子”。第四是可扩展,货位数从几十个涨到几百个,读写逻辑不能推倒重来。
这四条指标决定了技术选型不能图省事。用串口转以太网模块虽然便宜,但多设备并发时延抖动大;用 OPC 中间件省事,但部署多一层、现场排障多一个变量。直接走 PLC 原生以太网协议,链路最短,问题定位最直接。
2.2 MC 协议与 TCP 的连接方式
三菱 FX5U 内置以太网口,支持 MC 协议。上位机作为客户端,PLC 作为服务端,默认端口通常配置为 5000 系列端口(具体以 PLC 参数设置为准),走 TCP 长连接。MC 协议分 3E 帧和 4E 帧,FX5U 常用 3E 帧,报文结构是“副头部 + 访问路径 + 请求数据”三段式。副头部固定 5000,后面跟网络号、PLC 号、请求目标模块的 I/O 编号和站号,再往后才是软元件批量读写的命令码和地址。
选 TCP 而不是 UDP,是因为仓库指令不允许丢包,UDP 虽然快但重传要自己做,反而增加复杂度。长连接而不是短连接,是因为每次读写都三次握手,几百个货位轮询下来开销吃不消。这里有个血泪经验:不少人在 PLC 侧把“对象设备连接配置”里的连接方式设成 UDP,上位机却按 TCP 连,现象就是连上了但一条报文都收不到,排查半天以为是报文拼错了。
2.3 软元件地址映射先定死
在写任何代码之前,先把软元件地址表定下来,这是整个项目的“契约”。通常的做法是:D 区放任务参数(货位号、任务类型、目标层列),M 区放握手标志(指令有效、执行完成、故障),X/Y 区映射实际 I/O 状态。地址表一旦定死,上位机和 PLC 程序各自开发、最后联调,能省掉大量来回改地址的时间。
| 软元件 | 地址范围 | 用途 | 读写方向 |
|---|---|---|---|
| D100~D119 | 20 字 | 当前任务参数区 | 上位机写 |
| D200~D219 | 20 字 | 任务执行结果区 | 上位机读 |
| M100 | 1 位 | 指令有效握手位 | 上位机写 |
| M101 | 1 位 | 执行完成标志 | 上位机读 |
| M102 | 1 位 | 故障标志 | 上位机读 |
| D300~D399 | 100 字 | 货位状态缓存 | 上位机读 |
这张表看着简单,但它是后面所有代码的基准。地址偏移算错一位,读回来的就是隔壁货位的数据,现场表现是“货位状态乱跳”,这种玄学问题查起来最费时间。
3. 总体架构设计:三层结构与四个核心模块
3.1 分层思路与模块职责
整套上位机按三层划分:通信层、业务层、表现层。通信层只干一件事——把“读 D100 起 20 个字”翻译成字节流发出去,再把回来的字节流解析成数组,它不关心这些数据代表什么。业务层负责任务调度、状态机推进、地址映射,它调用通信层的读写接口,把业务语义翻译成软元件操作。表现层是 WinForm 界面,只做展示和用户输入,所有耗时操作通过异步回调更新 UI。
四个核心模块分别是:连接管理器(负责建链、心跳、断线重连)、报文编解码器(负责 3E 帧的组包与拆包)、软元件读写服务(封装批量读写接口)、任务调度器(负责指令下发与握手确认)。这样拆的好处是每个模块都能单独写单元测试,比如编解码器可以拿固定报文做回归,不需要连真实 PLC。
3.2 连接管理器:长连接与心跳保活
连接管理器的核心是一个带状态机的 TCP 客户端。状态分“未连接、连接中、已连接、重连中”四态,任何一次读写失败都触发状态迁移,而不是让异常往上冒。心跳的做法通常是每隔固定周期读一个固定软元件(比如读 M100 一位),读成功说明链路活着,连续失败达到阈值就主动断开重连。
public class PlcConnectionManager { private TcpClient _client; private NetworkStream _stream; private readonly string _ip; private readonly int _port; private volatile bool _connected; public PlcConnectionManager(string ip, int port) { _ip = ip; _port = port; } // 建立连接,超时时间单独控制,避免 UI 卡死 public async Task<bool> ConnectAsync(int timeoutMs = 3000) { try { _client = new TcpClient(); var connectTask = _client.ConnectAsync(_ip, _port); // 超时保护:现场网络不通时不能无限等待 if (await Task.WhenAny(connectTask, Task.Delay(timeoutMs)) != connectTask) return false; _stream = _client.GetStream(); _stream.ReadTimeout = 2000; _connected = true; return true; } catch (SocketException) { _connected = false; return false; } } // 心跳:读一位 M 区标志,成功即链路正常 public async Task<bool> HeartbeatAsync() { if (!_connected) return false; var req = McFrame.BuildReadBits("M100", 1); var resp = await SendAndReceiveAsync(req); return resp != null && McFrame.IsSuccess(resp); } }这段代码的关键点在超时保护。TcpClient.ConnectAsync在目标不可达时可能挂很久,现场调试时如果直接await,界面就假死了,操作员以为程序崩了。用Task.WhenAny加延时做竞速,是工控上位机的常规做法。心跳读一位而不是读一片,是为了让心跳报文尽量短,减少对 PLC 扫描周期的干扰。参数上,连接超时 3 秒、读超时 2 秒是经验值,网络条件差的现场可以放宽到 5 秒,但不宜再大,否则断线感知太迟钝。
3.3 报文编解码器:3E 帧的组包与拆包
编解码器是整个方案里最需要“抠字节”的部分。3E 帧的请求报文结构是:副头部(2 字节,固定 0x5000)、网络号(1 字节)、PLC 号(1 字节)、请求目标模块 I/O 编号(2 字节)、请求目标模块站号(1 字节)、请求数据长度(2 字节)、CPU 监视定时器(2 字节)、命令码(2 字节)、子命令(2 字节)、起始软元件地址(3 字节)、软元件点数(2 字节)。批量读字和读位的命令码不同,读字是 0x0401,读位是 0x0401 配子命令 0x0001,写字的命令码是 0x1401。
public static class McFrame { // 组批量读字报文:起始地址如 "D100",点数 count public static byte[] BuildReadWords(string device, int count) { var addr = DeviceAddress.Parse(device); // 解析出软元件类型与偏移 var body = new List<byte>(); body.AddRange(new byte[] { 0x01, 0x04 }); // 命令码 0401,小端存放 body.AddRange(new byte[] { 0x00, 0x00 }); // 子命令 0000 表示按字 body.AddRange(addr.ToMcAddressBytes()); // 3 字节起始地址 body.AddRange(BitConverter.GetBytes((ushort)count)); // 点数,小端 var frame = new List<byte>(); frame.AddRange(new byte[] { 0x50, 0x00 }); // 副头部 frame.AddRange(new byte[] { 0x00, 0xFF, 0xFF, 0x03, 0x00 }); // 网络号/PLC号/IO/站号 frame.AddRange(BitConverter.GetBytes((ushort)body.Count)); // 请求数据长度 frame.AddRange(new byte[] { 0x10, 0x00 }); // CPU 监视定时器 frame.AddRange(body); return frame.ToArray(); } // 判断响应是否成功:结束代码为 0 才算成功 public static bool IsSuccess(byte[] resp) { if (resp == null || resp.Length < 11) return false; // 结束代码位于副头部+访问路径之后,2 字节小端 int endCode = resp[9] | (resp[10] << 8); return endCode == 0; } }这里最容易翻车的是字节序。MC 协议整体是小端,但副头部 0x5000 在报文里表现为50 00,而命令码 0x0401 要写成01 04。软元件地址的 3 字节编码也有讲究:D 区地址是“偏移量左移一位后按 3 字节小端”,M 区类似但类型码不同。地址算错的表现是 PLC 返回结束代码非零,或者读回来的数据全零。建议把地址解析单独写一个类,用固定用例做回归测试,比如D100解析结果和手工计算值比对,这一步做扎实,后面能少熬好几个晚上。
3.4 软元件读写服务与任务调度器
读写服务在编解码器之上再包一层,提供ReadWordsAsync、WriteWordsAsync、ReadBitsAsync这类语义化接口,内部负责组包、发送、收包、校验、异常转换。任务调度器则维护一个任务队列,每条任务包含货位号、任务类型、超时时间。下发流程是:写任务参数到 D 区 → 置位 M100 指令有效 → 轮询 M101 完成标志 → 读 D200 结果区 → 复位 M100。这个握手过程必须有超时,超时后置故障标志并记录日志,而不是无限等。
public async Task<bool> DispatchTaskAsync(WarehouseTask task, int timeoutMs = 5000) { // 1. 写任务参数 await _io.WriteWordsAsync("D100", task.ToParamWords()); // 2. 置位指令有效 await _io.WriteBitsAsync("M100", new[] { true }); // 3. 轮询完成标志,带超时 var sw = Stopwatch.StartNew(); while (sw.ElapsedMilliseconds < timeoutMs) { var done = await _io.ReadBitsAsync("M101", 1); if (done[0]) { var result = await _io.ReadWordsAsync("D200", 20); await _io.WriteBitsAsync("M100", new[] { false }); // 复位握手 return task.ValidateResult(result); } await Task.Delay(50); // 轮询间隔,太密会压 PLC 扫描 } _logger.Warn($"任务 {task.Id} 超时未完成"); return false; }轮询间隔 50 毫秒是权衡结果:太密(比如 5 毫秒)会让 PLC 通信负载升高,影响扫描周期;太疏(比如 500 毫秒)则任务节拍跟不上。握手位复位这一步不能省,否则下一条任务下发时 PLC 还认为上一条没结束,现象是“第二条指令永远不执行”,这种问题在现场排查时非常隐蔽。
4. 关键实现细节:从界面线程到日志追溯
4.1 WinForm 异步更新与 UI 线程安全
WinForm 的老问题是跨线程更新控件抛异常。通信层跑在后台任务里,读回的数据要刷到 DataGridView 或状态灯上,必须切回 UI 线程。通常的做法是用Control.Invoke或BeginInvoke封装一个安全的更新方法,而不是在每个回调里手写。更稳妥的是引入一个轻量的事件聚合:通信层只发数据事件,界面层订阅后统一BeginInvoke刷新,这样通信层完全不依赖任何控件类型,单元测试也好写。
// 界面层统一的安全刷新入口 private void OnPlcDataReceived(object sender, PlcDataEventArgs e) { if (this.IsDisposed) return; if (this.InvokeRequired) { this.BeginInvoke(new Action(() => ApplyData(e))); return; } ApplyData(e); } private void ApplyData(PlcDataEventArgs e) { // 只更新变化的行,避免整表重绘造成闪烁 foreach (var slot in e.SlotStates) _slotGrid.UpdateRow(slot.Index, slot.State); }BeginInvoke而不是Invoke,是因为前者不阻塞后台线程,通信节拍不会被界面重绘拖慢。IsDisposed判断是防止窗体关闭瞬间回调还在跑,抛对象已释放异常。只更新变化行而不是整表刷新,是仓库货位多时的必要优化,几百行整表重绘的闪烁在现场很难看。
4.2 日志与报文追溯的设计
工控上位机的日志不是“打印点信息”那么简单,它要能回答三个问题:什么时候发了什么报文、PLC 回了什么、当时业务上下文是什么。做法是每条报文收发都记一条结构化日志,包含时间戳、方向、十六进制报文、解析后的软元件与数值、关联任务号。日志按天滚动,单文件限制大小,避免跑一周把磁盘写满。排查时按任务号一搜,整条链路清清楚楚,这就是前面说的“黑匣子”。
提示:报文日志建议同时保留原始十六进制和解析结果。只留解析结果,遇到协议层问题时没法复现;只留十六进制,业务排查时又看不懂。
4.3 断线重连与状态恢复
断线重连不是简单地把Connect再调一次。重连成功后要做的第一件事是“状态对齐”:把界面上显示的任务状态和 PLC 实际状态比对一遍,因为断线期间 PLC 可能已经执行完了某条指令。通常的做法是重连后先读一遍握手位和结果区,如果发现 M101 已置位而上位机记录还是“执行中”,就补一次结果读取并更新界面,避免状态永久卡死。重连策略用指数退避,首次 1 秒、之后 2 秒、4 秒,上限 30 秒,避免 PLC 重启期间上位机疯狂重连把连接队列打满。
5. 避坑与常见问题排查
5.1 连上了但读不到数据
现象是 TCP 连接建立成功,心跳却一直失败,或者读指令返回结束代码非零。原因通常有三类:PLC 侧连接配置的协议类型与上位机不一致(TCP/UDP 搞混)、站号或网络号填错、软元件地址编码算错。解决顺序是先抓报文看副头部和访问路径是否与 PLC 参数一致,再用一个最小读指令(读 M100 一位)验证链路,最后才怀疑地址解析。地址问题用固定用例回归,别靠现场试。
5.2 数据高低字节颠倒
现象是读回来的数值明显不对,比如期望 1000 读成 59395 之类。原因是 MC 协议按字传输时是小端,但某些软元件或某些 PLC 参数设置下字内字节序可能不同。解决方法是先用一个已知值写入再读回比对,确认字节序后再统一在解析层处理,不要在业务代码里到处Reverse。这类问题一旦散落在多处,后面改起来就是灾难。
5.3 轮询太密导致 PLC 扫描周期变长
现象是上位机一开,设备动作就变慢,甚至偶发看门狗报警。原因是轮询频率超过了 PLC 通信处理能力,或者一次读的点数过大。解决办法是合并读取:把相邻地址的货位状态一次批量读回,而不是一个货位发一条报文;轮询间隔不低于 50 毫秒;心跳和业务轮询错开时间片。批量读的点数也要控制,一次读几百个字虽然协议支持,但会占用 PLC 通信缓冲区。
5.4 握手位没复位导致任务卡死
现象是第一条任务正常,第二条下发后 PLC 毫无反应。原因是上位机置位 M100 后,PLC 执行完置位 M101,但上位机没有把 M100 复位,PLC 程序里“指令有效”的上升沿条件不再满足。解决办法是在任务完成分支里强制复位握手位,并且加一条兜底逻辑:每次下发新任务前先检查 M100 是否为 ON,若是则先复位再下发。这个兜底能救回很多现场意外。
5.5 界面卡顿与假死
现象是点“下发”后界面几秒无响应。原因是同步调用网络读写阻塞了 UI 线程,或者Invoke用成了同步版本造成死锁。解决办法是全链路异步化,网络操作一律async/await,界面更新用BeginInvoke,并且给所有网络操作加超时。工控现场操作员对卡顿极其敏感,一次假死就可能被判定为“程序不可靠”。
6. 验收与答辩:怎么证明这套通信真的可靠
验收这套方案,不能只看“界面上数据在跳”。我一般会设计四组可复现的测试,每组都有明确的通过标准。第一组是连通性测试:断开 PLC 网线,观察上位机是否在设定时间内进入重连状态,恢复网线后是否自动恢复且状态对齐,通过标准是全程无需人工干预。第二组是报文正确性测试:用固定任务下发,抓包比对请求报文的副头部、命令码、地址、点数是否与协议文档一致,通过标准是逐字节吻合。第三组是压力测试:连续下发 200 条任务,统计成功率和平均响应时间,通过标准是成功率 100%、无握手卡死。第四组是异常注入测试:在任务执行中途强制 PLC 断电再上电,观察上位机是否补读结果、是否误判任务失败。
| 测试项 | 操作 | 通过标准 |
|---|---|---|
| 断线重连 | 拔插网线 | 自动重连,状态对齐,无人工干预 |
| 报文比对 | 抓包对照协议 | 关键字段逐字节一致 |
| 连续任务 | 下发 200 条 | 成功率 100%,无卡死 |
| 异常注入 | 执行中断电重启 | 补读结果,不误判 |
答辩时最容易被问的是“你怎么保证报文没拼错”。这时候不要空口说,直接打开日志文件,指着一条收发记录讲:这是请求的十六进制,这是 PLC 返回的结束代码 0,这是解析出的货位状态,和界面上显示的一致。再补一句“编解码器有固定用例回归,改代码后先跑用例再上现场”,说服力就够了。另一个高频问题是“为什么不用现成库”,回答要落到可控性:现场排障时需要看到每一条原始报文,自己封装的编解码器能精确控制字节,出问题定位路径最短。
从那以后我每次做上位机项目,都会在写业务代码之前先把“最小可通信 Demo”跑通——一条读指令、一条写指令、一次断线重连,三件事验证完再往上堆功能。这个习惯让我少熬了很多本可以避免的夜。希望这套结构和实现细节,能帮你在做自己的仓库上位机方案时少走几步弯路。
本文还有配套的精品资源,点击获取