简介这份资源面向具备一定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 协议 TCP2.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~D11920 字当前任务参数区上位机写D200~D21920 字任务执行结果区上位机读M1001 位指令有效握手位上位机写M1011 位执行完成标志上位机读M1021 位故障标志上位机读D300~D399100 字货位状态缓存上位机读这张表看着简单但它是后面所有代码的基准。地址偏移算错一位读回来的就是隔壁货位的数据现场表现是“货位状态乱跳”这种玄学问题查起来最费时间。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 Taskbool 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 Taskbool 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 Listbyte(); 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 Listbyte(); 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 Taskbool 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”跑通——一条读指令、一条写指令、一次断线重连三件事验证完再往上堆功能。这个习惯让我少熬了很多本可以避免的夜。希望这套结构和实现细节能帮你在做自己的仓库上位机方案时少走几步弯路。本文还有配套的精品资源点击获取