三菱FX3U以太网通信:C#实现MC协议3E帧报文解析与数据采集 去年做产线数据采集项目时现场清一色的三菱FX3U上位机又定死了用C#。一开始我理所当然地准备走Modbus TCP结果一查才发现FX3U本体根本没有网口必须加装以太网模块而模块原生支持的通信协议是三菱私有的MC协议。被逼着啃协议规范之后发现这事其实没有想象中那么玄乎只是网上资料东一块西一块很多人卡在报文格式和联调上。这篇文章把我调通的过程完整写出来从为什么选MC协议到二进制3E帧的逐字节拆解再到C#客户端如何组包、收发、解析最后是实测中踩过的几个坑——字节序、TCP拆包粘包、X/Y八进制地址这类问题。做设备联网、MES对接或者只想用Windows上位机读FX3U数据的同学可以直接照着抄。1. 为什么最终选MC协议而不是Modbus或编程口很多人第一次接触三菱FX3U的以太网第一反应都是Modbus TCP。理由很简单Modbus是公开协议资料多随便找一个库就能用。但现实是FX3U要跑Modbus TCP并不是开箱即用它需要硬件模块或者网关做协议转换而且FX3U-ENET这类模块的核心协议其实是MC协议Modbus往往是附加支持甚至根本不支持。当时我手头的配置是FX3U-ENET-ADP加挂在FX3U主机上。这个模块的默认工作方式就是MC协议上位机通过TCP直接和它通信。MC协议全称MELSEC Communication Protocol是三菱PLC对外通信的“母语”覆盖所有软元件D寄存器、M继电器、X输入、Y输出都直接可读可写不需要PLC程序里专门写通信指令来配合。相比串口编程口协议MC over TCP走以太网速度快、距离远、不用插拔线缆对产线数据采集来说是最合适的一条路。做个简单对比帮助选型方案硬件要求协议开放性速度实现成本适用场景编程口串口协议FX3U自带编程口半公开慢中单机调试、小数据量Modbus TCP取决于模块型号公开中低有协议转换模块时MC协议以太网FX3U-ENET/ADP半公开需查手册快中产线批量采集、MES对接所以我最后采用的是标准三层架构C#上位机通过Socket连接到FX3U-ENET-ADP的IP和端口使用MC协议二进制3E帧格式收发报文完成D字寄存器、M位元件的批量读写。整个过程不依赖第三方控件一个TcpClient加上自己组报文的逻辑就够。2. MC协议3E帧的报文结构从帧头到数据域MC协议分ASCII帧和二进制帧两种格式。ASCII帧每个字节都用可打印字符表示十六进制数比如指令0x0401要发送字符“0104”肉眼可读但报文长度翻倍二进制帧直接用原始字节报文紧凑、解析快实际项目中我强烈建议用二进制帧。下面说的都是二进制3E帧格式。2.1 请求报文的固定头一个标准的3E帧请求报文固定部分是这样字段长度取值说明帧头4字节D0 00 00 00二进制3E帧的固定标识网络号1字节通常填0PC编号1字节通常填FF表示不指定IO编号2字节以太网模块的IO编号FX3U-ENET-ADP常见是03 FF站号1字节通常填0请求数据长度2字节从监视定时器开始到数据结束的字节数小端存储监视定时器2字节单位250ms比如10 00表示0x0010即4秒指令2字节小端存储比如字批量读取0x0401传01 04子指令2字节通常00 00请求数据变长根据指令不同存起始软元件号、点数或具体数据请求数据长度这个字段特别容易算错。它统计的是从监视定时器开始到报文末尾的字节数。以字软元件批量读取为例监视定时器2字节加指令2字节加子指令2字节加起始软元件号4字节加点数2字节一共12字节所以这个字段填0x000C小端存储就是0C 00。指令代码是MC协议的核心用的时候别记混。下表是几个我常用的功能手册指令值二进制发送字节位软元件批量读取0x010101 01字软元件批量读取0x040101 04位软元件批量写入0x020101 02字软元件批量写入0x050101 05字软元件随机读取0x140101 142.2 一个真实的读取D0到D9请求帧我写代码的时候习惯先把报文逐字节列出来再做组包逻辑。假设要读取D0到D9共10个字的D寄存器请求帧是这样D0 00 00 00 -- 帧头 00 -- 网络号 FF -- PC编号 03 FF -- IO编号 00 -- 站号 0C 00 -- 请求数据长度12 10 00 -- 监视定时器16*250ms4秒 01 04 -- 指令字软元件批量读取 00 00 -- 子指令 00 00 0A 00 -- 起始软元件号D0的编码 0A 00 -- 数量10注意起始软元件号这里D0转换后的32位整数值是0x000A0000小端存储就是00 00 0A 00。如果读D100就是0x000A0064字节序变成64 00 0A 00。高16位是软元件代码低16位是地址号这个规则后面解析地址时会用到。响应报文的结构比请求简单前面是相同的固定头9字节然后2字节响应数据长度然后是结束代码2字节加数据区。响应数据长度指的是结束代码到数据区末尾的总字节数。结束代码为00 00表示正常非零就是PLC返回的错误码。3. C#客户端从Socket连接到报文组包的完整实现3.1 连接管理我习惯把协议封装成一个独立的类不依赖具体业务。连接部分很简单底层是TcpClient设置好接收超时避免PLC没响应时线程一直卡住。using System.Net.Sockets; public class Fx3uMcClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private readonly string _ip; private readonly int _port; public Fx3uMcClient(string ip, int port 5000) { _ip ip; _port port; } public void Connect() { _tcpClient new TcpClient(); _tcpClient.Connect(_ip, _port); _tcpClient.ReceiveTimeout 3000; _tcpClient.SendTimeout 3000; _stream _tcpClient.GetStream(); } public void Dispose() { _stream?.Dispose(); _tcpClient?.Close(); } }端口默认是5000但要以实际模块参数为准。FX3U-ENET-ADP在GX Works2里配置IP时能看到端口号不同固件版本可能有差异动手前先去模块参数里确认一下。3.2 组包以字软元件批量读取为例核心组包逻辑就是按照上一节的字段顺序逐个写入。软元件地址的解析单独拆一个方法因为D、R、W这些字软元件和X、Y、M这些位软元件的代码不一样后面再说。private byte[] BuildReadWordsRequest(string device, ushort count) { (int code, int addr) ResolveWordDevice(device); byte[] frame new byte[23]; frame[0] 0xD0; frame[1] 0x00; frame[2] 0x00; frame[3] 0x00; frame[4] 0x00; // 网络号 frame[5] 0xFF; // PC编号 frame[6] 0x03; frame[7] 0xFF; // IO编号 frame[8] 0x00; // 站号 frame[9] 0x0C; frame[10] 0x00; // 请求数据长度12 frame[11] 0x10; frame[12] 0x00; // 监视定时器4秒 frame[13] 0x01; frame[14] 0x04; // 指令字批量读取 frame[15] 0x00; frame[16] 0x00; // 子指令 uint start ((uint)code 16) | (ushort)addr; byte[] startBytes BitConverter.GetBytes(start); frame[17] startBytes[0]; frame[18] startBytes[1]; frame[19] startBytes[2]; frame[20] startBytes[3]; byte[] countBytes BitConverter.GetBytes(count); frame[21] countBytes[0]; frame[22] countBytes[1]; return frame; }注意这里所有多字节字段我都用BitConverter生成C#在x86/x64平台上默认就是小端刚好和MC协议一致省了很多手工倒字节的麻烦。3.3 收包处理半包和粘包收到响应后固定头只有11个字节其中最后2字节是响应数据长度。问题是TCP是流协议一次Read可能只读到几个字节所以必须写一个“读满指定字节数”的方法循环直到数据凑齐。private byte[] ReadExactly(int length) { byte[] buffer new byte[length]; int offset 0; while (offset length) { int read _stream.Read(buffer, offset, length - offset); if (read 0) throw new IOException(连接被关闭); offset read; } return buffer; } private byte[] SendAndReceive(byte[] request) { _stream.Write(request, 0, request.Length); _stream.Flush(); byte[] header ReadExactly(11); int dataLength header[9] | (header[10] 8); byte[] body ReadExactly(dataLength); ushort endCode (ushort)(body[0] | (body[1] 8)); if (endCode ! 0) { throw new McProtocolException($PLC返回错误码: 0x{endCode:X4}); } byte[] data new byte[dataLength - 2]; Array.Copy(body, 2, data, 0, data.Length); return data; }因为整个通信模式是一问一答其实不太会出现粘包真正要防的是半包。ReadExactly这个循环就足够了。3.4 读取D寄存器并转成ushort数组读取完成后数据区每2字节对应一个D寄存器小端存储。D寄存器可以当作有符号short也可以当作无符号ushort业务上经常需要按无符号处理所以我返回ushort数组需要有符号时自己再转换。public ushort[] ReadWords(string device, ushort count) { byte[] request BuildReadWordsRequest(device, count); byte[] data SendAndReceive(request); ushort[] result new ushort[count]; for (int i 0; i count; i) { result[i] (ushort)(data[i * 2] | (data[i * 2 1] 8)); } return result; }写字的组包逻辑方向相反指令换成01 05请求数据长度是12加2乘以点数报文尾部追加要写入的字节。这里提醒一句批量写字单帧最大960字超出要拆帧别一个巨大的ushort数组直接塞进去。4. 地址解析尤其是X/Y八进制这个巨坑D寄存器的解析很简单字母部分是D对应软元件代码0x0A数字部分就是十进制地址直接转成int。M继电器对应0x90也是同样的方式。但X和Y不一样它们的编号是八进制的。4.1 软元件代码映射MC协议里每个软元件类型都对应一个16位代码软元件类型软元件代码说明X0x009C输入继电器八进制编号Y0x009D输出继电器八进制编号M0x0090内部继电器L0x0092锁存继电器F0x0093报警器S0x0098状态继电器D0x000A数据寄存器R0x000F文件寄存器W0x000B链接寄存器X/Y的代码是0x9C和0x9D地址号按八进制算。什么意思呢FX3U的X输入是从X0到X7然后是X10到X17没有X8和X9。如果我要读X10这个点报文里的地址号不是十进制的10而是八进制10对应的十进制8。C#里没有直接的八进制字符串解析我一般这样处理private (int code, int addr) ResolveBitDevice(string device) { var match Regex.Match(device, ^([A-Za-z])([0-9])$); if (!match.Success) throw new FormatException($无法解析软元件地址: {device}); string kind match.Groups[1].Value.ToUpper(); string numPart match.Groups[2].Value; switch (kind) { case X: return (0x009C, Convert.ToInt32(numPart, 8)); case Y: return (0x009D, Convert.ToInt32(numPart, 8)); case M: return (0x0090, int.Parse(numPart)); case L: return (0x0092, int.Parse(numPart)); default: throw new FormatException($不支持的位软元件类型: {kind}); } }Convert.ToInt32(string, 8)这个方法确实存在专门用来解析八进制字符串。读X10时传进去的就是地址号8这个转换务必做个单元测试我见过太多人在这上面翻车读出来的数据永远对不上。4.2 位软元件批量读取的响应格式读M0到M7共8个点请求帧指令是01 01其他字段和字读取一致起始软元件号用位软元件代码。响应数据区每个位占1字节00表示断开01表示闭合。也就是说读8个点会返回8个字节不是打包成1个字节。写位也一样每个点1字节要写通就给01写断就给00。批量读位和写位的点数上限通常是256点超出要分帧。这里有个实用的点M8000是FX3U的特殊继电器常闭时表示PLC运行中读一下M8000就能确认CPU状态比先从业务寄存器下手稳得多。5. 联调中实打实踩过的几个坑5.1 32位浮点数在D寄存器里的存储顺序项目里读温度、压力这类模拟量很多是32位浮点数占两个D寄存器。上位机读回来两个ushort后组合方式直接决定数值对不对。以FX3U为例D100和D101组成一个浮点数时D100是低位字D101是高位字。float ReadFloat(ushort low, ushort high) { uint raw ((uint)high 16) | low; return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); }我在现场遇到过一次奇怪的数据A区域读出来正常B区域全乱排查半天发现是PLC程序里写数据时用了不同的组合方式。稳妥起见联调时先用PLC侧写一个已知的浮点数比如1.0然后上位机读回来验证字节顺序。0x3F800000是1.0的IEEE754表示低字是0x0000高字是0x3F80如果读到的组合结果对得上说明顺序和预期一致。5.2 二进制帧里的小端和ASCII帧的字符顺序要分清二进制帧用BitConverter天然小端很容易。但有些人参考网上的ASCII帧例子看着“0104”这个字符串就以为二进制帧也是01 04结果倒腾层级混乱。记住一句话在二进制3E帧里多字节数值一律低位在前。指令0x0401发01 04监视定时器0x0010发10 00起始软元件号0x000A0064发64 00 0A 00。5.3 指令代码用错调试像捉迷藏我一开始把字批量读取当成了0x0104报文发出去PLC返回错误码排查半天才发现把“位批量读0x0101”和“字批量读0x0401”搞混了。手册里写的是0x0401但发送时是小端01 04。如果你用的是随机读取那类指令更要注意0x1401发送字节是01 14和批量读字差一个字节。5.4 IO编号和站号真不是随便填的框架里我填了03 FF作为IO编号这是FX3U-ENET-ADP比较常见的默认值。但如果你连的是Q系列PLC的内置以太网口或者现场改了模块参数这个值可能就不对了。联调遇到非0结束码时先别急着怀疑协议理解错了去模块参数里确认实际的IO编号和站号再回来改报文固定头。5.5 监视定时器单位是250ms监视定时器字段在很多例程里直接填10 00表示0x0010也就是16乘以250ms等于4秒。如果你的PLC程序扫描周期特别长或者一次读写数据量很大4秒可能不够适当放大。这个值只在请求里有PLC侧觉得超时会返回错误。5.6 以太网模块连接数量有限FX3U-ENET系列模块允许的TCP并发连接数是有限的一般几个连接就到顶了。调试阶段开的连接没关干净后面程序连不上是常态。所以在C#代码里单例复用连接不要每次读数据都新建连接。断线重连逻辑也简单每次操作前检查TcpClient的连接状态发现断开重新Connect即可。6. 工程化落地性能、稳定性与调试习惯协议跑通只是第一步真正放到产线上长期运行还要处理几个工程问题。通信频率不要太高。FX3U-ENET-ADP本质上是个轻量级模块我见过有人把采集周期压到50毫秒一次结果模块CPU负载高偶尔响应超时。一般数据采集100到500毫秒一个周期足够了MES对接更是秒级就行。数据要批量读不要逐点读。比如采集50个模拟量一次批量读100个字比循环发100次请求不知道快到哪里去了。线程安全方面因为TcpClient不能同时多线程收发我习惯在类内部放一个SemaphoreSlim所有读写操作先锁住。这样上层业务多线程调用时不会把报文写串。代码结构大概是private readonly SemaphoreSlim _lock new SemaphoreSlim(1, 1); public async Taskushort[] ReadWordsAsync(string device, ushort count) { await _lock.WaitAsync(); try { byte[] request BuildReadWordsRequest(device, count); byte[] data SendAndReceive(request); // 解析并返回 } finally { _lock.Release(); } }日志里一定记录原始hex报文。我踩过的坑超过一半是凭日志里的报文对比定位的。通信类程序最怕黑盒每次请求响应都打一行hex日志配合Wireshark抓包问题基本都能暴露出来。最后说一点个人习惯我调这类私有协议第一步永远是固定写死一组报文去试不封装任何业务方法先用原始socket发hex并打印响应确认PLC端通了这个命令字。链路通了再封装成类、加地址解析、加异步逻辑。这样能把变量控制在最小范围出了问题一眼就知道是协议层还是业务层。后面如果你想扩展FX5U或者Q系列PLC这套报文结构大体也是相通的只是软元件代码和地址类型会有些差异把地址解析部分抽象成配置项就行。