简介:面向工业自动化方向开发者与C#上位机编程学习者,这份资源聚焦三菱FX5U PLC的以太网通信实现,围绕3E帧报文结构展开,可用于自动化立体仓库场景下的通信调试与项目参考。压缩包共35个文件、约79KB,以cs源码为主体,辅以csproj、sln解决方案文件、config配置、resx与resources资源及exe可执行文件,构成一套可直接打开运行的WinForm工程。已有468人学习下载,具备一定参考基础。读者可从中获取连接建立、报文构造与字节序转换、数据收发、错误重试机制以及界面交互等环节的代码组织思路,并对照PLC指令集理解私有协议解析方式,适合作为通信模块搭建与排错的学习样本。
1. 从一条 MC 报文说起:这个上位机项目到底解决什么问题
产线调试最怕什么?PLC 就在网口对面,可上位机连不上,或者连上了读回来的数据全是乱的。我见过一个自动化立体仓库的现场,堆垛机在巷道里来回跑,上位机界面上的货位状态却停在十分钟前——问题不在 C# 代码写得多烂,而在以太网报文这一层没对齐。这个基于 WinForm 的 C# 上位机项目,干的就是把三菱 FX5U 的以太网口用起来:通过 MC 协议(MELSEC Communication)走 TCP,读写 D/M/X/Y 这些软元件,把仓库的货位、堆垛机、输送线状态搬到桌面上。适合谁?做自动化立体仓库上位机、产线数据采集、设备联调的 C# 开发者,尤其是手里有 FX5U 但不想上组态软件、想自己控报文的那批人。
2. 技术选型与工程骨架:WinForm + 原生 Socket 的取舍
2.1 为什么不用现成通信库
这个项目的技术栈很朴素:.NET Framework 上的 WinForm 做界面,System.Net.Sockets原生 Socket 做通信,没有引入任何第三方 PLC 库。原因不复杂——MC 协议的 3E 帧结构是公开且固定的,自己拼字节反而比套一层库更可控。常见做法是用TcpClient包一层,但项目里直接操作Socket,好处是超时、重连、粘包处理都能自己说了算,坏处是所有字节序、长度字段都得手写。
FX5U 的以太网通信走的是 MC 协议 3E 帧(也有 4E,但 FX5U 场景下 3E 最常用)。一帧请求的结构大致是:副头部50 00、网络号、PLC 号、请求目标模块 IO 号、请求目标模块站号、请求数据长度、CPU 监视定时器、命令码、子命令、起始软元件地址、软元件点数。响应帧把命令码之后换成结束代码和数据区。这些字段一个都不能错,错一个字节,PLC 直接回错误码或者干脆不回。
2.2 目录结构长什么样
项目是典型的 WinForm 三层结构,没有过度设计:
FX5U_Ethernet_Demo/ ├── FX5U_Ethernet_Demo.sln ├── FX5U_Ethernet_Demo/ │ ├── Program.cs // 入口,Application.Run │ ├── MainForm.cs // 主界面,按钮/定时器/数据显示 │ ├── MainForm.Designer.cs │ ├── Comm/ │ │ ├── McProtocol.cs // 报文拼装与解析核心 │ │ ├── PlcClient.cs // Socket 连接、收发、重连 │ │ └── PlcAddress.cs // 软元件地址换算(D100 -> 字节偏移) │ ├── Models/ │ │ └── DeviceState.cs // 货位/堆垛机状态实体 │ └── Config/ │ └── app.config // IP、端口、轮询间隔Comm目录是整个项目的命脉,界面层只负责调PlcClient.ReadWords()这类方法,不碰字节。这种分层的好处是:换 PLC 型号或者改协议版本,只动Comm,界面不用重写。
2.3 连接与重连的骨架代码
先看连接部分,这是所有读写的前提:
public class PlcClient { private Socket _socket; private readonly string _ip; private readonly int _port; // FX5U 默认 5000 左右,按工程实际配置 private readonly int _timeoutMs; // 收发超时,一般 1000~3000 public bool Connect() { try { _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.ReceiveTimeout = _timeoutMs; _socket.SendTimeout = _timeoutMs; // 关键:连接超时不能只靠 ReceiveTimeout,用异步等待 var ar = _socket.BeginConnect(_ip, _port, null, null); if (!ar.AsyncWaitHandle.WaitOne(_timeoutMs)) { _socket.Close(); return false; } _socket.EndConnect(ar); return true; } catch (SocketException ex) { // 记录 ex.SocketErrorCode,现场排查靠它 return false; } } }这里有个血泪经验:Socket.Connect()是同步阻塞的,PLC 没上电或者网线没插时,它会卡到系统默认超时(可能几十秒),界面直接假死。所以用BeginConnect+WaitOne自己控超时。_timeoutMs建议 2000 左右,太短现场网络抖动会误判,太长界面卡顿明显。ReceiveTimeout和SendTimeout也要设,否则Receive会无限等。
2.4 地址换算:D100 到底对应哪个字节
MC 协议里软元件地址不是直接写D100,而是要转成 3 字节的起始地址。D 寄存器按字为单位,地址就是编号本身;X/Y 是位软元件,要按 16 位一组换算。项目里PlcAddress.cs干的就是这件事:
public static class PlcAddress { // 软元件代码:D=0xA8, M=0x90, X=0x9C, Y=0x9D(3E 帧常用值) public static byte[] BuildDeviceAddress(string device, int address) { int code = device switch { "D" => 0xA8, "M" => 0x90, "X" => 0x9C, "Y" => 0x9D, _ => throw new ArgumentException("不支持的软元件") }; // 3 字节小端:低字节在前 return new byte[] { (byte)(address & 0xFF), (byte)((address >> 8) & 0xFF), (byte)((address >> 16) & 0xFF) }; } }注意软元件代码在不同帧格式下会变,3E 帧的 D 是0xA8,如果现场用的是 4E 帧或者 Q 系列,值不一样。这个表建议做成配置而不是硬编码,后面二次开发会讲到。地址是 3 字节小端,D100就是64 00 00,别写成大端,否则读回来的是别的寄存器。
3. 核心报文拆解:一次读写的完整链路
3.1 读 D 寄存器的请求帧怎么拼
读操作是上位机最常用的,比如定时读 100 个 D 寄存器看货位状态。3E 帧的批量读请求:
public byte[] BuildReadRequest(string device, int start, int count) { var frame = new List<byte>(); // 副头部 5000 + 网络号00 + PLC号FF + IO号FF03 + 站号00 frame.AddRange(new byte[] { 0x50, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00 }); // 请求数据长度:从监视定时器到软元件点数,共 12 字节 frame.AddRange(new byte[] { 0x0C, 0x00 }); // CPU 监视定时器,0x0010 表示 4 秒左右 frame.AddRange(new byte[] { 0x10, 0x00 }); // 命令码 0401 = 批量读,子命令 0000 = 按字 frame.AddRange(new byte[] { 0x01, 0x04, 0x00, 0x00 }); // 起始软元件地址 3 字节 frame.AddRange(PlcAddress.BuildDeviceAddress(device, start)); // 软元件代码 1 字节 frame.Add((byte)(device == "D" ? 0xA8 : 0x90)); // 点数 2 字节小端 frame.Add((byte)(count & 0xFF)); frame.Add((byte)((count >> 8) & 0xFF)); return frame.ToArray(); }逐段说:副头部50 00是 3E 帧标志;00 FF FF 03 00是网络号、PLC 号、目标模块 IO 号、站号,单机直连时基本固定;0C 00是后续数据长度,读请求固定 12 字节;10 00是监视定时器,单位 250ms,0x10约 4 秒,超时 PLC 会丢弃请求;01 04是批量读命令,00 00表示按字读;后面跟地址、软元件代码、点数。点数上限一般 960 字,超了要分批。
3.2 响应帧解析与粘包处理
发出去之后,Receive回来的不一定是一整帧,TCP 是流,可能半帧也可能两帧粘一起。项目里的做法是先读副头部和长度字段,再按长度读剩余:
private byte[] ReceiveFrame() { var header = ReadExact(9); // 副头2+网络1+PLC1+IO2+站1+长度2 int dataLen = header[7] | (header[8] << 8); var body = ReadExact(dataLen); // 结束代码2 + 数据区 var full = new byte[9 + dataLen]; Buffer.BlockCopy(header, 0, full, 0, 9); Buffer.BlockCopy(body, 0, full, 9, dataLen); return full; } private byte[] ReadExact(int n) { var buf = new byte[n]; int offset = 0; while (offset < n) { int read = _socket.Receive(buf, offset, n - offset, SocketFlags.None); if (read == 0) throw new IOException("连接被对端关闭"); offset += read; } return buf; }ReadExact是防粘包的核心,循环读到够数为止。响应帧第 9、10 字节是结束代码,00 00表示成功,非零就是错误码,比如C051是地址越界、C056是点数超限。数据区从第 11 字节开始,按字读时每 2 字节一个小端 short。解析时务必先判结束代码,别直接拿数据区当有效值,否则错误码会被当成货位数据,界面显示一堆乱码。
3.3 写操作的差异点
写单个 D 寄存器用命令码1401,子命令0000,数据区直接跟 2 字节值;批量写用1401+0001(按位)或按字。写请求的长度字段和读不同,要算上数据区。项目里WriteWord方法把值转小端后拼进去,注意写之前最好先读一次确认地址合法,现场见过地址写错把配方区覆盖的翻车案例。
4. 界面与轮询:WinForm 定时器怎么不卡
4.1 定时轮询的正确姿势
界面用System.Windows.Forms.Timer定时读状态,间隔 200~500ms。坑在于:如果在 UI 线程里同步Receive,网络一慢界面就卡。项目里的做法是通信放后台线程,读完用Invoke回 UI 更新:
private void PollTimer_Tick(object sender, EventArgs e) { if (_polling) return; // 防重入,上一轮没完就跳过 _polling = true; Task.Run(() => { try { var data = _client.ReadWords("D", 100, 50); this.Invoke((Action)(() => UpdateGrid(data))); } catch (Exception ex) { this.Invoke((Action)(() => SetStatus("通信异常: " + ex.Message))); } finally { _polling = false; } }); }_polling标志是后悔药,防止定时器叠加导致请求堆积。Invoke回 UI 是必须的,WinForm 控件跨线程直接访问会抛异常。轮询间隔别设太小,FX5U 的 MC 连接数有限,太频繁现场会偶发拒绝连接。
4.2 数据到界面的映射
UpdateGrid把 short 数组按货位编号映射到DataGridView的行,堆垛机状态用 X/Y 位解析。这里建议把「地址 → 业务含义」做成配置表,而不是在代码里写死data[0]是 1 号货位,否则仓库扩货位就得改代码。
5. 避坑与现场排查:那些让联调多花两天的坑
5.1 连不上但 ping 得通
现象:能 ping 通 PLC,Connect却超时。原因多半是 FX5U 的以太网参数没开 MC 协议,或者端口不对。解决:在工程设置里确认「以太网通信设置」启用了 MC 协议,端口和上位机一致;用抓包工具看有没有 SYN 回包,没有就是 PLC 侧没监听。
5.2 读回来全是 0 或乱码
现象:结束代码00 00成功,但数据不对。原因通常是软元件代码或地址字节序错了,比如把 D 的0xA8写成0x90,或者地址写成了大端。解决:先用单点读D0验证,对照 PLC 监视值;确认 3 字节地址是小端。
5.3 偶发超时、重连后正常
现象:跑一段时间偶发Receive超时。原因可能是轮询太密、PLC 连接数占满,或者网络有丢包。解决:加_polling防重入、拉长间隔、实现断线重连(Connect失败后隔几秒重试),别在异常里直接Close不重连。
5.4 界面卡死
现象:点按钮后界面无响应。原因是在 UI 线程同步收发。解决:通信全部放Task.Run,UI 更新走Invoke,Socket超时设短。
5.5 写操作覆盖了别的区
现象:写完配方,别的数据变了。原因是地址算错或点数超了。解决:写前先读校验,点数严格按实际,别图省事一次写一大片。
6. 二次开发:把这份 Demo 改成能上产线的上位机
拿到这个项目,直接跑通只是第一步,真正要落地得改三处。第一,把PlcAddress里的软元件代码和app.config里的 IP、端口、轮询间隔全部外置,现场换 PLC 不用重编译。第二,把轮询改成「按需 + 变化上报」:货位状态不用每 200ms 全读,按分区轮询,堆垛机位置用高频、库存用低频,减轻 PLC 负担。第三,加一层通信日志,把每帧请求响应的十六进制存文件,现场出问题直接翻日志,比猜快得多。
验证方法很直接:先用 PLC 编程软件强制几个 D 值,看界面是否同步;再拔网线看是否走重连;最后压测连续跑两小时看有没有内存增长。我一般会在PlcClient里加个FrameLogger,开关放配置里,平时关着,联调时打开。
| 改造点 | 原实现 | 建议改法 |
|---|---|---|
| 地址配置 | 代码硬编码 | 配置文件 + 映射表 |
| 轮询策略 | 全量定时 | 分区 + 变化检测 |
| 异常处理 | 简单 catch | 重连 + 日志 + 告警 |
有个习惯我保持了几年:任何 PLC 通信项目,第一版一定先写「单点读 + 单点写 + 日志」三件套,跑通了再往上堆业务。这个项目也是这么拆的,Comm层独立于界面,改起来不牵连。希望帮到你。
本文还有配套的精品资源,点击获取