简介:三菱FX5U上位机以太网通信方案,面向C#开发人员与工业自动化工程师,解决基于3E帧报文结构实现上位机与三菱FX5U PLC的以太网通信问题,适用于自动化立体仓库等需要上位机控制与数据交换的场景。压缩包共35个文件,以C#源文件(10个cs)为主,辅以项目工程文件(sln/csproj)、配置文件、资源文件及可直接运行的exe,整体仅79KB,便于快速查看源码与运行验证。已有467人学习下载。资源包含完整的WinForm上位机程序实现,涵盖网络接口选择、3E帧报文构造与字节序转换、数据收发及错误重试机制,并针对立体仓库业务逻辑给出了PLC指令交互与响应解析的参考代码,适合希望快速上手FX5U以太网通信或进行二次开发的技术人员。
1. 三菱FX5U上位机通信:自动化立库项目跑通报文的第一块跳板
很多做自动化立体仓库的C#开发会在同一个地方翻车:网络能连上三菱FX5U,但PLC就是不回正常数据,D寄存器读到一堆错值,M继电器写了没反应。问题往往不在硬件,而在SLMP报文的组包细节——命令码、设备代码、起始地址的拼接方式差一位都不行。这份基于WinForms的上位机程序把FX5U以太网通信完整拉通,包含批量读写、点位轮询、报文解析,适合刚接手工业通信的C#全栈开发者,也适合想把固定读写改造成可配置点位表的人。你拿过去照着跑通一台真机,基本能避开我当年踩过的大半坑。
2. 项目定位与技术选型:一台FX5U怎么跟WinForms上位机对话
2.1 通信协议选型:SLMP二进制帧对比Modbus TCP的优势
在动手读代码之前,先把通信层的地基聊清楚。FX5U的以太网口默认支持两套上位机协议:Modbus TCP和SLMP(旧称MC协议)。网上讲Modbus TCP的资料多,但Modbus对FX5U内部软元件的映射并不友好——读D区要保持寄存器,M、X、Y这类离散量要么绕路要么在PLC里写FROM/TO指令搬运,凭空多一层约定。
反过来看SLMP,它对三菱软元件有直接映射。D寄存器对应设备代码0x00A8,M继电器对应0x0090,指令结构天然贴合PLC内部寻址,这对自动化立体仓库这种场景很关键:上位机要批量读取货位状态、托盘编号、报警字,再往D寄存器写入目标库号和动作码,每一步都讲究点对点、响应结构固定。
另外要注意,SLMP分为二进制帧和ASCII帧,编码方式差一倍体积。这份项目用的是二进制3E帧:请求帧以“50 00”开头,响应帧以“D0 00”开头。很多教程把两种模式混讲,实际调试时你会看到抓包工具里要么是紧凑的二进制字节流,要么是一长串十六进制ASCII字符。强烈建议以二进制帧为准,不但传输效率高,代码里用BitConverter拼字节也更顺手。
2.2 三层模块划分:通信层、协议层、界面层各司其职
项目虽然是个WinForms程序,结构却没有把逻辑全堆在窗体里。有一个贯穿始终的三层划分,我拆包时花了些心思才看明白,也是我建议你保留下来的骨架:
通信层负责Socket连接、断线重连、收发缓冲,名字我一般叫PLCConnection。协议层专管SLMP组帧、拆帧,不关心业务逻辑,McProtocol这个类会把整包扔给它,返回List 。界面层只做展示和操作触发,FormMain里基本看不到任何Socket代码,它调用协议层后把结果绑定到DataGridView或者刷新状态栏。
这个边界在二次开发时的价值很直接:立体仓库要加一个堆垛机状态区,只需要在协议层加一个读方法,界面层加一组绑定,通信层完全不用动。反过来,如果某天要把串口通信换成以太网,界面层和协议层一个字都不用改,只替换通信层实现即可。实际项目中很多通信“玄学”问题,追根溯源都是边界没划清,界面线程里直接发报文,最后把线程安全和协议解析搅在一起。
2.3 目录结构与关键文件:先从哪几个文件读代码
解压源码包后,典型的结构是这样的,实际文件名以你拿到的那份为准:
FX5U上位机通信/ ├── FX5U上位机通信.sln ├── PLCCom/ │ ├── PLCDevice.cs # 设备信息实体 │ ├── PLCConnection.cs # Socket连接与收发缓冲 │ └── McProtocol.cs # SLMP协议组帧与解析 ├── UI/ │ ├── FormMain.cs # 主窗口 │ └── PointTable.cs # 点位配置实体 └── Config/ └── PointTable.xml # 点位映射表阅读顺序我建议从McProtocol.cs看起,这是整个项目的心脏。先看它的BuildBatchReadFrame方法怎么拼字节,再看BatchWrite怎么带数据段,最后才去翻FormMain界面代码。
PLCConnection.cs也别跳过,里面包含了最容易被忽略的TCP粘包处理:接收缓冲区要按“数据长度字段”拆帧,否则半包和粘包会直接导致解析错位。我见过不少开发把所有希望寄托在Socket.Receive恰好一次收到完整包上,这种写法在现场网络负载高的时候就原形毕露了。
3. 核心模块拆解:报文从组包到解析的完整往返链路
3.1 访问路径与请求帧组包:帧头、命令码、设备代码的排序
SLMP二进制帧的组包顺序有严格规定,偏移偏差一个字节,PLC就直接回错误结束码。下面是一帧“批量读取D寄存器”的内存布局:
| 偏移 | 字节数 | 字段 | 示例值 |
|---|---|---|---|
| 0 | 2 | 子帧头 | 0x5000 |
| 2 | 1 | 网络号 | 0x00 |
| 3 | 1 | PC号 | 0xFF |
| 4 | 2 | IO编号 | 0x03FF |
| 6 | 1 | 站号 | 0x00 |
| 7 | 2 | 请求数据长度 | 0x000C |
| 9 | 2 | 监视定时器 | 0x2710 |
| 11 | 2 | 命令码 | 0x0401 |
| 13 | 2 | 子命令 | 0x0000 |
| 15 | 2 | 设备代码 | 0x00A8 |
| 17 | 4 | 首地址 | 100(D100) |
| 21 | 2 | 设备点数 | 8 |
“请求数据长度”字段这里特别容易出错,它指的是从命令码到帧尾的字节数,本例为12字节(命令2+子命令2+设备代码2+首地址4+点数2)。如果你顺手把监视定时器也算进去,PLC会认为帧格式不完整,直接丢弃请求。当年我第一次组帧就栽在这里,抓包看了半天没想明白为什么请求发出去了没有响应。
组帧代码写出来就是纯字节操作,没有任何框架依赖:
public byte[] BuildBatchReadFrame(ushort deviceCode, uint startAddress, ushort pointCount) { const ushort command = 0x0401; // 批量读取 const ushort subCommand = 0x0000; // 字单位 const ushort timer = 0x2710; // 看门狗 10000ms int dataLen = 2 + 2 + 2 + 4 + 2; // 命令+子命令+设备代码+首地址+点数 byte[] frame = new byte[11 + dataLen]; // 通信链路信息 Buffer.BlockCopy(new byte[] { 0x50, 0x00 }, 0, frame, 0, 2); frame[2] = 0x00; // 网络号 frame[3] = 0xFF; // PC号 Buffer.BlockCopy(new byte[] { 0x03, 0xFF }, 0, frame, 4, 2); frame[6] = 0x00; // 站号 // 请求数据长度与监视定时器 Buffer.BlockCopy(BitConverter.GetBytes((ushort)dataLen), 0, frame, 7, 2); Buffer.BlockCopy(BitConverter.GetBytes(timer), 0, frame, 9, 2); // 命令区 Buffer.BlockCopy(BitConverter.GetBytes(command), 0, frame, 11, 2); Buffer.BlockCopy(BitConverter.GetBytes(subCommand), 0, frame, 13, 2); // 数据区 Buffer.BlockCopy(BitConverter.GetBytes(deviceCode), 0, frame, 15, 2); Buffer.BlockCopy(BitConverter.GetBytes(startAddress), 0, frame, 17, 4); Buffer.BlockCopy(BitConverter.GetBytes(pointCount), 0, frame, 21, 2); return frame; }参数说明:deviceCode传0x00A8就是D寄存器,传0x0090就是M继电器(字单位),startAddress传PLC里的编号,比如D100直接传100,pointCount是一次读取的点数。BitConverter在小端序环境下输出的字节顺序正好符合SLMP要求,不用额外反转。
这里有一个Open协议(即>PLC侧允许外部连接)中“站点号”的非常隐蔽的细节,如果你从多个设备访问PLC时出了问题,会用到站号字段——上位机发帧时传0即可,多数单台连接场景不需要改。
3.2 响应帧解析:结束码判断与数据段切割
响应帧的前7字节结构和请求帧一致,只是子帧头变成了0xD000,紧接着是2字节的数据长度字段,然后2字节结束代码。结束代码为0x0000表示成功,非0就得查错误码表,比如0xFFFF通常是格式错误,0x100F是接收数据长度异常。
解析代码长这样:
public List<int> ParseBatchReadResponse(byte[] response) { if (response.Length < 11) throw new McProtocolException("响应帧不完整,可能收到了半包"); // 帧头7字节 + 数据长度2字节 + 结束代码2字节 int declaredLen = BitConverter.ToUInt16(response, 7); if (response.Length != 7 + declaredLen) throw new McProtocolException($"响应长度不符:声明{declaredLen},实际{response.Length - 7}"); ushort endCode = BitConverter.ToUInt16(response, 9); if (endCode != 0x0000) throw new McProtocolException($"PLC返回错误结束码 0x{endCode:X4}"); // 数据部分:每个寄存器占2字节 int dataByteCount = declaredLen - 2; // 去掉结束代码 List<int> values = new List<int>(); for (int i = 0; i < dataByteCount; i += 2) { values.Add(BitConverter.ToInt16(response, 11 + i)); } return values; }逻辑说明:先校验响应总长度是否等于“帧头7字节 + 声明长度”。为什么要做这个校验?因为TCP是流式传输,你一次性读到的数据可能只是半个包,也可能是两个包粘在一起。只有先通过长度字段把完整帧边界切出来,才能安全解析。
做自动化立体仓库项目时有个人人都会遇到的参数坑:如果D寄存器里存的是32位整数,需要连续读取2个字再做拼接,返回值会变成两个Int16,需要根据业务自行合并。点位表里应该提前标注每个字段的长度是16位还是32位,不要在代码里靠猜,这是我在现场调试时踩出来的教训。
3.3 批量写入与轮询刷新:把动作码写进寄存器的实用写法
写指令跟读指令的差异主要在命令码(0x1401)和帧尾多出的数据段。以写连续8个D寄存器为例,数据段顺序必须和地址顺序严格一致,顺序错了相当于把堆垛机的目标地址直接写偏。
public byte[] BuildBatchWriteFrame(ushort deviceCode, uint startAddress, short[] values) { const ushort command = 0x1401; const ushort timer = 0x2710; int dataLen = 2 + 2 + 2 + 4 + 2 + values.Length * 2; byte[] frame = new byte[11 + dataLen]; // 链路信息与监视定时器填充,逻辑同读取帧 Buffer.BlockCopy(new byte[] { 0x50, 0x00 }, 0, frame, 0, 2); frame[2] = 0x00; frame[3] = 0xFF; Buffer.BlockCopy(new byte[] { 0x03, 0xFF }, 0, frame, 4, 2); frame[6] = 0x00; Buffer.BlockCopy(BitConverter.GetBytes((ushort)dataLen), 0, frame, 7, 2); Buffer.BlockCopy(BitConverter.GetBytes(timer), 0, frame, 9, 2); // 命令区 Buffer.BlockCopy(BitConverter.GetBytes(command), 0, frame, 11, 2); Buffer.BlockCopy(BitConverter.GetBytes((ushort)0x0000), 0, frame, 13, 2); // 数据区 Buffer.BlockCopy(BitConverter.GetBytes(deviceCode), 0, frame, 15, 2); Buffer.BlockCopy(BitConverter.GetBytes(startAddress), 0, frame, 17, 4); Buffer.BlockCopy(BitConverter.GetBytes((ushort)values.Length), 0, frame, 21, 2); // 待写数据 int dataOffset = 23; foreach (short v in values) { Buffer.BlockCopy(BitConverter.GetBytes(v), 0, frame, dataOffset, 2); dataOffset += 2; } return frame; }参数说明:values是short数组,一个元素对应一个寄存器。写入16位整数时直接用short,写入32位数据时要拆成两个short拼进去。这里要特别提醒:values.Length必须赋值给帧里的设备点数,很多人在点位表里写了8个点,实际传了个长度为4的数组,PLC会继续读后面内存里的值,造成相邻地址被篡改。
轮询刷新这块,项目里有的版本用Timer控件,有的用async/await循环。我建议用任务循环,因为Timer在UI线程里塞阻塞操作会让界面卡死。常见做法是启动一个独立线程,循环里调用通信层读取,再用Invoke回到UI线程刷新控件,间隔按现场需要调,一般的仓储项目200~500ms轮询足够。
4. 运行、联调与常见问题排查:从编译到实机跑通
4.1 环境准备与三步跑通流程
先确认开发环境能编译这份工程:VS2019或VS2022,装的.NET Framework版本与项目一致,常见是4.7.2或4.8。如果编译报缺少组件,去“Visual Studio Installer”里补装“基于.NET Framework 4.7.2的开发工具”即可。
真正接硬件前的准备工作,按顺序做:
- 在GX Works3里配置FX5U的以太网参数,把IP地址固定下来,比如192.168.1.60(不要跟现场总线上的其他设备冲突)。
- 确认FX5U的内置以太网端口已启用SLMP通信,并设置好开放协议端口,项目默认为2000。
- 在FormMain窗口填入IP和端口,启动程序,先点连接再点读取。
如果读取有数据返回,说明Socket链路和SLMP协议层都已经通了。如果点“连接”就报错,优先检查IP是否可达,用CMD ping一下,然后确认GX Works3里是否启用了SLMP监听。ping得通但连不上,问题基本在端口号或PLC侧的连接许可设置。
连接成功后建议写个Windows服务或开机自启项把它拉起来,自动化立体仓库现场的电控柜很少有人去手动开上位机程序,这一点常被忽略。
4.2 用Wireshark核验请求帧和响应帧
联调阶段我觉得最值得养成的习惯就是抓包。打开Wireshark,过滤器填tcp.port == 2000,然后把上位机的“批量读取”按钮点一下,找到对应TCP流,右键“追踪流”,切到十六进制显示。
你会在数据里看到两段关键字节:上位机发出的请求帧开头应为“50 00”,PLC回应的帧开头应为“D0 00”。如果请求帧正常但响应帧一直带非零结束码,问题多半在命令码或设备代码写错;如果压根没看到请求帧,要检查发送逻辑是不是被异常吞掉了。
特别说一下Wireshark的显示习惯:TCP流里可能混杂之前的握手指纹,你找最后一个请求-响应过程时,先在“追踪流”窗口里从末尾往前翻,找到最近一次读操作的完整来回。很多新人被开头的SYN、ACK混淆,以为报文没发出去,其实就是看错了位置。抓包能快速分离“程序逻辑错”和“通信格式错”,在排除故障的时候可以先把网络问题框死。
4.3 踩坑记录:FX5U以太网通信最常见的五个问题
现象一:上位机显示连接成功,但没过几秒就断开,再连就失败。
原因:FX5U在PLC侧对同时连接数有限制,上一次Socket没有正常释放,PLC侧的会话保超时后会把新连接也踢掉。
解决:断线重连前先Close再Dispose旧Socket,并加一个间隔5秒的心跳读操作,最简单就是读M0一个点。心跳不只是保活,还能提前发现PLC断电重启。
现象二:读取D100返回的数据全是0,但报文结束代码是0。
原因:结束代码为0代表指令执行成功,数据段本身就有值,问题大概率出在设备代码用错,或者你把别的机型地址偏移规则套到了FX5U上,比如把D100换算成了四百多号寄存器再发出去。
解决:在二进制3E帧里,D100就传十进制100,不要加任何偏移。唯一要额外注意的是X、Y输入输出端子,它们的地址是八进制,X7后面是X10而不是X8,这在三菱PLC里是常识,但上位机开发者容易忽略。
现象三:批量写入后,相邻地址的值被改动,看起来像“串位”。
原因:values数组长度和帧里声明的设备点数不一致,或数据段顺序与地址顺序不匹配。PLC按声明点数读数据段,多读的部分会被当成下一地址的值。
解决:发送前增加断言,values.Length必须等于组帧时传来的点数。点位表维护时也禁止一个字段横跨两段不同长度的写入。
现象四:读取连续大区段时频繁超时。
原因:FX5U对单次批量读取点数有上限,一次读太多会超出监视定时器的等待时间。我在现场看别人读两千个D点,连续读了几批,就有几批报超时。
解决:把单次读取控制在256点以内,分批次读,循环拼接结果。这个分片长度也建议做到配置里,不同机型上限不一样,配置化以后换PLC不用改代码。
现象五:收到的响应帧是半个包,解析抛异常或读到乱值。
原因:TCP是字节流,不保证一次Recv正好收到一个完整帧,常见表现是响应太长被拆成两次发送,或者请求太频繁两个响应粘在一起。
解决:在通信层维护一个接收缓冲区,收到数据先追加,然后循环寻找长度字段并截出完整帧。项目里通常把这块逻辑写进PLCConnection的ReceiveAsync回调,值得仔细阅读。
5. 二次开发思路:把单点通信封装成可配置的PLC驱动
5.1 点位表配置化:把地址搬离代码
一个自动化立体仓库的堆垛机可能就有上百个点位,把地址硬编码进窗体,后期维护会非常痛苦。
改造思路很直接:定义PointConfig实体,字段包括点位名称、设备类型、起始地址、数据长度、读写类型。然后把点位放进XML或JSON,程序启动时加载,运行时修改配置还能调“重新加载”按钮热更新。
public class PointConfig { public string Name { get; set; } public string Device { get; set; } // M 或 D public ushort StartAddress { get; set; } public ushort Length { get; set; } public bool IsWrite { get; set; } }读取时根据Device字段映射设备码:D对应0x00A8,M对应0x0090。新增点位只需要在配置里追加一行,完全不用重新编译程序。我把这个封装叫“兴趣点表”,驱动遍历表项批量构建读写帧,一次轮询就能把所有区域刷新一遍。
5.2 适配Q系列与其他三菱机型的接口抽象
如果你想把这套代码用在Q系列PLC上,好消息是3E帧结构基本一致,命令码、子命令、设备代码都通用。主要区别在帧头的IO编号和站号,以及不同机型的网络参数——把这两项从常量改成参数即可。
写接口时建议抽象两个方法:ReadWords(deviceCode, address, length)和WriteWords(deviceCode, address, values),上层业务只依赖接口,不关心底层操作的是FX5U还是Q系列。这样你后续插一块板卡或者换PLC品牌,都只需新增一个实现,不用动WMS逻辑。
还有一个验证技巧:在没有真机时,跑一个SLMP模拟器,监听2000端口,收到请求后按协议返回固定数据。先用模拟器把程序跑通,到现场再对接真机,能省下很多“到了客户现场才冒烟”的尴尬。验证时重点对比模拟器返回的请求帧和抓包数据,确认长度字段、设备代码、点位数量完全一致,然后逐步增加并发和持续运行时间,检查连接稳定性。
从那以后我每次接手三菱这种PLC通信项目,都强制先画一遍协议字段图和点位表,再动手写代码,这成了雷打不动的习惯。希望这套实践思路能帮你在FX5U的通信改造里少走些弯路,直接上手这份源码工程。
本文还有配套的精品资源,点击获取