简介:这份资源面向具备一定C#基础、希望打通上位机与PLC通信的工控开发者,聚焦三菱FX5U通过以太网进行数据交互这一典型场景,尤其贴合自动化立体仓库中库存管理、货位查询与物料搬运等业务需求。压缩包共35个文件、约79KB,以cs源码为主体,辅以csproj、sln解决方案文件、config配置、resx与resources资源文件及exe可执行程序,构成一套可直接打开运行的WinForm工程。内容围绕3E帧报文结构展开,涵盖连接建立、报文构造与字节序转换、基于Socket的数据收发、PLC私有协议指令解析,以及超时重发与错误恢复等稳定性处理,界面部分则演示通信状态与读写结果的展示方式。已有468人学习,适合作为理解FX5U以太网通信流程、搭建上位机原型的参考样例。
1. 从一条报文说起:为什么这套上位机方案值得照着做
做自动化立体仓库上位机,最容易被低估的不是界面,而是通信。设备侧是一台 FX5U 系列 PLC,上位机跑在工控机上,中间隔着交换机,走的是以太网。现场调试时最常出现的画面是:WinForm 界面点下「启动」,堆垛机不动,日志里只有一行超时。翻手册、抓报文、对地址,一折腾就是半天。这套方案要解决的正是这件事——把 C# 上位机与 FX5U 之间的以太网通信做成可复用、可排查、可交付的模块,再套上立体仓库的入库、出库、库位管理业务。适合正在做自动化立体仓库毕设、课程设计,或者要给小型仓库写一套上位机程序的人。下面按需求、设计、实现、避坑、验收的顺序拆开讲,代码和报文格式都能直接拿去改。
2. 需求与选型:立体仓库上位机到底要接哪些活
2.1 先把业务需求翻译成通信需求
立体仓库上位机的需求,表面看是「能控制堆垛机、能看库位」,落到工程上其实是三张清单。第一张是设备清单:堆垛机水平行走、升降、货叉伸缩,每个动作对应 PLC 里的一个输出点或一个状态位;输送线入口、出口的光电开关对应输入点;再加上急停、限位、就绪这些安全信号。第二张是数据清单:库位坐标、托盘条码、任务号、任务状态,这些要么存在上位机数据库里,要么和 PLC 的 D 寄存器做交换。第三张是时序清单:上位机下发任务后,PLC 什么时候回「已接收」、什么时候回「执行中」、什么时候回「完成」,中间任何一步丢了,界面就会卡在「执行中」不动。
把这三张清单列出来,通信需求就清楚了:需要周期性地读一批位信号和字信号,需要在下发任务时写一批寄存器和线圈,还需要一个能区分「写成功」和「PLC 已执行」的确认机制。很多同学一上来就写界面,写到一半发现不知道读哪个地址,回头补清单,返工量很大。我一般会先画一张地址映射表,左边是业务含义,右边是 PLC 软元件地址和数据类型,这张表后面既是代码依据,也是答辩时最能说明问题的东西。
2.2 为什么选以太网而不是串口
FX5U 自带以太网口,这是选型上最省事的一点。串口方案(比如走 RS-485 加 Modbus RTU)在小型设备上够用,但立体仓库的通信量不小:几十个库位状态、多个轴的当前位置、任务队列,轮询一遍的数据量放在 9600 波特率下会明显拖慢刷新。以太网走 TCP,百兆链路下这点数据几乎无感,而且工控机通常本来就有网口,不需要额外加转换器。
协议层面有两条路。一条是用 PLC 厂商提供的通信组件或协议库,配置简单但绑定性强;另一条是走标准的 Modbus TCP,FX5U 可以通过内置以太网口支持,上位机侧用开源库就能实现,移植性好。这套方案选的是后者,原因是毕设和课设环境里往往拿不到完整的厂商授权组件,而 Modbus TCP 的报文格式公开、抓包工具能直接看,出问题时排查路径最短。代价是要自己处理寄存器地址映射和字节序,这部分后面会专门讲。
2.3 技术栈与工程结构定下来
上位机用 C# WinForm,理由很实际:工控现场的操作员要的是大按钮、大字体、一眼能看懂的库位图,WinForm 拖控件最快,部署也简单,一个 exe 加几个 dll 拷过去就能跑。通信层用 Modbus TCP 客户端库,数据访问用轻量 ORM 或直接 ADO.NET,数据库选本地文件型数据库即可,立体仓库单机部署的场景不需要上服务端数据库。
工程结构按职责分四层:界面层放 WinForm 窗体,只负责展示和收集操作;业务层放任务调度、库位分配、状态机;通信层封装连接、读写、重连、日志;数据层管库位表和任务表。分层的意义在于,通信层可以单独写一个测试窗体跑通,不用等界面做完。我见过不少项目把通信代码直接写在按钮事件里,最后想加个心跳都无处下手,这是典型的血泪经验。
3. 总体设计:四层结构与地址映射的职责划分
3.1 分层架构与数据流
整体数据流是单向加回环:操作员在界面发起入库任务,业务层生成任务记录并分配库位,通信层把任务号、目标列、目标层、目标位写入 PLC 对应寄存器,PLC 执行后把状态位和当前位置回写到另一批寄存器,通信层周期读取,业务层据此更新任务状态和库位占用,界面刷新。这条链路里,通信层是唯一碰网络的地方,业务层不关心 TCP 还是串口,界面层不关心寄存器地址。
分层之后有个好处:通信层可以做成一个独立的类库,接口只有连接、断开、读位、写位、读字、写字、批量读写几个方法。业务层调用时传的是业务对象,比如「任务下发」,由通信层内部翻译成寄存器写入序列。这样以后换 PLC 型号,只改通信层的地址映射,业务代码不动。做毕设时这一点在答辩里很加分,因为它说明你想过可维护性,而不是堆功能。
3.2 地址映射表怎么设计
地址映射是这套方案的核心资产。做法是建一张配置表,字段包括业务键、软元件类型(M 线圈、D 寄存器、X 输入、Y 输出)、起始地址、数据类型(bool、short、int)、读写方向、缩放系数、备注。程序启动时加载这张表,通信层按表拼报文,业务层按业务键取值。表可以存在数据库里,也可以存成 XML 或 JSON,现场调试时改配置不用重新编译。
| 业务键 | 软元件 | 地址 | 类型 | 方向 | 说明 |
|---|---|---|---|---|---|
| TaskNo | D | 100 | int | 写 | 任务号,0 表示无任务 |
| TargetCol | D | 102 | short | 写 | 目标列号 |
| TargetLayer | D | 103 | short | 写 | 目标层号 |
| TaskCmd | M | 200 | bool | 写 | 任务下发触发位 |
| TaskAck | M | 300 | bool | 读 | PLC 已接收 |
| TaskDone | M | 301 | bool | 读 | 任务完成 |
| ForkPos | D | 200 | short | 读 | 货叉当前位置 |
| FaultCode | D | 210 | short | 读 | 故障码 |
这张表看着朴素,但它把「业务说什么」和「PLC 认什么」彻底分开。现场改地址只动表,不动代码;答辩时把表一放,评委立刻能看懂你和 PLC 的约定。注意地址要按 PLC 侧实际分配填,别照抄示例,不同项目的 IO 分配差别很大。
3.3 通信时序与状态机
任务下发不能只写一个触发位就完事,要有握手。通常的做法是:上位机写任务参数,置位 TaskCmd;PLC 检测到 TaskCmd 上升沿后读取参数,置位 TaskAck;上位机读到 TaskAck 后复位 TaskCmd;PLC 检测到 TaskCmd 复位后复位 TaskAck,开始执行;执行完成置位 TaskDone;上位机读到 TaskDone 后记录完成,复位 TaskDone 或由 PLC 自行复位。这个四步握手能避免参数还没写完就被 PLC 读走,也能避免上位机重复下发。
状态机放在业务层,任务状态至少要有「待下发、已下发、执行中、完成、异常」五个。每次轮询读到 TaskAck 和 TaskDone 的变化,驱动状态迁移。异常分支要覆盖:下发后超时没收到 TaskAck、执行中超时没收到 TaskDone、读到 FaultCode 非零。超时时间按设备动作节拍定,堆垛机走一个库位通常十几秒,超时给到动作时间的 1.5 倍比较稳妥。
4. 通信层实现:报文、读写与重连的落地代码
4.1 Modbus TCP 报文结构与地址换算
Modbus TCP 的报文由 MBAP 头和 PDU 组成。MBAP 头 7 个字节:事务标识 2 字节、协议标识 2 字节(固定 0)、长度 2 字节、单元标识 1 字节。PDU 里功能码 1 字节,后面跟数据。读保持寄存器用功能码 03,写单个寄存器用 06,写多个寄存器用 16,读写线圈用 01、05、15。FX5U 侧要先把软元件映射到 Modbus 地址,D 寄存器通常对应保持寄存器区,M 线圈对应线圈区,具体偏移以设备侧设置为准。
地址换算是最容易翻车的地方。Modbus 报文里的地址是从 0 开始还是从 1 开始,不同库和不同设备理解不一致,差一位就全错。我的做法是配置表里统一填「协议地址」,也就是报文里实际出现的那个数,然后在注释里写清它对应 PLC 的哪个软元件。调试时先用单个寄存器读写验证,确认无误再批量铺开。
// Modbus TCP 读保持寄存器请求报文组装(功能码 03) // transactionId 事务号,用于匹配响应;unitId 单元标识,FX5U 侧按设置填 public static byte[] BuildReadHolding(int transactionId, int unitId, int startAddr, int quantity) { var frame = new byte[12]; frame[0] = (byte)(transactionId >> 8); // 事务标识高字节 frame[1] = (byte)(transactionId & 0xFF); // 事务标识低字节 frame[2] = 0; frame[3] = 0; // 协议标识固定为 0 frame[4] = 0; frame[5] = 6; // 后续字节数:单元标识 + PDU frame[6] = (byte)unitId; // 单元标识 frame[7] = 0x03; // 功能码:读保持寄存器 frame[8] = (byte)(startAddr >> 8); // 起始地址高字节 frame[9] = (byte)(startAddr & 0xFF); // 起始地址低字节 frame[10] = (byte)(quantity >> 8); // 寄存器数量高字节 frame[11] = (byte)(quantity & 0xFF); // 寄存器数量低字节 return frame; }这段代码只做报文组装,实际项目里更推荐直接用成熟的 Modbus 库,把精力放在地址映射和状态机上。但理解报文结构有实际价值:抓包时能一眼看出是地址错了还是数量错了,响应里的异常码(比如 02 表示非法数据地址)也能直接定位问题。参数上,quantity 一次别读太多,超过 125 个寄存器很多设备会拒绝,按业务分组读,比如库位状态一组、轴位置一组。
4.2 批量读写与轮询节奏
轮询不能一个信号一个请求,那样网络往返次数太多,界面刷新会明显发卡。做法是按地址连续性分组,一次读一段。比如库位状态占 D300 到 D399,就一次读 100 个寄存器,回来再按位拆。写的时候同理,任务参数几个寄存器连续,用功能码 16 一次写完,再单独置触发位。
轮询周期要分档:安全信号和任务握手位用短周期,200 到 500 毫秒;库位状态和轴位置用长周期,1 到 2 秒。全部用一个周期会导致要么太慢要么网络太忙。实现上用一个定时器驱动一个读任务队列,队列里每个任务带自己的周期和上次执行时间,到点才发。这样加信号只加队列项,不改主循环。
// 轮询任务定义:按周期调度,避免所有信号同一频率刷 public class PollItem { public string Key; // 业务键,对应地址映射表 public int IntervalMs; // 轮询周期,毫秒 public DateTime LastRun; // 上次执行时间 public Action OnData; // 读到数据后的回调 } // 主循环里按到期时间筛选,只发到期的请求 var due = pollItems.Where(p => (DateTime.Now - p.LastRun).TotalMilliseconds >= p.IntervalMs); foreach (var item in due) { var values = modbus.ReadHolding(item.StartAddr, item.Quantity); // 一次读一段 item.OnData(values); // 交给业务层解析 item.LastRun = DateTime.Now; }这里的关键是「一次读一段」和「按需调度」。回调里做解析和状态更新,不要在里面做耗时操作,否则会拖慢整个轮询。解析时注意字节序,Modbus 寄存器是大端,C# 里拼 int 要手动移位,别直接 BitConverter 了事,x86 机器上会反。
4.3 断线重连与通信日志
现场网络不会一直稳,交换机重启、网线松动、PLC 断电都会断连。通信层必须自己处理重连,不能让界面卡死。做法是连接状态用一个枚举维护,读写出异常时置为断开,后台一个重连线程按退避间隔尝试,连上后重新初始化地址映射并恢复轮询。退避间隔从 1 秒开始翻倍,到 10 秒封顶,避免疯狂重连把日志刷爆。
日志是排查的黑匣子。每条收发报文记时间、方向、功能码、地址、数量、结果,异常时记异常码和原始字节。日志按天切文件,界面上留一个「通信日志」页签实时显示最近若干条。现场出问题时,先看日志里最后一条成功报文和第一条失败报文,基本能判断是地址问题、超时还是设备拒绝。我一般还会在日志里记下每次状态机迁移,任务卡住时能看出是没收到确认还是业务层没处理。
提示:日志里不要只记「通信失败」,要记到功能码和地址。只写失败两个字的日志,排查时等于没有。
5. 避坑与排查:现场最容易翻车的五个点
5.1 地址差一位,读写全错
现象是读回来的值全是 0 或者明显不对,写下去 PLC 没反应。原因多半是 Modbus 地址基准不一致,配置表里填的是 PLC 软元件号,报文里却按协议地址发,中间差了个偏移。解决方法是先用一个已知寄存器做单点验证:在 PLC 侧给 D100 赋一个特征值,上位机分别按 99、100、101 去读,哪个读对就以哪个为基准,然后把整张表统一换算。换算规则写进配置表注释,别靠记忆。
5.2 字节序和字序搞反
现象是读一个 32 位整数,高低字颠倒,数值大得离谱。原因是 Modbus 按寄存器传输,一个 32 位数占两个寄存器,先发高字还是低字、寄存器内字节怎么排,设备之间可能不同。解决方法是拿一个已知值(比如 0x00010002)写下去再读回来,对比字节顺序,确定字序后在解析函数里固定处理。别在每个业务点各写一套解析,统一放通信层的转换工具里。
5.3 握手位没做边沿检测
现象是任务重复下发,或者 PLC 收到一次触发执行了两次。原因是上位机把触发位一直保持为 1,PLC 每个扫描周期都检测到有效。解决方法是握手严格按上升沿和复位走:置位后等确认,确认到了立刻复位,PLC 侧也按边沿触发。上位机侧再加一个「任务已下发」标志,同一任务号不允许重复下发。这个坑在联调时特别隐蔽,因为单次操作看起来正常,连续操作才暴露。
5.4 轮询把界面拖卡
现象是界面点按钮延迟明显,库位图刷新一顿一顿。原因是通信和界面在同一个线程,或者轮询请求太多太密。解决方法是通信全部放后台线程,读到数据后通过 Invoke 回主线程更新界面,且更新做节流,比如 200 毫秒合并一次刷新。轮询按第 4 章的分档调度,别所有信号一个频率。界面刷新只更新变化的控件,别整表重绘。
5.5 异常分支没写,任务永远停在执行中
现象是任务状态卡在「执行中」,操作员只能重启程序。原因是只处理了成功路径,超时和故障码没接。解决方法是状态机每个状态都配超时定时器,超时后置异常并允许人工复位;轮询里持续检查 FaultCode,非零就中断当前任务并弹提示。异常处理不是加个 try-catch 就完事,要有明确的状态出口,否则现场一出问题就只能重启,这在验收时是硬伤。
6. 验收与答辩:怎么证明这套上位机真的能用
验收分三步走,每一步都有可观察的证据。第一步是通信单点验收:用测试窗体对每个业务键做一次读或写,记录实际值和 PLC 侧监视值是否一致,形成一张对照表。这一步证明地址映射没错。第二步是业务流程验收:完整跑一遍入库和出库,从任务下发到完成,记录每个状态的时间戳,检查握手四步是否都出现、超时是否在设定范围内。第三步是异常验收:拔网线看是否自动重连、给一个非法地址看是否报异常码、模拟 PLC 不回确认看是否走超时分支。三步都过,这套程序才算能交付。
答辩时最有效的不是讲功能列表,而是讲清楚三个问题:你和 PLC 的约定是什么(地址映射表)、任务怎么保证不丢不重(握手时序和状态机)、出问题怎么定位(通信日志和异常分支)。把这三样做成一张图或一页表,比念二十页 PPT 管用。评委常问的「如果通信断了怎么办」「如果 PLC 没响应怎么办」,答案都在避坑那一章里,提前准备好具体现象和解决动作,别泛泛说「做了异常处理」。
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 地址映射 | 逐键单点读写 | 上位机值与 PLC 监视值一致 |
| 任务握手 | 完整下发一次任务 | 四步握手时间戳齐全,无重复下发 |
| 断线重连 | 断开网线 10 秒再恢复 | 自动重连,轮询恢复,界面不卡死 |
| 超时分支 | 模拟 PLC 不回确认 | 状态转异常,可人工复位 |
| 故障码 | 写入非零 FaultCode | 任务中断并提示,日志有记录 |
从那以后我每次做上位机项目,都强制先把地址映射表和握手时序写成文档再动代码,联调时按验收表逐项打勾,不靠「感觉能跑」。这套习惯让我少熬了很多夜。希望帮到你。
本文还有配套的精品资源,点击获取