简介:面向工业自动化、上位机开发与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 是位单位,混用时命令里的子命令也要跟着变。
| 软元件 | 二进制设备代码 | 单位 | 注意 |
|---|---|---|---|
| D | 0xA8 | 字 | 最常用,任务参数、状态字都放这里 |
| M | 0x90 | 位 | 触发、握手、报警复位常用 |
| X | 0x9C | 位 | FX 系列 X/Y 常按八进制编号,别把 X10 当十进制 10 |
| Y | 0x9D | 位 | 输出点,写之前确认 PLC 程序是否也在写 |
| W | 0xB4 | 字 | 链接寄存器,按项目实际使用 |
// 软元件代码只取常用几类,现场先够用,后续按协议表补 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 0x00;X10 则不能直接按十进制 10 算,FX 系列 X/Y 是八进制编号,现场要先确认 PLC 侧显示的是 X10 还是 X8。这个边界不确认,读回来的点位会整体错位。
2.3 一帧请求报文的最小组成
4E 二进制批量读的一帧请求,结构不复杂,但每一段都有固定长度。子头0x50 0x00,后面跟网络号、PC 号、目标模块 I/O 号、站号、请求数据长度、监视定时器、命令、子命令、软元件代码、起始地址、点数。最容易错的是“请求数据长度”不是整帧长度,而是从监视定时器开始到帧尾的字节数。
| 段 | 长度 | 示例 | 说明 |
|---|---|---|---|
| 子头 | 2 | 50 00 | 4E 二进制请求 |
| 网络号 | 1 | 00 | 单网络常见 0 |
| PC 号 | 1 | FF | 上位机访问 PLC 常见 FF |
| 目标模块 I/O 号 | 2 | FF 03 | 以 PLC 参数为准 |
| 目标站号 | 1 | 00 | 单机直连常见 0 |
| 请求数据长度 | 2 | 小端 | 从监视定时器到帧尾 |
| 监视定时器 | 2 | 10 00 | 常见保守值,按 PLC 侧约定填 |
| 命令 | 2 | 01 04 | 批量读 |
| 子命令 | 2 | 00 00 | 字单位 |
| 软元件代码 | 1 | A8 | D 区 |
| 起始地址 | 3 | 小端 | D100 为64 00 00 |
| 点数 | 2 | 小端 | 读取字数 |
public static byte[] BuildReadWords( byte stationNo, byte deviceCode, int headDevice, ushort points, ushort monitorTimer = 0x0010) { var body = new List<byte>(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 List<byte>(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); Task<ushort[]> 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 EventHandler<bool> 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 是字数,返回数组长度应为 10;WriteWordsAsync的数组长度决定写入字数,不要传空数组;WriteBitsAsync的每个bool对应一个位点,协议里每个位点通常占 1 字节,写true是0x01,写false是0x00。如果 M100 在 PLC 里是上升沿触发,连续写true再写false可能被 PLC 扫描周期错过,稳妥做法是置位后等 PLC 回握手,再复位。
3.4 用仓库任务报文验证读写是否对齐
自动化立体仓库的任务报文,最怕字段顺序和 PLC 侧不一致。我的做法是固定一张 D 区映射表,先跟 PLC 侧对表,再用 C# 的 DTO 生成字数组。任务号、起点站、终点站、命令码、托盘码、校验字按顺序排,写入后用触发位通知 PLC,PLC 在另一段 D 区回确认字。
| D 地址 | 字段 | 类型 | 说明 |
|---|---|---|---|
| D500 | 任务号 | ushort | 不能为 0,新任务递增 |
| D501 | 起点站 | ushort | 按仓库站台编号 |
| D502 | 终点站 | ushort | 按仓库站台编号 |
| D503 | 命令码 | ushort | 入库、出库、移库等 |
| D504-D507 | 托盘码 | ushort[4] | 8 位 ASCII,两个字符挤一个字 |
| D508 | 校验字 | ushort | 前面字段 XOR |
| D600 | 确认字 | ushort | 1 接收,0xFFFF 拒绝 |
| D601 | 状态字 | ushort | 0 空闲,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 收到后回非零结束码。
解决
本文还有配套的精品资源,点击获取