简介:面向工业自动化与上位机开发人员的C#通信源码包,围绕三菱FX5U可编程逻辑控制器的以太网扩展帧协议,解决上位机与PLC之间的报文构造、发送与解析问题,并针对自动化立体仓库的库存管理和搬运控制场景做了业务封装。压缩包共三十五个文件,核心为十个C#源文件以及WinForm工程配置,另含可执行程序、调试符号、界面资源与Visual Studio状态文件,整体约七十九千字节,结构紧凑,方便在开发环境中直接打开研读。已有四百六十七人学习下载,适合具备基础C#语法、希望掌握PLC以太网通信或上位机界面开发的初学者。源码覆盖网络接口选择、扩展以太网帧报文封装、字节序转换、数据读写与超时重发机制,同时展示了如何通过自定义指令模块驱动货位查询、物料搬运等自动化立体仓库业务。通过对照WinForm界面与底层通信代码,可清晰理解从连接建立到业务指令下发的完整链路,为二次开发提供可直接参考的实现范例。
1. 三菱FX5U上位机通信:一个字节错误让堆垛机停在巷道中间
某天晚上立体仓库现场,堆垛机停在巷道中间不动,上位机界面显示"通信超时"。排查了一小时,最后发现是三菱FX5U的MC协议报文里,IO编号的模块号填错了一位。这个项目的源码就是那套上位机通信程序的完整版:C# WinForm 实现,通过以太网和三菱FX5U PLC走SLMP/MC二进制协议通信,带自动化立体仓库的入库、出库、移库任务下发和堆垛机状态采集。适合要用C#写上位机跟三菱PLC通信的开发者,也适合做立体仓库WCS/RCS项目的同学把通信底层拿来直接做二次开发——报文格式、软元件地址映射、轮询逻辑都是现成的。
2. 技术选型与协议拆解:C# WinForm 怎么和 FX5U 对话
2.1 为什么是 C# WinForm + MC 协议:这套组合省在哪
工业上位机开发里,C# WinForm 到现在依然是产线项目的主力。原因很简单:WinForm 的控件体系成熟,DataGridView、Timer、SerialPort、Socket 这些组件开箱即用,部署时拿一台 Windows 工控机装上 .NET Framework 就能跑,不像 WPF 那样要额外折腾绑定和样式。对立体仓库这种业务界面密集、状态刷新频繁的场景,WinForm 的开发效率和稳定性都够用。
通信协议这一层,三菱 FX5U 走以太网时支持 SLMP(Seamless Message Protocol),也就是常说的 MC 协议。MC 协议有两种帧格式:ASCII 格式和二进制格式。ASCII 格式用明文十六进制字符,比如读取 D100 的请求看起来是一串F9 50 01 00...,可读性强但报文体积大一倍;二进制格式直接用原始字节,报文紧凑、解析快。这个项目用的是二进制格式,因为立体仓库的实时性要求高,堆垛机位置、任务状态的轮询周期通常要压到 200ms 以内,报文越短越不容易出问题。
调试工具方面,三菱的 GX Works3 用来配 PLC 侧参数和监控软元件,网络调试助手用来抓上位机发出的报文。MC 协议的调试有个血泪经验:先不要接业务逻辑,单独把报文发到 PLC 上,用 GX Works3 的软元件监视看 D 寄存器有没有变化。这一步通了再谈别的。
2.2 MC 协议二进制帧格式:一个字节一个字节拆开看
MC 协议二进制格式的请求帧分三段:公共头部、请求数据长度、请求数据。读写指令的公共头部结构如下:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 2 | 0x50 0x00,固定值 |
| 命令 | 2 | 0x01 0x00 读,0x02 0x00 写 |
| 子命令 | 2 | 0x00 0x00 按字单位访问 |
| 网络编号 | 1 | 0x00,直连 PLC 用 0 |
| PLC 编号 | 1 | 0xFF,星号(任意站) |
| IO 编号 | 3 | 模块号,通常 0x10 0x00 0x00 |
| 站号 | 1 | 0x00 |
| 请求数据长度 | 2 | 请求数据部分的字节数,小端 |
读指令的请求数据部分是监视定时器(2字节)+ 软元件起始地址(3字节)+ 软元件代码(1字节)+ 软元件点数(2字节)。读 D100 两个字的完整报文这样拼:
// 读 D100,连续读取 2 个字 public byte[] BuildReadRequest(int startAddress, int count, byte deviceCode) { List<byte> frame = new List<byte>(); // 公共头部 frame.Add(0x50); frame.Add(0x00); // 帧头 frame.Add(0x01); frame.Add(0x00); // 命令:读 frame.Add(0x00); frame.Add(0x00); // 子命令:按字 // 网络号 / PLC号 / IO编号 / 站号 frame.Add(0x00); // 网络编号 frame.Add(0xFF); // PLC 编号 frame.Add(0x10); frame.Add(0x00); frame.Add(0x00); // IO 编号 frame.Add(0x00); // 站号 // 请求数据长度:2(定时器) + 3(地址) + 1(代码) + 2(点数) = 8 frame.Add(0x08); frame.Add(0x00); // 请求数据 frame.Add(0x10); frame.Add(0x27); // 监视定时器 10000ms = 0x2710,小端 byte[] addrBytes = BitConverter.GetBytes(startAddress); frame.Add(addrBytes[0]); frame.Add(addrBytes[1]); frame.Add(0x00); // 软元件地址 3 字节,第三个字节固定 0 frame.Add(deviceCode); // 软元件代码,D 区是 0xA8 byte[] countBytes = BitConverter.GetBytes((ushort)count); frame.Add(countBytes[0]); frame.Add(countBytes[1]); return frame.ToArray(); }软元件代码是 MC 协议里最需要记的一张表:D 数据寄存器是0xA8,M 中间继电器是0x90,X 输入是0x9C,Y 输出是0x9D,R 文件寄存器是0xAF。地址字段按字访问时直接填软元件编号本身,比如 D100 就是十进制的 100,转成 3 字节小端就是64 00 00。这里有个常见误区:X/Y 这类按位访问的软元件,地址编号是十六进制,比如 X0A 在报文里地址段直接填0x0A,而 D/M 这类字软元件填十进制编号。两种编码规则不一样,写代码时务必分开处理。
响应帧的结构和请求帧对应:帧头0xD0 0x00,命令和子命令原样返回,接下来是网络号、PLC号、IO编号、站号,然后是响应数据长度、结束代码和数据。结束代码0x00 0x00表示正常,其他值就是 PLC 侧的报错信息,常见错误码有0xC051(地址超范围)、0xC059(点数超上限)、0xC102(软元件不存在)。
2.3 工程目录结构:通信层和业务层分开
拿到源码先扫一眼目录,这个项目的结构是经典的通信分层:
Fx5U_Warehouse/ ├── Fx5U.Core/ │ ├── McProtocolClient.cs // Socket 通信基础类 │ ├── McFrameBuilder.cs // 报文构建器 │ ├── McResponseParser.cs // 响应帧解析器 │ └── Enums/DeviceCode.cs // 软元件代码枚举 ├── Fx5U.Business/ │ ├── WarehouseService.cs // 出入库业务封装 │ ├── PlcSignalMap.cs // 软元件地址映射表 │ └── TaskStateMachine.cs // 任务状态机 ├── Fx5U.WinForm/ │ ├── MainForm.cs // 主界面 │ ├── Controls/ConnectionPanel.cs │ ├── Controls/DebugPanel.cs // 报文调试面板 │ └── Controls/WarehousePanel.cs └── Fx5U.Tests/ ├── McFrameBuilderTests.cs └── ResponseParserTests.csFx5U.Core只做一件事:Socket 连接、发帧、收帧、解析,不掺业务。Fx5U.Business把软元件地址和业务动作绑定,比如"下发入库任务"对应写哪几个寄存器。Fx5U.WinForm只管界面展示和用户操作。这样分层的好处是:换 PLC 型号时只改 Core 层,换业务流程只改 Business 层,界面基本不动。测试项目里放的是报文构建和解析的单元测试,没有真实 PLC 的时候可以用模拟数据验证帧格式正确。
3. 核心模块拆解:一个请求从界面按钮到 PLC 动作的完整链路
3.1 通信基础类:Socket 连接、发送超时和断线重连
McProtocolClient是整条链路的起点。它维护一个TcpClient连接,提供了连接、断开、发送请求并等待响应的完整实现。FX5U 的以太网模块默认监听 5000 端口,PLC 侧需要在 GX Works3 里把连接方式配成 TCP,协议选 SLMP。
public class McProtocolClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private readonly object _lockObj = new object(); private string _ip = "192.168.3.250"; // PLC 的 IP private int _port = 5000; // FX5U 默认 SLMP 端口 private int _timeout = 3000; // 响应超时 3 秒 public bool Connect() { try { _tcpClient = new TcpClient(); var task = _tcpClient.ConnectAsync(_ip, _port); if (!task.Wait(_timeout)) { _tcpClient.Close(); throw new TimeoutException("PLC 连接超时,请检查 IP 和端口"); } _stream = _tcpClient.GetStream(); _stream.ReadTimeout = _timeout; _stream.WriteTimeout = _timeout; return _tcpClient.Connected; } catch (SocketException ex) { throw new IOException($"连接 PLC 失败:{ex.Message}"); } } public byte[] SendAndReceive(byte[] request) { lock (_lockObj) // 串行化请求,避免多线程交叉发帧导致解析错乱 { if (_stream == null || !_tcpClient.Connected) throw new IOException("连接已断开,请重新连接"); _stream.Write(request, 0, request.Length); // 先读 9 字节公共头部 byte[] header = new byte[9]; ReadExactly(header, 9); // 第 6~7 字节是响应数据长度字段(小端) int dataLength = BitConverter.ToUInt16(header, 6); byte[] data = new byte[dataLength]; ReadExactly(data, dataLength); return data; } } private void ReadExactly(byte[] buffer, int count) { int offset = 0; while (offset < count) { int read = _stream.Read(buffer, offset, count - offset); if (read == 0) throw new IOException("PLC 连接被关闭"); offset += read; } } }这个类必须加锁,因为上位机界面上可能有多个线程同时触发读操作——位置轮询线程在读 D105,操作员点了入库按钮在写 D100。如果不串行化,两个请求共用一个 Socket,响应帧就会互相串包。_timeout参数要按场景调:如果 PLC 扫描周期长,3 秒可能不够,代码里把它暴露成属性,界面调试面板里能改。
3.2 报文构建与响应解析:小端字节序和错误码处理
帧构建和响应解析是通信正确性的核心。MC 二进制协议用的是小端字节序(低字节在前),和 C# 在 Windows 上BitConverter的默认行为一致,但要注意跨平台场景——如果代码将来跑到 ARM 工控机或者 Linux 上用 Mono,字节序就未必一致了。稳妥做法是写两个显式处理字节序的工具方法:
public static class ByteOrder { // 将 int 转成指定长度的字节数组,按 MC 协议小端排列 public static byte[] GetBytes(int value, int length) { byte[] result = new byte[length]; for (int i = 0; i < length; i++) { result[i] = (byte)((value >> (8 * i)) & 0xFF); } return result; } // 从响应帧中解析一个字(2 字节,小端) public static ushort ToUInt16(byte[] data, int offset) { return (ushort)(data[offset] | (data[offset + 1] << 8)); } // 解析 32 位数据:FX5U 的双字寄存器是低位字在前 public static uint ToUInt32(byte[] data, int offset) { return (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)); } }响应解析器的核心逻辑是:先校验结束代码,非零直接抛带错误码的异常;然后把数据区按点数切成多个 2 字节字。写入指令的响应比较简单,数据区为空,只要检查结束代码就行。
public class McResponseParser { // 解析读响应:返回读取到的字数据数组 public static ushort[] ParseReadResponse(byte[] responseData, int pointCount) { if (responseData.Length < 2) throw new McProtocolException("响应帧长度不足"); ushort endCode = ByteOrder.ToUInt16(responseData, 0); if (endCode != 0) throw new McProtocolException($"PLC 返回错误码 0x{endCode:X4}"); // 去掉结束代码后,每 2 字节一个字 ushort[] values = new ushort[pointCount]; for (int i = 0; i < pointCount; i++) { values[i] = ByteOrder.ToUInt16(responseData, 2 + i * 2); } return values; } }这里有个容易被忽略的点:读响应返回的字数等于请求的点数,但响应数据区的长度等于2 + 2 * 点数(结束代码 2 字节 + 每个字 2 字节)。如果写代码时直接拿响应数据长度除以 2 得到点数,会多出一个结束代码的字节,导致解析出来的第一个字是错误码、后续数据整体错位。这个坑很多人在批量读取时踩——解析结果里第一个数永远不对,后面全是乱的。
3.3 业务命令封装:把"入库"翻译成一组软元件写入
通信基础层只认软元件,业务层需要把抽象操作翻译成具体的寄存器读写。拿立体仓库的入库任务举例,PLC 侧约定了一套软元件信号表:D100 存任务类型、D101 存目标货位的列号、D102 存层号、M100 是执行请求脉冲、M101 是执行中标志、M102 是完成标志。上位机下发的动作其实就是"写 D100~D102、然后置位 M100 再复位"。
public class WarehouseService { private readonly McProtocolClient _client; // 下发入库任务:目标货位为 column 列 / layer 层 public void SendStoreTask(int column, int layer) { // 1. 写任务类型 = 1(入库) _client.WriteD(100, new ushort[] { 1 }); // 2. 写目标位置 _client.WriteD(101, new ushort[] { (ushort)column }); _client.WriteD(102, new ushort[] { (ushort)layer }); // 3. 触发执行脉冲:M100 置 1 _client.WriteM(100, true); Thread.Sleep(50); // 等待 PLC 扫描周期识别到脉冲 _client.WriteM(100, false); } // 读取堆垛机当前位置 public (int column, int layer) GetCurrentPosition() { ushort[] values = _client.ReadD(105, 2); return (values[0], values[1]); } }这个封装模式的核心原则是:业务方法里不出现任何报文字节和软元件地址的拼装,全部收敛到McProtocolClient的ReadD、WriteD、WriteM方法里。这样 UI 层调用时就是语义化代码——"下发入库任务"一句话,后续换 PLC 型号或者改地址表,只需要改PlcSignalMap一个类。M 继电器的写入在内部走的是写指令的位访问模式,命令码也是0x02 0x00,但子命令变成0x01 0x00(按位访问),地址编码规则和字访问不同,这块在WriteM内部处理掉了,外面看不出来。
4. 立体仓库业务落地:软元件地址表、入库状态机和界面联动
4.1 信号表设计:PLC 侧软元件地址怎么规划才不乱
自动化立体仓库的通信程序,本质上是上位机和 PLC 约定一套"谁写哪个地址、谁读哪个地址"的契约。PLC 程序由另外的工程师用梯形图写,上位机程序这边必须有一张和梯形图严格一一对应的信号表。这个项目的地址规划分三个区段:
| 信号类别 | 软元件范围 | 用途 |
|---|---|---|
| 任务下发区 | D100~D103 | 任务类型、目标列、目标层、临时备用 |
| 任务状态区 | M100~M110 | 执行请求、执行中、完成、异常标志 |
| 设备状态区 | D105~D110 | 堆垛机当前位置、货叉状态、当前任务号 |
| 库位状态区 | D200~D549 | 10 列 5 层共 50 个库位的货物编号 |
库位状态区的地址映射有个讲究:地址 = 起始地址 + (列编号 × 层数 + 层编号)。比如 10 列 5 层,起始地址 D200,那么第 2 列第 3 层的库位地址就是200 + (2 * 5 + 3) = 213。这个换算规则必须让 PLC 工程师和上位机工程师都遵守,否则两边各算各的,轻则显示错位,重则把货物信息写到别的库位上——这在立体仓库里就是事故。代码里用PlcSignalMap这个静态类统一管理这些换算:
public static class PlcSignalMap { private const int ColumnCount = 10; // 巷道列数 private const int LayerCount = 5; // 每列层数 private const int CellStartAddress = 200; // 库位状态起始地址 // 根据列和层换算库位状态地址 public static int GetCellAddress(int column, int layer) { if (column < 0 || column >= ColumnCount) throw new ArgumentOutOfRangeException(nameof(column)); if (layer < 0 || layer >= LayerCount) throw new ArgumentOutOfRangeException(nameof(layer)); return CellStartAddress + (column * LayerCount + layer); } // 任务下发相关地址 public const int TaskTypeAddress = 100; public const int TaskColumnAddress = 101; public const int TaskLayerAddress = 102; // 任务状态相关地址 public const int TaskRequestBit = 100; // M100 public const int TaskRunningBit = 101; // M101 public const int TaskDoneBit = 102; // M102 public const int TaskFaultBit = 103; // M103 }信号表定下来之后,上位机和 PLC 的开发就可以并行推进了。PLC 这边按表写梯形图,上位机这边按表写读写逻辑,联调前先用 GX Works3 的软元件监视手动改几个地址值,验证上位机界面能不能正确刷新。
4.2 入库出库流程:从按钮点击到堆垛机动作的状态机
入库操作的完整链路是:操作员在界面选目标库位,点确认入库 → 上位机写任务类型和目标位置 → 置位 M100 发出请求脉冲 → PLC 收到后置位 M101(执行中),控制堆垛机去取货 → 堆垛机到位后 PLC 置位 M102(完成)→ 上位机轮询到 M102 后刷新界面。这个流程用一个简单的状态机来管理比较清晰:
public enum TaskState { Idle = 0, // 空闲 Waiting = 1, // 请求已下发,等待 PLC 应答 Running = 2, // PLC 执行中 Completed = 3, // 任务完成 Fault = 4 // 异常 } public class TaskStateMachine { private readonly McProtocolClient _client; private TaskState _currentState = TaskState.Idle; public event Action<TaskState, string> StateChanged; // 状态变化事件 public async Task PollTaskStateAsync(CancellationToken token) { while (!token.IsCancellationRequested) { // 读取 M101(执行中)和 M102(完成)的标志位 bool running = _client.ReadM(101); bool done = _client.ReadM(102); bool fault = _client.ReadM(103); TaskState newState = _currentState; if (fault) newState = TaskState.Fault; else if (done) newState = TaskState.Completed; else if (running) newState = TaskState.Running; else newState = TaskState.Idle; if (newState != _currentState) { _currentState = newState; StateChanged?.Invoke(_currentState, DescribeState(newState)); } await Task.Delay(200, token); // 200ms 轮询周期 } } }这里有三个工程细节值得注意。第一,轮询周期不能太短,FX5U 的扫描周期通常在 5~20ms 之间,但 PLC 程序里如果带了运动控制指令,扫描周期可能被拉长到 50ms 以上。轮询间隔给 200ms 既不会漏状态,也不会给 PLC 造成通信压力。第二,M 继电器的读操作底层是位访问模式,命令码和字访问不同,如果直接用读 D 的方式来读 M,报文发出去 PLC 会返回错误码。第三,状态变化用事件通知界面刷新,轮询线程只负责采集状态,不直接碰控件,避免 WinForm 跨线程访问控件抛异常。
4.3 WinForm 界面与轮询逻辑:数据刷新不能卡界面
界面的核心区域是一张 10 列 5 层的库位表格,用DataGridView实现,每一格显示货物编号,用背景色区分状态——空闲是白色、占用是浅蓝、任务目标是黄色、异常是红色。刷新逻辑没有用Timer全量刷新,而是让轮询线程每次读到 D200~D549 的 50 个字后,和本地的缓存数组对比,有变化的格子才更新 UI:
public void RefreshCellStatus(ushort[] cellData) { if (cellData.Length != ColumnCount * LayerCount) return; // 只更新变化的格子,避免 DataGridView 全量刷新闪烁 for (int i = 0; i < cellData.Length; i++) { if (_cellCache[i] != cellData[i]) { _cellCache[i] = cellData[i]; int column = i / LayerCount; int layer = i % LayerCount; var cell = _grid[column, layer]; cell.Value = cellData[i] == 0 ? "空" : $"货-{cellData[i]:D3}"; cell.Style.BackColor = cellData[i] == 0 ? Color.White : Color.LightBlue; } } }WinForm 界面和操作线程的交互有一条铁律:轮询线程或异步任务里不能直接改控件,必须通过Control.Invoke或Control.BeginInvoke封送到 UI 线程。这个项目在RefreshCellStatus的调用处统一做了BeginInvoke封装——把数据传到 UI 线程再执行,异步不阻塞轮询线程。如果直接在后台线程里改DataGridView的Value,偶尔会蹦出InvalidOperationException,而且这类异常往往在自测时发现不了,因为自测的轮询频率低,现场跑起来才有一定概率触发。
操作区的按钮逻辑相对直接:点击"入库"后,先校验目标库位有没有被占用(查本地缓存),占用则弹提示,空闲则调用WarehouseService.SendStoreTask,同时界面切到"任务等待"状态。这里有个反馈体验问题——PLC 执行一个入库任务可能要 20 秒,期间操作员反复点按钮会重复下发任务。解决办法是状态机里加一个保护:_currentState不是Idle时禁用入库/出库按钮,任务完成或故障后才恢复。
5. 项目运行与避坑排查:IP 配置、报文长度和 PLC 上的几个坑
5.1 运行环境和网络配置:连不上 PLC 时先查这四个地方
运行这个项目不需要特殊环境,Visual Studio 2019 或 2022 打开解决方案,选中Fx5U.WinForm作为启动项目,编译后直接跑。依赖只有 .NET Framework 4.7.2 和 Visual C++ Redistributable(Fx5U.Core 里如果用到了 C++ 混合编译的库才需要,这套源码纯 C# 实现,一般用不到)。给操作员部署时用 Release 配置发布,目标选win-x64,拿一台 Windows 10 工控机装上 .NET Framework 4.7.2 就行。
连接 PLC 之前,务必把以下四件事核对一遍,这个顺序就是排查的顺序:
第一,PLC 的 IP 地址。FX5U 默认 IP 是192.168.3.250,但很多项目现场会被改掉。用 GX Works3 连上 PLC,在"参数 → FX5U CPU → 模块参数 → 以太网端口设置"里看实际 IP。上位机电脑的 IP 必须和 PLC 在同一网段——PLC 是192.168.3.250的话,电脑就设192.168.3.20之类,子网掩码一致。第二,SLMP 连接是否启用。FX5U 的以太网端口默认是启用的,但有些 PLC 程序会把端口配置成 MELSOFT 连接用,导致外部上位机连不上。在 GX Works3 的以太网端口设置里,确认"SLMP 连接"是允许状态,端口号默认 5000。第三,Windows 防火墙。工控机上跑上位机,Windows 防火墙经常会拦截 TCP 5000 端口的入站连接。用管理员身份在 PowerShell 里跑New-NetFirewallRule -DisplayName "FX5U SLMP" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Allow。第四,PLC 的 CPU 编号。报文公共头部里的"PLC 编号"字段默认0xFF表示任意站,但如果 PLC 侧设置了具体的 CPU 编号,这里必须填对应的编号,否则通信超时。这个字段在调试时最容易被忽略,因为大多数教程都用默认值。
网络配置验证有一个速成方法:电脑上用网络调试助手建一个 TCP Server,监听 5000 端口,然后让 PLC 主动连接(需要在 PLC 程序里加一个 TCP 主动连接指令,GX Works3 的库里有现成的 FB 块)。如果 PLC 能连到调试助手并发出数据,说明 PLC 的以太网模块没问题,问题在上位机侧;反过来就是 PLC 侧配置问题。
5.2 常见问题排查:六个真实的踩坑记录
坑 1:连接正常但发送后超时
现象:上位机显示"已连接",但每次读操作都超时,GX Works3 软元件监视正常。
原因:报文里的"请求数据长度"字段算错了。MC 协议的请求数据长度是"请求数据部分"的字节数,不包括公共头部。读指令是 8 字节,写指令按点数变化。写错长度时 PLC 直接丢弃报文,不返回任何响应。
解决:在McFrameBuilder的测试项目里加了帧长度断言,要求每个构建出来的报文长度必须和实际字节数一致。调试时先用网络调试助手手动发一条正确报文确认 PLC 有响应,再回头查代码。
坑 2:批量读取时第一个数据永远不对
现象:读 D200~D249 共 50 个字,解析结果第一个值始终是0xC051或某个固定值,后面的数据整体错位。
原因:响应解析时把"结束代码"也当成了一个数据字。响应数据区的结构是"结束代码 2 字节 + 实际数据 N 字节",解析时没跳过结束代码,直接从头开始按 2 字节切分。
解决:解析函数里固定从offset = 2开始读数据,这个规则写在McResponseParser.ParseReadResponse的注释里。从那以后我每次写 PLC 通信的解析器,第一件事就是确认结束代码的偏移量。
坑 3:写 D 区数据没反应,但 GX Works3 手动写可以
现象:上位机写 D100 返回正常(结束代码为 0),但 PLC 程序里的值没变。
原因:写入数据的字节序反了。FX5U 双字寄存器(32 位数据)是低字在前,高位字在后。写一个int数值到两个连续地址时,如果直接BitConverter.GetBytes后按顺序发,高低字就颠倒了。
解决:写入 32 位数据到 D 区时,地址必须连续,数据按低字地址存低位、高字地址存高位排列,代码里用PutValueByLowFirst方法显式处理。
坑 4:运行两小时后通信中断,重连也连不上
现象:上位机跑了几个小时,突然所有读写都超时,点断开再连接报"目标主机积极拒绝"。
原因:PLC 的 SLMP 连接数被占满了。FX5U 的以太网端口默认同时只允许 16 个连接(具体看 CPU 型号),每次上位机异常断开后,PLC 侧的老连接不会立刻释放。反复重连几次后连接数耗尽。
解决:上位机这边加上心跳保活——每 5 秒发一条长度最小的读指令(比如读 M0 一个字),确认连接还活着;异常断开时Dispose掉 TcpClient 再重连,而不是直接新建连接。PLC 侧也可以把连接超时时间调短,让未活动的连接快速释放。
坑 5:M 继电器读出来全是 false
现象:PLC 程序里 M100 已经置位,上位机读 M100 一直返回 false。
原因:位访问模式下软元件地址的编码规则和字访问不同。读 D 区地址字段填十进制编号,读 M 区时地址字段填的是位地址偏移,按 16 进制转换后填入 3 字节地址。
解决:ReadM方法内部把 M 编号做一次转换——地址 = 编号 & 0xFFF,超出部分放到地址字段的第三个字节。这个转换的代码和注释放在McFrameBuilder里,改 PLC 程序时只动这一个方法就行。
坑 6:界面刷新闪烁、CPU 占用高
现象:库位表格刷新时界面明显闪烁,任务管理器看上位机进程的 CPU 占用一直在 15% 以上。
原因:界面用System.Windows.Forms.Timer做全量刷新,500ms 一次,每次都重建 50 个单元格。Timer本身在 UI 线程执行,刷新期间界面无法响应用户点击。
解决:换成后台轮询线程 +BeginInvoke的方案,只更新有变化的格子。CPU 占用降到了 3% 以内,界面操作也流畅了。WinForm 项目里要习惯一个原则:Timer只用来做轻量任务,通信轮询、数据处理这些一律丢到Task.Run或后台线程。
6. 二次开发扩展思路:从单机通信到多设备产线调度
这套源码的定位是"通信底座 + 单机业务层",往上扩展的接口其实都已经留好。最常见的改造方向是把单机版扩成多设备管理——立体仓库现场通常不止一台堆垛机,也可能有 RGV(有轨制导小车)和输送线。改造点在WarehouseService上做一个设备管理器,每台设备持有自己的McProtocolClient实例,统一用一个调度模块管理任务排队。这里的关键是多设备间的互锁逻辑:同一列巷道不能同时进两台堆垛机,这种规则要么放 PLC 侧梯形图,要么上位机在发任务前先读设备状态区做判断。
另一个高频扩展是对接 MES 或 WMS 系统。上位机从通信工具变成承上启下的中间层:接收 MES 下发的入库/出库订单,拆解成堆垛机任务,执行完成后把结果回传。这需要加一个 HTTP 或 WebSocket 接口层,把WarehouseService的SendStoreTask方法暴露成 RESTful API。接口层的写法不复杂,但要注意事务一致性问题——MES 那边已经确认任务下发,结果上位机这边 PLC 通信断了,任务没执行成功,回传 MES 的时候必须明确返回失败原因,否则库存数据就漂移了。
日志和异常恢复也是必须补的一块。现在的代码超时会抛异常,界面弹错误对话框,但现场工控机旁边不一定有人盯着。建议加一个ICommunicationLogger接口,把每次读写的报文、时间戳、耗时、错误码落到本地日志文件,同时把异常状态通过界面闪烁或者蜂鸣器提醒。断线后自动重连的逻辑补上,按"3 秒重试一次、连续失败 5 次后报警"的节奏来。这套代码里McProtocolClient的Dispose和锁机制是线程安全的,直接加重连循环不用担心连接泄漏。
我自己的习惯是拿到这类通信源码先不改业务,而是把调试面板做厚——能手动改任意软元件的值、能看报文 hex 文本、能回放历史报文。这套源码的DebugPanel.cs已经有基础的报文显示功能,但加上报文回放之后,排障效率会高出一个量级。从那以后我每次做 PLC 通信都要先在调试面板里手工把每个软元件读写一遍,确认底层没问题再往上写业务——这个步骤能省掉后面至少一半的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取