☰
C#与三菱FX5U PLC通信实战:SLMP协议解析与健壮通信库实现
2026/9/30 21:32:33 网站建设 项目流程

简介:本资源是一套面向工业自动化开发者的C#与三菱FX5U系列PLC以太网通信实战DEMO源代码,适用于具备基础.NET编程能力及PLC概念的工程师、自动化专业学生和设备集成人员,解决上位机与FX5U控制器高效稳定通信的核心开发问题。压缩包共38个文件,包含6个核心C#源码文件(如Form1.cs、Program.cs)、4个关键DLL库(支撑协议解析与连接管理)、3个可执行EXE程序(含调试运行入口)、以及配置文件(app.config)、资源文件(.resx)、解决方案工程(.sln/.csproj)等,整体仅310KB,轻量易部署。已有2149人学习下载,代码结构清晰,涵盖连接初始化、寄存器读写封装、异常处理机制与定时轮询逻辑,特别适合作为二次开发起点或教学参考——读者可直接复用通信模块、理解FX5U内存映射规则,并基于现有框架快速扩展HMI监控、数据采集或远程控制功能。

1. 项目缘起:为什么我们需要一个FX5U的C#通信DEMO?

如果你正在用C#开发上位机软件,并且需要和三菱FX5U系列PLC打交道,那么你大概率已经踩过或者即将踩进一个“坑”:官方文档虽然详尽,但直接上手写通信代码,尤其是处理数据读写、错误重连这些细节时,总感觉隔着一层纱。网上能找到的代码片段要么是基于老旧的MX Component,要么是零散的Socket示例,缺乏一个完整、健壮、能直接跑起来的项目骨架。

这就是我当初的困境。我需要一个能稳定连接FX5U,进行批量位、字数据读写,并且能优雅处理网络异常和PLC端错误的C#程序。翻遍了三菱的MELSEC通信协议手册(SLMP协议),结合实际的调试经验,我整理出了这套DEMO源代码。它不是一个简单的“Hello World”连接测试,而是一个包含了连接管理、数据帧构造、响应解析、异常处理等核心模块的、可直接用于生产环境二次开发的通信库雏形。对于从零开始的开发者,它能帮你跳过最痛苦的协议理解阶段;对于有经验的工程师,其中的一些设计思路和避坑点或许也能给你带来启发。

2. 核心通信协议:SLMP与MC协议的精髓

与三菱PLC通信,绕不开SLMP协议。你可以把它理解为三菱为自家设备定制的一套“语言规则”。FX5U作为新一代PLC,全面支持基于TCP/IP的SLMP协议(也称为MC协议),这让我们用C#通过以太网进行通信变得非常直接。

2.1 协议帧结构:请求与响应的“对话模板”

SLMP协议的核心在于其固定的帧结构。一次完整的“对话”由上位机(我们的C#程序)发送“请求帧”,PLC返回“响应帧”。

一个典型的读取软元件(如D寄存器)的请求帧结构如下:

字段名长度(字节)说明示例值(十六进制)
副头部2固定为50 00,表示SLMP50 00
访问路径15网络号、PC号等,通常默认为FF FF 03 00+ 11个00FF FF 03 00 00 00 00 00 00 00 00 00 00 00 00
请求数据长度2后续请求数据的字节长度00 0C(表示后面有12个字节)
定时器2通信超时时间(单位:250ms),00 00表示使用PLC侧设置00 00
命令2核心:指明操作类型,如04 00表示批量读04 00
子命令2核心:进一步细分,如00 00表示软元件访问00 00
起始软元件4核心:要操作的软元件起始地址(如D100)A8 00 00 00(D100)
软元件代码2核心:软元件类型,A8表示D寄存器A8 00
软元件点数2核心:要读取/写入的点数(字数或位数)00 02(2个字)

注意:这里的“软元件代码”和“起始软元件”的编码方式是关键坑点。对于D寄存器(代码A8),其地址100需要转换为100 * 16 = 1600,再转为十六进制0x0640,但在帧中需要以40 06 00 00(小端序)的形式存放。上表中的A8 00 00 00是经过转换后,表示D100的最终形态。DEMO代码中的ConvertDeviceAddressToBytes函数就是专门处理这个转换的。

响应帧的结构类似,但包含一个“结束代码”字段。如果结束代码为00 00,表示成功,后面跟着请求的数据;如果不是,则表示发生了错误,需要根据代码排查。

2.2 TCP连接与保持:不是连上就一劳永逸

在C#中,我们使用System.Net.Sockets.TcpClient来建立TCP连接。但工业现场的网络环境复杂,PLC也可能重启,因此简单的“连接-使用-断开”模式不可靠。

DEMO中的连接管理策略:

  1. 心跳机制:定期(如每5秒)向PLC发送一个轻量级的读取命令(例如读取一个固定的系统位)。如果连续多次失败,则判定连接失效,触发重连逻辑。
  2. 自动重连:在检测到连接断开后,不是简单抛异常给上层,而是进入一个后台重试循环,尝试重新建立连接,并在成功后恢复之前的通信状态。重连间隔应逐渐延长(如1秒,2秒,4秒…),避免频繁冲击网络和PLC。
  3. 资源清理:确保TcpClient和NetworkStream在使用完毕后被正确Dispose。我习惯使用using语句块或在类中实现IDisposable接口来管理。
// 简化的连接与发送示例 public class MitsubishiPLCClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private string _ipAddress; private int _port; private CancellationTokenSource _heartbeatCts; public async Task ConnectAsync(string ip, int port = 5001) // FX5U默认端口通常是5001或5002 { _ipAddress = ip; _port = port; _tcpClient = new TcpClient(); await _tcpClient.ConnectAsync(ip, port); _stream = _tcpClient.GetStream(); StartHeartbeat(); } private void StartHeartbeat() { _heartbeatCts = new CancellationTokenSource(); Task.Run(async () => { while (!_heartbeatCts.Token.IsCancellationRequested) { await Task.Delay(5000, _heartbeatCts.Token); try { // 尝试读取一个无关紧要的位,如SM0(常ON) await ReadBitAsync("SM0"); } catch { // 心跳失败,触发重连事件或标记连接状态 OnConnectionLost?.Invoke(this, EventArgs.Empty); } } }, _heartbeatCts.Token); } public void Dispose() { _heartbeatCts?.Cancel(); _stream?.Close(); _tcpClient?.Close(); } }

3. DEMO核心模块详解:从字节流到业务数据

这个DEMO项目不是一个大而全的框架,而是聚焦于通信最核心的几个功能:连接、读、写。项目结构清晰,主要分为以下几个部分:

3.1 地址转换器:破解三菱的“地址密码”

这是通信的第一道关卡。在C#里我们习惯用字符串如“D100”、“M50”来表示地址,但协议帧里需要的是特定的二进制码。

核心函数ConvertDeviceAddressToBytes逻辑:

  1. 解析字符串:识别软元件类型(D, M, Y, X等)和十进制地址。
  2. 类型映射:将软元件字母映射到协议规定的代码(如 D->0xA8, M->0x90)。
  3. 地址计算:这是最容易出错的地方。对于字设备(如D, W),地址需要乘以16。D100->100 * 16 = 1600-> 十六进制0x0640。对于位设备(如M, X, Y),计算方式相同,但读写时命令不同。
  4. 字节序处理:将计算出的地址转换为字节数组,并按照小端序(低位在前)排列。0x0640在帧中应为[0x40, 0x06, 0x00, 0x00]。
// 地址转换核心代码片段 public static byte[] ConvertDeviceAddressToBytes(string deviceAddress, out byte deviceCode) { // 示例:解析 “D100” char deviceType = deviceAddress[0]; // ‘D’ int addressNumber = int.Parse(deviceAddress.Substring(1)); // 100 switch (deviceType) { case 'D': case 'd': deviceCode = 0xA8; // D寄存器代码 addressNumber *= 16; // 关键步骤:乘以16 break; case 'M': case 'm': deviceCode = 0x90; // M继电器代码 addressNumber *= 16; break; // ... 其他软元件类型 default: throw new ArgumentException($"Unsupported device type: {deviceType}"); } byte[] addressBytes = BitConverter.GetBytes((uint)addressNumber); // 确保小端序,如果系统不是小端序则需要反转数组 if (!BitConverter.IsLittleEndian) Array.Reverse(addressBytes); return addressBytes; // 返回4字节地址数组 }

3.2 帧构造器:组装完整的请求命令

有了地址字节和操作类型(读/写),我们需要组装成一个完整的、符合SLMP协议的请求帧数组。

BuildReadWordCommand函数流程:

  1. 创建固定长度的字节数组(如26字节的头部 + 数据部分)。
  2. 按顺序填入副头部、访问路径等固定值。
  3. 填入由ConvertDeviceAddressToBytes得到的软元件代码和地址字节。
  4. 填入要读取的点数(字数)。
  5. 计算“请求数据长度”字段并填入。注意:这个长度是指命令码之后的数据长度,不包括副头部、访问路径等。
public byte[] BuildReadWordCommand(string startDevice, ushort pointCount) { byte deviceCode; byte[] addressBytes = ConvertDeviceAddressToBytes(startDevice, out deviceCode); // 假设固定头部长度为 21 字节(副头2+路径15+数据长2+定时器2) int frameLength = 21 + 10; // 10字节是命令码(2)+子命令(2)+地址(4)+软元件码(2) byte[] frame = new byte[frameLength]; int index = 0; // 填充副头部 50 00 frame[index++] = 0x50; frame[index++] = 0x00; // 填充15字节访问路径 (默认) byte[] defaultPath = new byte[] { 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; Array.Copy(defaultPath, 0, frame, index, defaultPath.Length); index += defaultPath.Length; // **请求数据长度**:从命令码开始到帧尾的字节数。这里是 10 字节。 ushort requestDataLength = 10; byte[] lengthBytes = BitConverter.GetBytes(requestDataLength); if (!BitConverter.IsLittleEndian) Array.Reverse(lengthBytes); frame[index++] = lengthBytes[0]; frame[index++] = lengthBytes[1]; // 定时器 (默认00 00) frame[index++] = 0x00; frame[index++] = 0x00; // 命令码:0400 批量读 frame[index++] = 0x04; frame[index++] = 0x00; // 子命令:0000 软元件访问 frame[index++] = 0x00; frame[index++] = 0x00; // 填入起始地址 (4字节,小端序) Array.Copy(addressBytes, 0, frame, index, 4); index += 4; // 填入软元件代码 (2字节) frame[index++] = deviceCode; frame[index++] = 0x00; // 填入点数 (2字节,小端序) byte[] pointBytes = BitConverter.GetBytes(pointCount); if (!BitConverter.IsLittleEndian) Array.Reverse(pointBytes); frame[index++] = pointBytes[0]; frame[index++] = pointBytes[1]; return frame; }

3.3 通信执行与响应解析:处理粘包与错误

通过NetworkStream发送请求帧后,我们需要读取响应。这里有两个关键点:确定响应长度和处理粘包。

响应解析步骤:

  1. 读取固定头部:先读取响应帧的前11个字节(副头部+访问路径)。从这个头部里,可以解析出“响应数据长度”。
  2. 计算总帧长:总帧长 = 11字节头部 + 响应数据长度。根据这个长度,继续读取剩余的字节。
  3. 检查结束代码:在响应数据的起始位置,就是2字节的“结束代码”。00 00表示成功。
  4. 提取数据:如果成功,结束代码后面就是实际读取到的数据。每个字(16位)占2个字节,同样是小端序,需要转换。
public async Task<short[]> ReadWordsAsync(string startDevice, ushort pointCount) { byte[] requestFrame = BuildReadWordCommand(startDevice, pointCount); await _stream.WriteAsync(requestFrame, 0, requestFrame.Length); // 1. 先读取11字节的响应头部 byte[] headerBuffer = new byte[11]; int bytesRead = await _stream.ReadAsync(headerBuffer, 0, 11); if (bytesRead != 11) throw new InvalidDataException("Failed to read response header."); // 2. 从头部解析出响应数据长度 (位于第9-10字节,小端序) ushort responseDataLength = BitConverter.ToUInt16(headerBuffer, 9); if (!BitConverter.IsLittleEndian) responseDataLength = (ushort)((responseDataLength >> 8) | (responseDataLength << 8)); // 3. 读取剩余响应数据 byte[] dataBuffer = new byte[responseDataLength]; bytesRead = await _stream.ReadAsync(dataBuffer, 0, responseDataLength); if (bytesRead != responseDataLength) throw new InvalidDataException("Incomplete response data."); // 4. 检查结束代码 (数据区前2字节) ushort endCode = BitConverter.ToUInt16(dataBuffer, 0); if (endCode != 0x0000) { throw new PlcCommunicationException($"PLC returned error code: 0x{endCode:X4}"); } // 5. 提取数据 (结束代码后开始) int dataStartIndex = 2; short[] result = new short[pointCount]; for (int i = 0; i < pointCount; i++) { int dataOffset = dataStartIndex + i * 2; result[i] = BitConverter.ToInt16(dataBuffer, dataOffset); // 注意:如果PLC字数据是高位在前,可能需要在这里做反转 // if (!BitConverter.IsLittleEndian) Array.Reverse(dataBuffer, dataOffset, 2); } return result; }

实操心得:粘包处理:在高速通信时,PLC可能将多个响应一次性返回,或者网络底层合并了数据包。上面的代码通过“先读固定头部,再根据头部信息读剩余部分”的方式,完美解决了粘包问题。这是一种非常可靠的模式。

4. 从DEMO到实用:必须考虑的进阶问题与优化

把DEMO跑通只是第一步。要用于实际项目,以下几个方面的考量至关重要。

4.1 错误处理与重试策略:让程序更健壮

工业通信必须稳定。不能因为一次网络抖动或PLC忙就导致整个程序卡死或崩溃。

  1. 超时设置:TcpClient和NetworkStream的ReadTimeout、WriteTimeout属性必须设置。通常设为3000-5000毫秒。
  2. 异常分类处理:
    • SocketException:网络层错误,如连接拒绝、主机不可达。应触发重连逻辑。
    • IOException:流错误,可能是超时或连接中断。也应触发重连。
    • PlcCommunicationException(自定义):PLC返回非零结束代码,表示逻辑错误(如地址非法)。这类错误通常不需要重连,但需要记录并通知用户。
  3. 重试策略:对于可重试的异常(如网络超时),实现指数退避重试。例如,第一次重试等待1秒,第二次2秒,第三次4秒,最多重试3次。
public async Task<short[]> ReadWordsWithRetryAsync(string device, ushort points, int maxRetries = 3) { int retryCount = 0; while (true) { try { return await ReadWordsAsync(device, points); } catch (SocketException ex) { retryCount++; if (retryCount > maxRetries) throw; Logger.Warn($"Socket error reading {device}, retry {retryCount}/{maxRetries}. Error: {ex.Message}"); await Task.Delay(1000 * (int)Math.Pow(2, retryCount - 1)); // 指数退避 await ReconnectAsync(); // 尝试重新连接 } catch (IOException ex) when (ex.InnerException is SocketException) { // 处理因Socket引起的IO异常 retryCount++; if (retryCount > maxRetries) throw; Logger.Warn($"IO error reading {device}, retry {retryCount}/{maxRetries}. Error: {ex.Message}"); await Task.Delay(1000 * (int)Math.Pow(2, retryCount - 1)); await ReconnectAsync(); } catch (PlcCommunicationException) { // PLC逻辑错误,直接抛出,不重试 throw; } } }

4.2 性能优化:批量操作与异步并发

频繁地读写单个字效率极低。SLMP协议支持批量读写,一定要充分利用。

  1. 批量读写:一次命令读写多个连续地址。DEMO中的pointCount参数就是用于此。尽量将需要同步更新的数据安排在连续的地址内,一次操作完成。
  2. 异步编程:使用async/await避免UI线程或主线程阻塞。所有的ReadAsync、WriteAsync、ConnectAsync都应使用异步版本。
  3. 连接池与资源复用:对于需要与多台PLC通信的大型系统,可以考虑管理一个TcpClient连接池,避免频繁创建和销毁连接的开销。但FX5U通常一对一通信,单例模式管理一个长连接即可。

4.3 FX5U特定配置:让通信真正连通

代码写得再好,PLC侧配置不对也是白搭。确保以下几点:

  1. PLC IP设置:为FX5U设置固定的IP地址、子网掩码和默认网关,确保与上位机在同一网段。
  2. 内置以太网端口参数:在GX Works3中,需要配置“模块参数”。
    • 打开“内置以太网端口”设置。
    • 在“打开设置”中,新建一个协议,选择“MC协议”。
    • 设置端口号(默认为5001或5002,需与代码中一致)。
    • 设置通信目标,通常允许“所有连接对象”或指定上位机的IP。
    • 务必设置“在线操作”->“当前连接设置”,将你创建的MC协议配置进去,并设置好IP地址和端口。这是最容易遗漏的一步!
  3. 防火墙:关闭PLC和上位机Windows的防火墙,或为相应端口添加入站规则。
  4. PLC运行状态:确保PLC处于RUN模式,STOP模式下某些通信可能被禁止。

4.4 数据类型转换:处理浮点数与长整数

PLC中的D寄存器是16位字。但实际数据可能是32位整数(DINT)、32位浮点数(REAL)或64位长整数(LINT)。

  • 32位数据(如D100-D101组成的浮点数):读取连续的2个字(D100, D101),将其转换为4字节的byte[],再用BitConverter.ToSingle()转换为float。特别注意字节顺序!三菱FX5U的浮点数格式通常是IEEE 754标准,但字序可能是“高字在前,低字在后”(即D100是高16位,D101是低16位),也可能相反。这需要在代码中根据实际情况调整或提供配置选项。
  • 位操作:读写单个线圈(如M0)使用不同的命令码(位读/位写)。读取多个连续位时,响应数据中每个位占用一个字节(0x00或0x01)。

DEMO源代码中提供了ReadFloat和WriteFloat的示例方法,展示了如何组合两个字并进行字节序处理。

5. 调试与排错实战:当通信失败时该怎么办

即使按照上述步骤,第一次调试也难免失败。这里有一套系统的排查流程。

第1步:检查物理连接与基础配置

  • Ping测试:在电脑命令行ping PLC的IP地址。如果不通,检查网线、交换机、IP设置。
  • 确认端口:使用telnet PLC_IP 5001测试端口是否开放。如果连接被拒绝,检查PLC的MC协议配置是否启用且端口正确。
  • 核对GX Works3配置:反复确认“当前连接设置”中已添加了MC协议,并且IP/端口与代码一致。

第2步:抓包分析——终极武器当逻辑层面找不到问题时,网络抓包是唯一真相。使用Wireshark。

  1. 在运行上位机程序前,在Wireshark中选择正确的网卡开始抓包。
  2. 执行一次通信操作(如点击“读取”按钮)。
  3. 停止抓包,使用过滤器tcp.port == 5001筛选出与PLC的通信数据包。
  4. 分析请求帧:找到上位机发出的TCP包,展开“Melsec”协议(如果Wireshark识别正确)。逐字节对比你的代码生成的帧,与Wireshark解析出来的帧是否完全一致。重点关注命令码、地址、软元件代码、点数这几个字段。
  5. 分析响应帧:查看PLC返回的包。如果结束代码非零,Wireshark通常会直接显示错误信息(如“地址超出范围”)。这能直接定位问题。

第3步:代码层逐段调试

  • 打印字节数组:在发送前和接收后,将byte[]以十六进制格式打印到日志中。与手册中的示例帧或Wireshark抓到的包进行比对。
  • 检查地址转换:单独测试ConvertDeviceAddressToBytes函数,输入“D100”,看输出的4字节地址是否是[0x40, 0x06, 0x00, 0x00]。
  • 简化测试:先尝试最简单的功能,比如读取一个位的状态(如X0),因为位操作帧更短,便于分析。

常见错误代码与原因:

  • 0xC050:访问目标不正确(IP/端口错,或PLC未配置MC协议)。
  • 0xC054:请求数据长度不正确(你构造的帧长度字段算错了)。
  • 0xC059:指令错误(命令码或子命令码不正确)。
  • 0xC05B:软元件地址超出范围(地址转换错误,或点数设置过大)。

我个人的经验是,90%的通信问题都出在地址转换和帧长度计算这两个环节。务必把这两个部分的代码逻辑和手册反复核对。

6. 超越DEMO:构建生产级通信库的思考

这个DEMO提供了一个坚实的起点。在此基础上,你可以根据项目需求进行扩展:

  1. 抽象与接口:定义一个IPlcCommunicator接口,包含Connect,Disconnect,ReadWord,WriteWord等方法。这样可以将三菱FX5U的具体实现与业务逻辑解耦,未来如果需要支持西门子、欧姆龙等其他品牌的PLC,只需实现新的接口即可。
  2. 配置化:将PLC的IP、端口、站号、超时时间、重试策略等提取到配置文件(如appsettings.json)中。
  3. 日志与监控:集成像Serilog或NLog这样的日志框架,详细记录每一次通信请求、响应、耗时和异常。这对于线上问题排查至关重要。
  4. 数据映射与缓存:对于需要频繁读取的工艺参数,可以实现一个缓存层,定时从PLC读取并更新到内存中,业务逻辑直接访问缓存,减少实时通信压力。
  5. 支持更多软元件和功能:扩展支持T、C(定时器、计数器)的当前值读取,支持Z、V(变址寄存器),甚至扩展文件寄存器(R)的访问。

最后,这个DEMO的源代码我会整理后分享出来。它可能不是功能最全的,但其中的连接管理、帧构造、响应解析和错误处理骨架,是经过实际项目验证的、稳定可靠的核心。希望它能帮你更快地打通C#与FX5U之间的通信桥梁,把精力更多地投入到上层业务逻辑的开发中。记住,工业通信,稳定性和可靠性永远排在第一位,优雅的代码和健壮的错误处理是达成这一目标的基石。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询