
简介在工业自动化领域上位机与PLC通信是实现设备监控与数据采集的核心技术。其原理基于TCP/IP网络通过特定的应用层协议如三菱MC协议进行数据交换实现计算机与工业控制器之间的可靠对话。这项技术的价值在于打通了信息层与控制层为设备状态可视化、生产数据记录和报警触发提供了基础。在C#上位机开发中通过Socket编程实现MC协议通信是连接三菱FX系列PLC的常见应用场景。本文聚焦于MC协议3E帧的解析与FX3U软元件寻址详细阐述了如何构建请求帧、处理响应数据并针对工业现场常见的网络不稳定问题提供了连接保活、断线重连等稳定性优化方案是构建可靠数据监控系统的关键实践。1. 项目缘起当C#上位机遇上三菱FX3U最近在做一个设备数据监控的项目客户现场的主力控制器是一台老当益壮的三菱FX3U系列PLC。需求很明确需要开发一个运行在工控机上的C#上位机软件实时读取PLC内部M辅助继电器的状态用于在电脑屏幕上显示设备运行状态、触发报警和记录生产数据。这个需求在工业自动化领域非常典型就是所谓的“上位机与PLC通信”。三菱FX3U本身自带编程口和422/485接口但这次客户为了布线方便和未来扩展已经在PLC上扩展了一块以太网模块比如FX3U-ENET-ADP或者FX3U-ENET-L。这就意味着我们不能再走传统的串口协议比如编程口协议、无顺序协议而是要走网络通信。在众多工业以太网协议中三菱自家的MC协议MELSEC Communication Protocol是一个绕不开的选择。它是一种基于TCP/IP的应用层协议专门用于三菱MELSEC系列PLC包括FX, Q, L系列等与外部设备如HMI、SCADA、定制上位机进行数据交换。对于C#开发者来说这意味着我们需要在.NET环境中通过Socket编程按照MC协议的帧格式去组包和解包从而实现与FX3U的“对话”。这个项目的核心挑战不在于C#的网络编程本身而在于对MC协议细节的精确把握以及处理工业现场网络通信中各种稳定性问题。下面我就把这次从零搭建通信链路、成功读取M区值的过程以及中间踩过的坑和总结的经验完整地分享出来。2. 通信基石理解三菱MC协议与FX3U寻址在动手写代码之前必须先把通信的“语言”——MC协议搞清楚。我们用的是MC协议3E帧二进制码这是目前最常用、效率较高的一种格式。它基于TCP客户端/服务器模型我们的C#程序作为TCP客户端PLC的以太网模块作为TCP服务器。一个完整的MC协议通信过程就是客户端向服务器的指定端口默认为5002或5003通常用5002发送一个请求帧PLC处理后再回复一个响应帧。2.1 MC协议3E帧结构拆解请求帧和响应帧都有固定的结构。对于我们“读取位设备M区”这个目标请求帧主要包含以下几个部分副头部固定为50 00代表这是MC协议。网络编号通常为00FX系列PLC固定为0。PLC编号通常为FF广播或根据网络设置。请求目标模块I/O编号通常为FF 03CPU模块。请求目标模块站号通常为00。请求数据长度后面所有数据的字节长度。CPU监视定时器设置通信超时时间例如10 00表示16*10ms160ms。指令04 00代表“位设备批量读取”。子指令00 00。起始软元件地址这是最关键也是最容易出错的部分。它指定了从哪个M点开始读。例如要读取M0这里的值不是简单的00 00。MC协议采用了一种特殊的编码方式需要将软元件号转换成“软元件代码偏移地址”。对于FX3U的M区其软元件代码是90。假设我们要读取M0计算过程如下软元件代码90M0的偏移量0在帧中我们需要按90 00 00 00四个字节来表示。即先放软元件代码90后面三个字节放偏移地址00 00 00小端序低位在前。读取点数用两个字节表示。例如要连续读取10个M点M0-M9就是0A 00。把上面这些部分按顺序拼接成一个字节数组就是我们的请求帧。PLC收到后如果正常会返回一个响应帧。响应帧的前面部分是对请求的回应包含结束代码00 00表示正常后面跟着的就是读取到的数据。每个M点的状态ON/OFF用一个位bit表示8个点凑成一个字节。2.2 FX3U软元件地址计算实战很多人在这里迷糊我举个例子。假设我们要读取M100开始的5个点。确定软元件代码M区代码是90(十六进制)。计算偏移地址M100的编号是100。在MC协议中这个编号需要直接作为偏移量。100的十六进制是64。构建地址字段地址字段共4字节。第一字节是软元件代码90。后三字节是偏移地址需要转换成小端序的3字节格式。数字100 (64) 用3字节小端序表示就是64 00 00。所以完整的起始地址字段是90 64 00 00构建读取点数字段读取5个点即05 00(小端序)。那么在请求帧中对应“起始软元件地址”的部分我们就填入90 64 00 00。这一点和读取D寄存器时用A8代码不同务必区分。注意三菱不同系列的PLCQ/L/FX甚至不同批次的FX3U固件对于某些特殊M点如M8000以上的地址映射可能有细微差别。最稳妥的方法是先在GX Works2的“软元件批量监视”功能里确认该点的实际地址或者查阅对应以太网模块的详细手册。3. 环境搭建与Socket通信层实现理解了协议我们就可以开始搭建C#项目了。我使用的是Visual Studio 2022.NET 6框架兼容性好也可用.NET Framework 4.7.2。3.1 创建项目与核心类设计首先创建一个C#控制台应用或WinForms/WPF应用。我将通信核心功能封装在一个单独的类MitsubishiMcProtocol.cs中这样便于复用和管理。这个类的核心成员包括TcpClient _tcpClient: 用于TCP连接。NetworkStream _stream: 网络流用于读写数据。string _ipAddress和int _port: PLC的IP和端口。int _receiveTimeout: 接收超时时间。构造函数中传入IP和端口进行初始化。public class MitsubishiMcProtocol { private TcpClient _tcpClient; private NetworkStream _stream; private string _ipAddress; private int _port; private int _receiveTimeout 2000; // 默认2秒超时 public MitsubishiMcProtocol(string ip, int port 5002) { _ipAddress ip; _port port; } }3.2 建立与断开连接连接方法需要处理异常因为工业现场网络可能不稳定。public bool Connect() { try { _tcpClient new TcpClient(); // 设置连接超时避免长时间阻塞 var connectTask _tcpClient.ConnectAsync(_ipAddress, _port); if (connectTask.Wait(TimeSpan.FromMilliseconds(3000))) // 等待3秒 { if (_tcpClient.Connected) { _stream _tcpClient.GetStream(); _stream.ReadTimeout _receiveTimeout; Console.WriteLine($成功连接到PLC {_ipAddress}:{_port}); return true; } } else { Console.WriteLine(连接PLC超时。); // 手动取消并清理 _tcpClient?.Close(); } } catch (SocketException ex) { Console.WriteLine($网络连接错误: {ex.SocketErrorCode} - {ex.Message}); } catch (Exception ex) { Console.WriteLine($连接发生异常: {ex.Message}); } return false; } public void Disconnect() { try { _stream?.Close(); _tcpClient?.Close(); Console.WriteLine(与PLC连接已断开。); } catch { } }实操心得一连接超时设置直接使用_tcpClient.Connect(ip, port)在无法连接时会阻塞很长时间。我采用ConnectAsync配合Wait和超时时间的方式可以更友好地控制连接等待时长给用户及时的反馈。同时一定要在UI线程外进行连接操作防止界面卡死。3.3 核心通信方法发送与接收帧这是最底层、最核心的方法负责把组装好的字节数组发出去并把收到的字节数组读回来。private byte[] SendAndReceive(byte[] requestData) { if (_stream null || !_tcpClient.Connected) { throw new InvalidOperationException(未连接到PLC。); } // 发送请求帧 _stream.Write(requestData, 0, requestData.Length); // Console.WriteLine($已发送: {BitConverter.ToString(requestData)}); // 接收响应帧 // 先读取固定长度的头部11字节以确定后续数据长度 byte[] headerBuffer new byte[11]; int bytesRead ReadBytesFromStream(headerBuffer, 0, 11); if (bytesRead ! 11) { throw new IOException($读取响应头失败期望11字节实际收到{bytesRead}字节。); } // 从响应头第9-10字节小端序获取后续数据长度 int extraDataLength BitConverter.ToInt16(headerBuffer, 9); // 总响应长度 11字节头 后续数据长度 byte[] fullResponse new byte[11 extraDataLength]; Array.Copy(headerBuffer, 0, fullResponse, 0, 11); // 读取剩余的数据部分 if (extraDataLength 0) { bytesRead ReadBytesFromStream(fullResponse, 11, extraDataLength); if (bytesRead ! extraDataLength) { throw new IOException($读取响应数据失败期望{extraDataLength}字节实际收到{bytesRead}字节。); } } // Console.WriteLine($已接收: {BitConverter.ToString(fullResponse)}); return fullResponse; } // 一个可靠的从流中读取指定字节数的方法 private int ReadBytesFromStream(byte[] buffer, int offset, int count) { int totalRead 0; while (totalRead count) { int read _stream.Read(buffer, offset totalRead, count - totalRead); if (read 0) { throw new IOException(网络流已关闭。); } totalRead read; } return totalRead; }实操心得二粘包处理与长度解析工业TCP通信要特别注意“粘包”问题。MC协议的响应帧是定长头部可变长度数据体的结构。绝对不能简单地用_stream.Read读到缓冲区满为止那样可能会把下一次的响应帧也读进来。正确做法是先读取固定11字节的头部然后从头部中解析出后续数据体的长度第9、10字节再精确读取对应长度的数据体。这是稳定通信的关键。4. 协议组装与M区读取功能实现有了可靠的通信底层接下来就是按照第2章分析的协议格式组装具体的“读取M区”请求帧。4.1 构建读取位设备的请求帧我将其封装成一个独立的方法BuildReadMRequest。private byte[] BuildReadMRequest(ushort startAddress, ushort pointCount) { // 1. 计算起始地址字段 byte deviceCode 0x90; // M软元件代码 // 将起始地址转换为3字节小端序数组 byte[] addressBytes BitConverter.GetBytes(startAddress); // BitConverter.GetBytes 得到的是2字节ushort我们需要3字节且小端序。 // 例如 startAddress 100 (0x0064), addressBytes [0x64, 0x00] // 我们需要的是 [0x64, 0x00, 0x00] 作为后三字节。 byte[] fullAddress new byte[4]; fullAddress[0] deviceCode; fullAddress[1] addressBytes[0]; // 低位 fullAddress[2] addressBytes[1]; // 高位 fullAddress[3] 0x00; // 第三字节补0 // 2. 计算请求数据长度 (指令以后的所有数据字节数) // 指令(4) 子指令(2) 地址(4) 点数(2) 12 字节 ushort requestDataLength 12; byte[] lengthBytes BitConverter.GetBytes(requestDataLength); // 3. 构建完整帧 using (MemoryStream ms new MemoryStream()) using (BinaryWriter bw new BinaryWriter(ms)) { // 副头部 bw.Write((ushort)0x5000); // 网络号PLC号目标模块IO目标站号 bw.Write((byte)0x00); // 网络号 bw.Write((byte)0xFF); // PLC号 bw.Write((ushort)0x03FF); // 目标模块IO (0x03FF) bw.Write((byte)0x00); // 目标站号 // 请求数据长度 bw.Write(lengthBytes); // 小端序写入 // CPU监视定时器 bw.Write((ushort)0x0010); // 160ms // 指令位设备批量读取 bw.Write((ushort)0x0004); // 子指令 bw.Write((ushort)0x0000); // 起始软元件地址 bw.Write(fullAddress); // 读取点数 bw.Write(BitConverter.GetBytes(pointCount)); return ms.ToArray(); } }4.2 解析响应帧并提取M点状态发送请求后我们需要解析PLC返回的响应帧提取出M点的ON/OFF状态。public bool[] ReadMBatch(ushort startAddress, ushort pointCount) { byte[] request BuildReadMRequest(startAddress, pointCount); byte[] response SendAndReceive(request); // 检查结束代码 (响应帧第9-10字节) ushort endCode BitConverter.ToUInt16(response, 9); if (endCode ! 0x0000) { throw new Exception($PLC返回错误结束代码: 0x{endCode:X4}。请检查地址和点数。); } // 数据部分从第11字节开始 int dataStartIndex 11; // 计算返回的数据字节数。每个字节包含8个M点的状态。 int byteCount (pointCount 7) / 8; // 向上取整 bool[] results new bool[pointCount]; for (int i 0; i pointCount; i) { int byteIndex i / 8; int bitIndex i % 8; if (dataStartIndex byteIndex response.Length) { byte dataByte response[dataStartIndex byteIndex]; // 判断特定位是否为1 (通常1表示ON) results[i] ((dataByte bitIndex) 0x01) 0x01; } else { // 理论上不会发生因为前面检查过长度 results[i] false; } } return results; }4.3 提供一个简单的读取单个M点的方法为了方便使用可以再封装一个更简单的方法。public bool ReadMSingle(ushort address) { bool[] results ReadMBatch(address, 1); return results[0]; }5. 从Demo到实战完整应用与稳定性打磨把上面的类封装好就可以写一个简单的控制台程序进行测试了。class Program { static void Main(string[] args) { string plcIp 192.168.1.100; // 替换为你的PLC实际IP MitsubishiMcProtocol mc new MitsubishiMcProtocol(plcIp, 5002); try { if (mc.Connect()) { // 示例1读取M0到M9共10个点 Console.WriteLine(读取 M0-M9:); bool[] m0To9 mc.ReadMBatch(0, 10); for (int i 0; i m0To9.Length; i) { Console.WriteLine($ M{i} {m0To9[i]}); } // 示例2读取单个点M100 Console.WriteLine(\n读取 M100:); bool m100State mc.ReadMSingle(100); Console.WriteLine($ M100 {m100State}); // 示例3读取M200开始的16个点用于监控一组设备状态 Console.WriteLine(\n读取 M200-M215 (16个点):); bool[] groupStatus mc.ReadMBatch(200, 16); // 可以在这里将状态数组与具体的设备名称映射显示 } } catch (Exception ex) { Console.WriteLine($操作失败: {ex.Message}); } finally { mc.Disconnect(); } Console.ReadLine(); } }如果一切顺利运行程序后就能在控制台看到PLC内部M点的状态了。但这仅仅是开始要把这个功能用到实际的工业上位机软件中还需要解决很多稳定性问题。5.1 通信超时与重试机制工业网络环境复杂偶尔丢一两个包是常事。我们的通信层必须有重试机制。public bool[] ReadMBatchWithRetry(ushort startAddress, ushort pointCount, int maxRetries 3) { int retryCount 0; while (retryCount maxRetries) { try { return ReadMBatch(startAddress, pointCount); } catch (IOException ex) // 网络异常 { retryCount; Console.WriteLine($第{retryCount}次读取失败 ({ex.Message}){retryCount maxRetries ? 正在重试... : 已达最大重试次数。}); if (retryCount maxRetries) { throw new Exception($读取M{startAddress}失败已达最大重试次数。, ex); } Thread.Sleep(100 * retryCount); // 退避等待 } catch (Exception ex) // 协议错误等非网络异常不重试 { throw; } } return new bool[pointCount]; // 理论上不会执行到这里 }5.2 连接保活与断线重连对于需要长时间运行的监控软件不能假设连接永远不断。需要实现一个后台心跳或定时读取机制并在检测到断线时自动重连。public class PlcDataService { private MitsubishiMcProtocol _protocol; private System.Timers.Timer _heartbeatTimer; private bool _isConnected false; public PlcDataService(string ip) { _protocol new MitsubishiMcProtocol(ip); _heartbeatTimer new System.Timers.Timer(5000); // 5秒一次心跳 _heartbeatTimer.Elapsed HeartbeatElapsed; _heartbeatTimer.AutoReset true; } public void Start() { TryReconnect(); _heartbeatTimer.Start(); } private void HeartbeatElapsed(object sender, System.Timers.ElapsedEventArgs e) { try { // 尝试读取一个固定的、无害的M点比如M8000常ON点作为心跳 // 注意FX3U中M8000是运行监视常ON触点读取它是安全的。 bool status _protocol.ReadMSingle(8000); _isConnected true; // Console.WriteLine(心跳检测连接正常); } catch { _isConnected false; Console.WriteLine(心跳检测连接异常尝试重连...); TryReconnect(); } } private void TryReconnect() { for (int i 0; i 3; i) { try { _protocol.Disconnect(); // 先清理旧连接 if (_protocol.Connect()) { _isConnected true; Console.WriteLine(断线重连成功。); return; } } catch { } Thread.Sleep(2000); } Console.WriteLine(断线重连失败请检查网络和PLC状态。); } }5.3 性能优化批量读取与异步操作如果需要监控上百个M点逐点读取效率极低网络压力也大。务必使用批量读取ReadMBatch。在UI程序中为了不阻塞界面所有PLC通信操作都应该放在后台线程或使用异步方法。// 在WinForms或WPF中使用异步方法避免UI卡顿 public async Taskbool[] ReadMBatchAsync(ushort startAddress, ushort pointCount, CancellationToken cancellationToken) { return await Task.Run(() { // 这里可以加入取消令牌检查 if (cancellationToken.IsCancellationRequested) { return new bool[pointCount]; } return ReadMBatchWithRetry(startAddress, pointCount); }, cancellationToken); }然后在UI按钮事件中调用private async void btnReadM_Click(object sender, EventArgs e) { btnReadM.Enabled false; try { bool[] status await _plcService.ReadMBatchAsync(0, 100, _cts.Token); // 更新UI显示状态... } catch (OperationCanceledException) { MessageBox.Show(读取操作被取消。); } catch (Exception ex) { MessageBox.Show($读取失败: {ex.Message}); } finally { btnReadM.Enabled true; } }6. 深度排错当通信失败时该怎么办即使代码看起来完美第一次调试也大概率会失败。下面是我总结的排查链路按照这个顺序查基本能解决99%的问题。6.1 第一步基础网络连通性检查这是最根本的一步。在运行C#程序的电脑上打开命令提示符执行ping 192.168.1.100替换为你的PLC IP。如果ping不通说明物理层或网络层有问题。检查网线是否插好交换机/路由器是否通电检查IP设置确保电脑和PLC的IP地址在同一网段且子网掩码一致。例如PLC是192.168.1.100电脑可以是192.168.1.50子网掩码都是255.255.255.0。关闭防火墙临时关闭电脑和网络设备上的防火墙排除拦截可能。6.2 第二步确认PLC以太网模块设置通过GX Works2软件连接PLC可以通过USB编程线检查以太网模块的参数设置。IP地址确认是否和我们程序中写的一致。端口号确认MC协议使用的端口号通常是5002。协议确认以太网模块的“打开方式”中是否允许MC协议通信。有些模块默认只允许“MELSOFT连接”即GX Works2编程通信需要手动勾选“TCP”或“MC协议”。站号设置通常保持默认即可。6.3 第三步使用网络抓包工具分析这是定位协议层问题最强大的手段。在电脑上安装Wireshark开始抓包然后运行你的C#程序。过滤器在Wireshark中输入tcp.port 5002过滤出与PLC通信的数据包。观察TCP握手看是否有完整的TCP三次握手SYN, SYN-ACK, ACK。如果没有说明连接被拒绝或路由有问题。分析应用层数据找到你的程序发出的第一个数据包通常是发送请求帧。右键 - “追踪流” - “TCP流”。这时你可以看到原始的十六进制通信数据。对比请求帧将Wireshark中显示的十六进制数据与你程序中BuildReadMRequest方法生成的字节数组进行逐字节对比。重点检查“起始软元件地址”字段第21-24字节左右是否正确。一个字节不对PLC就不会响应。查看响应帧如果PLC回复了看响应帧的“结束代码”第9-10字节。如果不是00 00就根据三菱手册查询错误码含义。常见的C050表示软元件地址错误C054表示访问点数超限。踩坑实录我曾遇到一个诡异的问题程序发出的请求帧和手册例子一模一样但PLC就是不回复。用Wireshark抓包对比后发现我程序里“请求数据长度”字段计算错了导致整个帧长度不对。PLC的协议栈可能直接丢弃了格式错误的帧连错误代码都不回。所以抓包是验证你发出的数据是否“标准”的唯一金标准。6.4 第四步检查代码中的常见陷阱字节序问题MC协议中多字节数据如长度、点数、地址偏移量都采用小端序Little-Endian即低位字节在前。C#的BitConverter.GetBytes()方法在小端序的Windows系统上默认就是小端序所以通常没问题。但心里一定要有这个意识。软元件代码错误读取M区用0x90读取D寄存器用0xA8读取X输入点用0x9C读取Y输出点用0x9D。用错了代码PLC会返回地址错误。点数超限FX3U单次读取M点的数量是有限制的根据使用的指令和模块不同可能是512点或更少。一次不要请求太多点可以分批读取。线程阻塞在UI线程中同步调用ReadMBatch如果网络超时会导致程序界面“卡死”数秒。务必使用异步调用。6.5 第五步利用PLC侧诊断功能FX3U的以太网模块上有LED指示灯可以直观判断状态LINK/ACT灯常亮表示物理链路正常闪烁表示有数据收发。如果不亮检查网线。OPEN灯常亮表示TCP连接已建立。如果你的程序连接后这个灯不亮说明TCP连接没成功回到第一步和第二步检查。7. 功能扩展与进阶思考实现了基础的M点读取这个通信框架就可以作为基石扩展出更多功能。7.1 写入M点写入和读取类似但指令码不同14 00表示位设备批量写入。你需要构建的请求帧除了地址和点数还要附带要写入的数据。数据部分也是每个位对应一个M点ON为1OFF为0。务必谨慎使用写入功能特别是写入M8000以上的系统特殊继电器可能导致PLC停止运行。7.2 读取D寄存器读取D寄存器字设备的软元件代码是0xA8。指令码为01 00字设备批量读取。响应帧中的数据部分每两个字节代表一个D寄存器的值0-65535。解析时需要用BitConverter.ToUInt16进行转换。7.3 封装通用读写方法可以设计更通用的方法通过枚举来指定软元件类型。public enum DeviceType { M, D, X, Y } public object ReadDeviceBatch(DeviceType type, ushort startAddress, ushort pointCount) { byte deviceCode GetDeviceCode(type); // 根据类型返回 0x90, 0xA8等 // ... 后续组装逻辑类似根据是位设备还是字设备返回bool[]或ushort[] }7.4 考虑使用成熟的通信库如果项目通信需求复杂或者想更快上手可以考虑使用一些成熟的开源或商业的三菱PLC通信库例如Mitsubishi.MC.Protocol开源或S7NetPlus的兄弟库如果有。这些库封装了协议细节提供了更友好的API。但自己实现一遍的最大好处是出了问题你知道从哪里下手去查而不是对着黑盒库一筹莫展。这次从零实现C#通过MC协议读取FX3U M区的过程让我对工业协议底层的严谨性和现场调试的复杂性有了更深的认识。代码本身的逻辑并不复杂难的是对细节的把握和对异常情况的处理。最深的体会就是一定要用抓包工具验证它比任何日志都可靠一定要加超时和重试工业现场没有“绝对稳定”的网络。把这个基础打牢了后续无论是扩展功能还是移植到其他品牌PLC都会顺畅很多。本文还有配套的精品资源点击获取