
简介面向工业自动化、上位机开发与PLC通信学习者这份压缩包以三菱FX5U的以太网通信为主题提供一套基于C# WinForm的通信示例围绕3E帧报文完成网络连接、指令下发、响应解析与状态展示并对应自动化立体仓库中的库存查询、货位管理与物料搬运等应用场景。包内共35个文件主要包含cs源码、config配置、exe程序、resources与resx界面资源以及sln、csproj等工程组织文件另附少量缓存和调试文件压缩包约79KB规模轻量但工程要素较完整。已有468人学习下载可作为理解PLC私有协议、报文封装、字节序转换、超时重试和WinForm事件交互的参考样例便于读者顺着解决方案结构定位通信层、界面层与业务逻辑之间的调用关系并借鉴其中的错误处理与调试思路。1. 从仓库任务下发说起FX5U 以太网通信不是拖个 Socket 那么简单自动化立体仓库的上位机最怕一种现场界面点下“下发任务”堆垛机不动PLC 只回一个非零结束码日志里只有一串十六进制。这个资源围绕 FX5U 系列 PLC 的以太网通信给了一套 C# WinForm 上位机程序骨架重点在 MC 协议报文拼装、D/M/X/Y 软元件读写、连接状态维护以及仓库任务字段落到 D 区的映射方式。它适合已经会写 WinForm、但一碰 PLC 报文就卡住的工程师也适合拿它对照自己通信层边界的老手想要完整 WMS/WCS 的人会失望它更像通信骨架不是业务大而全的成品。2. 拆开通信层MC 协议、软元件地址与一帧请求报文2.1 先分清 TCP/UDP、二进制/ASCII 三条选择线FX5U 的以太网口能跑多种通信方式落到 C# 上位机先定三条线TCP 还是 UDP、二进制还是 ASCII、用哪一帧格式。我的默认组合是 TCP 二进制 4E 帧原因是仓库上位机通常常驻运行任务报文不长二进制解析快TCP 又能省掉业务层自己做重发的一部分麻烦。ASCII 不是不能用调试期看字符确实直观但报文会变长空格、大小写、结束符都容易埋坑。组合适合场景代价TCP 二进制 4E 帧立体仓库上位机常驻、读写频繁抓包不直观要对照十六进制表TCP ASCII联调初期、单点验证报文长拼装容易多空格或漏结束符UDP 二进制高频小数据、可接受丢包业务层要做序号、超时和重发单机直连时站号常见填0x00网络号0x00目标模块 I/O 号常见0xFF 0x03但这些值必须跟 PLC 侧以太网参数对齐。现场最怕“代码里写死一套PLC 里开的是另一套”ping 得通不代表 MC 端口通更不代表站号对。2.2 FX5U 软元件地址怎么映射成 C# 枚举C# 里不要把D100这种字符串直接丢进 Socket 发送。先把软元件拆成“设备代码 起始地址 点数”三段设备代码按协议表映射起始地址按十进制或八进制换算点数按字单位或位单位决定。D 区是字单位M/X/Y 是位单位混用时命令里的子命令也要跟着变。软元件二进制设备代码单位注意D0xA8字最常用任务参数、状态字都放这里M0x90位触发、握手、报警复位常用X0x9C位FX 系列 X/Y 常按八进制编号别把 X10 当十进制 10Y0x9D位输出点写之前确认 PLC 程序是否也在写W0xB4字链接寄存器按项目实际使用// 软元件代码只取常用几类现场先够用后续按协议表补 private static byte ToDeviceCode(string device) { switch (device.Trim().ToUpperInvariant()) { case D: return 0xA8; // 数据寄存器字单位 case M: return 0x90; // 内部继电器位单位 case X: return 0x9C; // 输入注意 FX 系列按八进制编号 case Y: return 0x9D; // 输出 case W: return 0xB4; // 链接寄存器 default: throw new NotSupportedException($未支持的软元件: {device}); } }这段代码只做一件事把界面上的软元件名转成协议里的一个字节。真正的坑在headDevice例如 D100 的起始地址是十进制的 100写成三字节小端就是0x64 0x00 0x00X10 则不能直接按十进制 10 算FX 系列 X/Y 是八进制编号现场要先确认 PLC 侧显示的是 X10 还是 X8。这个边界不确认读回来的点位会整体错位。2.3 一帧请求报文的最小组成4E 二进制批量读的一帧请求结构不复杂但每一段都有固定长度。子头0x50 0x00后面跟网络号、PC 号、目标模块 I/O 号、站号、请求数据长度、监视定时器、命令、子命令、软元件代码、起始地址、点数。最容易错的是“请求数据长度”不是整帧长度而是从监视定时器开始到帧尾的字节数。段长度示例说明子头250 004E 二进制请求网络号100单网络常见 0PC 号1FF上位机访问 PLC 常见 FF目标模块 I/O 号2FF 03以 PLC 参数为准目标站号100单机直连常见 0请求数据长度2小端从监视定时器到帧尾监视定时器210 00常见保守值按 PLC 侧约定填命令201 04批量读子命令200 00字单位软元件代码1A8D 区起始地址3小端D100 为64 00 00点数2小端读取字数public static byte[] BuildReadWords( byte stationNo, byte deviceCode, int headDevice, ushort points, ushort monitorTimer 0x0010) { var body new Listbyte(16); body.AddRange(BitConverter.GetBytes(monitorTimer)); // 监视定时器 body.AddRange(new byte[] { 0x01, 0x04 }); // 命令 0401批量读 body.AddRange(new byte[] { 0x00, 0x00 }); // 子命令 0000字单位 body.Add(deviceCode); // 软元件代码 body.Add((byte)(headDevice 0xFF)); // 起始地址低字节 body.Add((byte)((headDevice 8) 0xFF)); body.Add((byte)((headDevice 16) 0xFF)); body.AddRange(BitConverter.GetBytes(points)); // 读取点数小端 var frame new Listbyte(11 body.Count); frame.AddRange(new byte[] { 0x50, 0x00 }); // 4E 二进制请求子头 frame.Add(0x00); // 网络号 frame.Add(0xFF); // PC 号 frame.AddRange(new byte[] { 0xFF, 0x03 }); // 目标模块 I/O 号 frame.Add(stationNo); // 目标站号 frame.AddRange(BitConverter.GetBytes((ushort)body.Count)); // 请求数据长度 frame.AddRange(body); return frame.ToArray(); }这段代码里stationNo是目标站号deviceCode来自上一节的映射headDevice是起始软元件编号points是一次读取的字数monitorTimer是 PLC 等待超时参数。请求数据长度用body.Count计算不是frame.Count这一点写错时PLC 往往直接回错误码或者干脆不响应。响应解析时结束码在偏移 9 到 10 字节数据从偏移 11 开始别把子头长度算进去。提示先用 D0 读 1 个字做最小闭环再用 D100 读 10 个字验证地址和长度。直接上几十个字的批量读出错后很难判断是地址错、长度错还是解析起点错。3. 在 WinForm 里搭通信闭环连接、读写、重连与任务对齐3.1 通信类接口设计不要把 Socket 塞进按钮事件WinForm 按钮事件里直接new TcpClient()的写法联调期能跑现场跑两天就出问题UI 卡死、重连重复、Socket 没释放、异常吞掉。我的做法是先定一个通信接口把连接、读字、写字、读位、写位收口窗体只调用接口不碰NetworkStream。public interface IPlcClient : IDisposable { bool IsConnected { get; } Task ConnectAsync(CancellationToken ct default); Taskushort[] ReadWordsAsync(string device, int headDevice, ushort points, CancellationToken ct default); Task WriteWordsAsync(string device, int headDevice, ushort[] values, CancellationToken ct default); Task WriteBitsAsync(string device, int headDevice, bool[] values, CancellationToken ct default); event EventHandlerbool ConnectionChanged; }接口里的device是D、M这类软元件名headDevice是起始编号points是读取长度values是写入数据。事件只通知连接状态变化不把异常对象直接抛给 UI 线程。这样做的价值是通信层可以单独做模拟器测试WinForm 只负责把任务字段填进去把状态字读出来显示。3.2 连接与心跳用定时器和状态机收口连接不是Connect一次就完事。现场交换机重启、PLC 断电、网线松动都会让TcpClient.Connected变成不可信状态。我一般会开一个后台循环没连上就重连连上后按固定周期读一个专用心跳字读失败就释放连接退避几秒再重连。心跳字要跟 PLC 侧约定别拿业务正在写的 D 区地址凑数。private async Task MaintainConnectionAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { if (_tcp is not { Connected: true }) { _tcp?.Dispose(); _tcp new TcpClient(); using var timeout CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(3)); await _tcp.ConnectAsync(_ip, _port, timeout.Token); _stream _tcp.GetStream(); ConnectionChanged?.Invoke(this, true); } // 心跳用专用 D 字别拿业务正在写的地址凑数 await ReadWordsAsync(D, 0, 1, ct); await Task.Delay(1000, ct); } catch (Exception ex) { ConnectionChanged?.Invoke(this, false); _stream?.Dispose(); _tcp?.Dispose(); _tcp null; _lastError ex.Message; await Task.Delay(3000, ct); // 退避别死循环重连 } } }这里的参数不是随便填连接超时 3 秒避免 UI 长时间等心跳周期 1 秒既能及时发现断线又不会把 PLC 通信口打满重连退避 3 秒防止网络刚断时疯狂重连。_lastError只用于日志和状态栏不要在后台线程直接弹 MessageBox。还有一点TcpClient.Connected只能当参考真正判断靠心跳读写是否成功。3.3 读 D 区与写 M 区的完整调用示例通信类封装好之后窗体里的调用应该很短。下面这段代码先读 D100 的 10 个字再写 D200 的 3 个字最后写 M100 到 M102 三个位。IP 和端口是示例现场按 PLC 参数替换。var plc new McTcpClient(192.168.0.10, 5000, stationNo: 0x00); await plc.ConnectAsync(); ushort[] d100 await plc.ReadWordsAsync(D, 100, 10); // 读 D100-D109 await plc.WriteWordsAsync(D, 200, new ushort[] { 1, 2, 3 }); // 写 D200-D202 await plc.WriteBitsAsync(M, 100, new[] { true, false, true }); // 写 M100-M102ReadWordsAsync(D, 100, 10)里的 100 是起始地址10 是字数返回数组长度应为 10WriteWordsAsync的数组长度决定写入字数不要传空数组WriteBitsAsync的每个bool对应一个位点协议里每个位点通常占 1 字节写true是0x01写false是0x00。如果 M100 在 PLC 里是上升沿触发连续写true再写false可能被 PLC 扫描周期错过稳妥做法是置位后等 PLC 回握手再复位。3.4 用仓库任务报文验证读写是否对齐自动化立体仓库的任务报文最怕字段顺序和 PLC 侧不一致。我的做法是固定一张 D 区映射表先跟 PLC 侧对表再用 C# 的 DTO 生成字数组。任务号、起点站、终点站、命令码、托盘码、校验字按顺序排写入后用触发位通知 PLCPLC 在另一段 D 区回确认字。D 地址字段类型说明D500任务号ushort不能为 0新任务递增D501起点站ushort按仓库站台编号D502终点站ushort按仓库站台编号D503命令码ushort入库、出库、移库等D504-D507托盘码ushort[4]8 位 ASCII两个字符挤一个字D508校验字ushort前面字段 XORD600确认字ushort1 接收0xFFFF 拒绝D601状态字ushort0 空闲1 运行9 故障public sealed class WarehouseTask { public ushort TaskNo { get; init; } public ushort FromStation { get; init; } public ushort ToStation { get; init; } public ushort Command { get; init; } public string PalletCode { get; init; } 00000000; public ushort[] ToWords() { if (TaskNo 0) throw new ArgumentException(任务号不能为 0); if (PalletCode.Length ! 8) throw new ArgumentException(托盘码固定 8 位 ASCII); var words new ushort[9]; words[0] TaskNo; words[1] FromStation; words[2] ToStation; words[3] Command; for (int i 0; i 4; i) { // 两个 ASCII 字符挤进一个字高低字节顺序跟 PLC 侧对齐 words[4 i] (ushort)((PalletCode[i * 2] 8) | PalletCode[i * 2 1]); } words[8] (ushort)(words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4] ^ words[5] ^ words[6] ^ words[7]); return words; } }ToWords()返回 9 个字对应 D500 到 D508。任务号用来防重复起点站和终点站用数字编码托盘码固定 8 位校验字用 XOR 做基本防错不是安全校验。写入时调用WriteWordsAsync(D, 500, task.ToWords())再写 M100 触发PLC 回 D600 等于 1 后复位 M100。托盘码高低字节顺序要跟 PLC 侧确认有的项目要求低字节在前照抄代码就会得到反码。4. 报文对不上时先查这五处字节序、地址基数、站号、长度与粘包4.1 现象读 D100 返回全 0写进去再读还是旧值现象很典型PLC 监控里 D100 明明有数C# 读回来全是 0往 D200 写 123读回还是旧值。原因通常不是 Socket 不通而是起始地址基数或软元件代码错了。有的库把地址做成 1 基有的按 0 基D100 可能被加 1 发成 101也有人把 D 的设备代码写错PLC 收到后回非零结束码。解决本文还有配套的精品资源点击获取