ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#实现三菱MC协议3E帧读写:从报文结构到上位机实战

C#实现三菱MC协议3E帧读写:从报文结构到上位机实战 简介这是一款面向三菱PLC开发与调试人员的C#版MC协议通信示例与调试工具聚焦单地址读出/写入的协议实现适合希望快速理解MC协议报文构造、Socket通信与PLC联调的初学者。压缩包共40个文件体积仅113KB包含C#源码工程csproj/sln/cs、界面资源resx/resources与编译输出exe/pdb目录结构整体精简可直接打开工程对照学习。已有1112人浏览学习。通过该资源读者可以掌握MC协议中数据请求与应答报文的拆装方法了解三菱PLC寄存器地址表示及I/O映射并借助可直接调试的exe体验从连接、读取到写入的完整流程为后续开发更复杂的PLC上位机工具打下基础。1. 为什么用 C# 写三菱 MC 协议客户端而不是直接买现成组件手边一台三菱 Q 系列 PLC上位机要读 D100 里当前温度你第一个想到的方案是什么组态王加驱动、Modbus TCP 网关、还是直接买商业通信库这些都能跑通但遇到“就一个地址读不对”“扫描周期和读写频率对不上”的时候你手里没有任何能逐字节排查的底牌。SanLingMC 这个项目就是干这个的用 C# 的 Socket 把三菱 MC 协议的 3E 帧二进制模式完整地构建、发送、解析了一遍演示了单地址读取和单地址写入两条最核心路径。项目本身没有引用任何 PLC 通信组件全部是Socket、MemoryStream和字节操作这意味着协议里每个字段都裸露在代码里适合想搞懂报文结构再写正式上位机的开发者也适合拿去做商业库的黑盒比对。需要说明的是MC 协议在三菱各系列 CPU 上的帧格式基本一致但地址编码和 CPU 型号绑定下文会专门拆开讲。2. 三菱 MC 协议 3E 帧的字节布局与 D/M 设备寻址2.1 从“副头部 0xD000”说起二进制帧的最小骨架三菱 MC 协议在以太网上的主流形态是 3E 帧官方手册里同时给了 ASCII 帧和二进制帧两种编码方式。SanLingMC 用的是二进制模式因为它每个字段定长解析逻辑简单报文长度也短。一个完整的 3E 二进制请求帧从前往后依次是副头部2 字节、请求数据长度2 字节、监视定时器2 字节、命令码2 字节、子命令2 字节、起始地址设备代码 2 字节 地址编号 3 字节、点数2 字节。其中副头部固定为0xD000用来告诉 PLC“这是一条请求帧”。这里有个容易误读的字段请求数据长度。它统计的是从“监视定时器”开始到帧末尾的字节数不包含副头部和长度字段本身。比如一条“读 D100读 1 个字”的请求监视定时器(2) 命令码(2) 子命令(2) 设备代码(2) 地址(3) 点数(2) 13 字节换算成十六进制就是0x000D写入长度字段。很多初学 MC 协议的人在抓包时发现 PLC 不回包不是命令写错而是长度字段多算或少算了 4 个字节。2.1.1 监视定时器的单位与取值监视定时器用来告诉 PLC“上位机允许这条指令执行多久”单位是 250ms。最常见的写法是0x0010也就是 16 × 250ms 4 秒。批量读写大量寄存器、或者 PLC 扫描周期偏长时这个值要适当调大如果对实时性要求高可以缩小到0x0002500ms。注意它是 2 字节的大端整数直接写0x0010没问题但有些封装库会把这个字段省略掉导致 PLC 按默认值执行跨系列移植时行为不一致。2.2 设备代码与起始地址的编码规则MC 协议里每个软元件寄存器/继电器都有对应的设备代码设备代码占 2 字节地址编号占 3 字节。地址编号不是简单的十进制转十六进制填进去不同设备规则不同设备设备代码地址编号说明示例D数据寄存器0x00A8十进制编号直接转十六进制D100 → 0x000064M内部继电器0x0090十进制编号直接转十六进制M100 → 0x000064X输入继电器0x009C十六进制编号按位地址换算X0F → 0x00000FY输出继电器0x009D十六进制编号按位地址换算Y10 → 0x000010W链接寄存器0x00B4十进制编号直接转十六进制W10 → 0x00000AD 和 M 的地址编号规则相同但设备代码不同这一点在拼接报文时极易出错——地址对、设备代码错PLC 返回的响应帧里依然会显示正常但读出来的是另一片区域的数据。一个稳妥的做法是把设备代码和地址编号的换算封装成一个方法用switch区分字设备和位设备而不是在业务代码里直接拼常量。2.2.1 字设备与位设备的点数单位点数2 字节的语义随设备类型变化D、W、R 这类字设备的点数单位是“字”M、X、Y 这类位设备的点数单位是“位”。读 M0 连续 16 点就是连续读 16 个位读 D0 连续 16 点则是连续读 16 个字。很多现成库的坑在于上层接口统一用“点数”表达用户拿读 D 的逻辑去读 M一次性读了 32 个字回来数据全错位。SanLingMC 把读和写分别实现就是为了让这两种语义在代码层面分清楚。2.3 大小端与地址偏移比报文格式更容易踩的暗坑3E 帧里绝大多数多字节字段都是大端序高字节在前包括设备代码、地址编号、点数和监视定时器。但响应帧里携带的数据字段其字节序由 PLC 侧的“数据字节序”参数决定不同 CPU 型号出厂值不一致有的默认小端有的默认大端且可以在 PLC 参数里修改。这就是为什么同一套代码连 Q 系列正常、连 FX5U 数据全反的原因。常见的工程做法不是去猜 PLC 侧的设置而是让上位机提供“数据字节序”可配置项默认用大端读取遇到数据反了再切换。另外X/Y 设备在部分 CPU 型号的 3E 帧中需要加上“软元件编号初始偏移”具体偏移量以 GX Works 监控到的实际地址为准。更保险的做法是先用手持调试或 GX Works 软元件监控确认目标地址的实际数值再用抓包工具对比你构建的帧里的地址字段两者一致再写死到程序里。3. SanLingMC 项目骨架单地址读取与写入的 C# 实现3.1 工程结构与连接层从项目文件可以看出来SanLingMC 是一个 WinForms 工程TestForm.cs、TestForm.Designer.cs、TestForm.resx不是控制台程序。这意味着作者把协议逻辑和界面操作分开了Program.cs负责启动TestForm.cs承载按钮和文本框协议构建的代码则可以直接复用到任何 C# 上位机项目里。连接层最简单可靠的写法是直接用TcpClient不引入额外的通信框架public sealed class PlcMcClient : IDisposable { private TcpClient? _tcp; private NetworkStream? _stream; private readonly int _timeoutMs; public PlcMcClient(int timeoutMs 3000) { _timeoutMs timeoutMs; } public void Connect(string ip, int port) { _tcp new TcpClient(); var task _tcp.ConnectAsync(ip, port); if (!task.Wait(_timeoutMs)) { _tcp.Close(); throw new TimeoutException($连接 {ip}:{port} 超时); } _stream _tcp.GetStream(); _stream.ReadTimeout _timeoutMs; _stream.WriteTimeout _timeoutMs; } public void Dispose() { _stream?.Dispose(); _tcp?.Close(); } }这里有几个值得注意的参数决策。ConnectAsync配合Wait(timeout)是为了让界面层能捕获“PLC 网线没插好”这类场景而不是卡死 UI 线程ReadTimeout和WriteTimeout分别控制读写阻塞时间默认 3 秒对单地址读写足够。如果是在 WinForms 按钮事件里直接调用建议把 Connect 和后续读写都放进async Task方法里避免界面假死——这是用 C# 写上位机最容易犯的初级错误。3.2 构建读取请求帧把字段按顺序写进字节流读取请求的核心代码是构建 13 字节的请求体再用BinaryWriter写入MemoryStream最后补充副头部和长度字段。下面这段代码对应的是 3E 帧二进制模式的读取指令命令码0x0401private byte[] BuildReadFrame(byte[] deviceCode, int address, int points) { using var ms new MemoryStream(); using var bw new BinaryWriter(ms); // 副头部固定 0xD000大端 bw.Write((byte)0xD0); bw.Write((byte)0x00); // 请求数据长度从监视定时器开始算先占位 bw.Write((byte)0x00); bw.Write((byte)0x00); // 监视定时器0x0010 4 秒 bw.Write((byte)0x00); bw.Write((byte)0x10); // 命令码读取固定 0x0401写入为 0x1401 bw.Write((byte)0x04); bw.Write((byte)0x01); // 子命令固定 0x0000 bw.Write((byte)0x00); bw.Write((byte)0x00); // 设备代码2 字节例如 D 设备为 0x00A8 bw.Write(deviceCode[0]); bw.Write(deviceCode[1]); // 地址编号3 字节大端 bw.Write((byte)((address 16) 0xFF)); bw.Write((byte)((address 8) 0xFF)); bw.Write((byte)(address 0xFF)); // 点数2 字节大端 bw.Write((byte)((points 8) 0xFF)); bw.Write((byte)(points 0xFF)); var body ms.ToArray(); // 回填请求数据长度 body.Length - 4去掉副头部和长度字段自身 int len body.Length - 4; body[2] (byte)((len 8) 0xFF); body[3] (byte)(len 0xFF); return body; }逻辑说明代码先把除长度字段外的所有字节写入MemoryStream最后用body.Length - 4回填长度避免手工数偏移量。设备代码以 2 字节数组形式传入是为了让调用方可以复用一套构建逻辑比如PlcDevice.D、PlcDevice.M各自定义好byte[]。地址编号用移位拆成 3 字节大端这里最容易错的是忘记对address做范围校验比如 M 地址超过 0xFFFFF 会被静默截断。进阶一点的做法在方法开头加上if (address 0xFFFFF) throw new ArgumentOutOfRangeException(...)宁可抛出异常也不要发一条截断地址的帧给 PLC。3.3 解析响应帧返回码与数据提取响应帧的结构比请求帧多了结束代码副头部(2) 应答数据长度(2) 命令码(2) 子命令(2) 结束代码(2) 数据(N)。结束代码为0x0000表示成功非 0 则是错误码。解析时先检查副头部和长度再验证返回码最后才取数据private static ReadOnlySpanbyte ParseReadResponse(byte[] frame) { if (frame.Length 10) throw new InvalidDataException($响应帧长度不足: {frame.Length}); // 副头部校验 if (frame[0] ! 0xD0 || frame[1] ! 0x00) throw new InvalidDataException(副头部错误不是有效的 3E 帧); // 结束代码偏移 8-9大端 int endCode (frame[8] 8) | frame[9]; if (endCode ! 0) throw new InvalidOperationException($PLC 返回错误码: 0x{endCode:X4}); // 数据区从偏移 10 开始 return frame.AsSpan(10); }参数说明ReadOnlySpanbyte作为返回值是 C# 7.2 以后可用的高性能写法它在 WinForms 项目里同样适用避免了为解析多分配一次byte[]拷贝。校验副头部这一步不能省——串口转以太网网关、或者 PLC 端配置了 ASCII 模式时返回帧的第一字节就不是0xD0提前失败比等到数据全乱再排查要快得多。拿到数据区后按 2 字节一组解析短整型注意每个字内部是大端序short value (short)((data[0] 8) | data[1]);如果是连续多个字循环取data[i * 2]和data[i * 2 1]即可。这里不需要BitConverter.ToInt16因为BitConverter依赖运行平台的大小端序Windows x86/x64 都是小端直接用它会把大端数据解析成颠倒的值——这是 C# 与 MC 协议对接中最典型的字节序错误。3.4 单字写入命令码换成 0x1401数据接在末尾写入单字和读取的帧结构几乎一样只有两点不同命令码从0x0401变成0x1401帧末尾追加要写入的数据2 字节大端。写入的响应帧只有结束代码没有数据区解析逻辑比读取更短public void WriteWord(byte[] deviceCode, int address, short value) { using var ms new MemoryStream(); using var bw new BinaryWriter(ms); bw.Write((byte)0xD0); bw.Write((byte)0x00); bw.Write((byte)0x00); // 长度占位 bw.Write((byte)0x00); bw.Write((byte)0x00); // 监视定时器 0x0010 bw.Write((byte)0x10); bw.Write((byte)0x14); // 命令码写入 bw.Write((byte)0x01); bw.Write((byte)0x00); // 子命令 bw.Write((byte)0x00); bw.Write(deviceCode[0]); bw.Write(deviceCode[1]); bw.Write((byte)0x00); // 地址 3 字节 bw.Write((byte)0x00); bw.Write((byte)0x64); // 假设写入 D100 bw.Write((byte)0x00); // 点数 1 bw.Write((byte)0x01); bw.Write((byte)((value 8) 0xFF)); // 数据高字节 bw.Write((byte)(value 0xFF)); // 数据低字节 var frame ms.ToArray(); int len frame.Length - 4; frame[2] (byte)((len 8) 0xFF); frame[3] (byte)(len 0xFF); SendAndReceive(frame); }注意写入命令的“请求数据长度”比读取多了 2 字节因为数据字段计入长度。很多从读取代码复制改造的工程第一个 bug 就出在这里——长度没更新PLC 直接返回错误码。代码里写死了 D100 的地址0x000064实际工程中应该把地址作为参数传入这里是为了让逻辑更直白。4. 协议边界与异常处置超时、粘包与 PLC 返回码4.1 为什么“收到数据就解析”会踩粘包/半包TCP 是字节流协议MC 协议则是报文协议两者之间没有天然的边界。一次Read调用返回的数据可能只是响应帧的前半段也可能一次返回了两条响应帧的拼接。SanLingMC 的示例项目因为每次请求后立即同步解析遇到慢 PLC 时概率性出现解析异常这不是协议错误而是接收逻辑没有做报文完整性判断。正确的边界判断依据是响应帧里的“应答数据长度”字段。先读固定 4 字节的头部副头部 2 字节 长度 2 字节解析出长度 N 后再继续读取 N 字节凑齐整个响应帧。这里 N 的含义和请求帧相同从命令码到数据区末尾的总字节数所以一个完整响应帧的总长度是 4头部 N。用NetworkStream.Read循环读直到累积字节数达标private byte[] ReadFullFrame(NetworkStream stream) { var header new byte[4]; int read 0; while (read header.Length) { int n stream.Read(header, read, header.Length - read); if (n 0) throw new IOException(连接已关闭); read n; } if (header[0] ! 0xD0 || header[1] ! 0x00) throw new InvalidDataException(非法的帧头部); int bodyLen (header[2] 8) | header[3]; var body new byte[bodyLen]; read 0; while (read bodyLen) { int n stream.Read(body, read, bodyLen - read); if (n 0) throw new IOException(连接已关闭); read n; } return header.Concat(body).ToArray(); }这里的header.Concat(body)在每次请求时都分配新数组高频读写100ms 周期以上时建议用ArrayPoolbyte重用缓冲区避免 GC 压力。Industrial 场景比办公软件更在意“长时间运行不卡顿”把接收缓冲区和发送缓冲区都做成池化能显著降低 8 小时连续采集时的内存抖动。另外如果项目要对同一 TCP 连接做多个并发请求需要给每个请求加递增序号否则响应无法对应到请求——MC 协议本身没有请求序号字段工程上一般用“请求-响应互斥”方式发一条等一条收到后再发下一条。4.2 结束代码PLC 返回的非 0 值到底在说什么解析响应帧时结束代码是最重要的诊断入口。SanLingMC 项目里只检查了是否为 0实际排错时应该把非 0 值打印出来。常见的结束代码含义如下结束代码含义常见原因0x0000正常完成无需处理0xC051命令码错误请求帧命令码写错如把写入 0x1401 写成 0x04010xC052软元件指定范围错误地址编号超范围或点数过多越过设备末尾0xC053请求数据长度错误长度字段少算/多算常见于手工拼帧0xC05B软元件数据错误写入数据长度与点数不匹配0xC05D监视定时器超时PLC 侧扫描周期过长或请求点数过大0xC060通信失败以太网模块与 CPU 间异常偶发重试可恢复遇到0xC052时优先检查设备代码和地址编号是否匹配D100 在 3E 帧里地址编号是0x000064但在某些型号中 D 设备实际范围只有 0~9999越过上限时就报这个错。遇到0xC05D则是把监视定时器调大或者把批量读拆成小批次比如一次最多读 64 个字。4.2.1 用返回码做故障分级不是所有错误码都需要中断业务流程。0xC060这类瞬时通信故障可以在上位机里做两次重试重试间隔建议 50ms 起步0xC051、0xC052这类协议错误重试一百次也一样应该直接抛异常并记录请求帧的 HEX 字符串方便离线分析报文。把错误码枚举和重试策略封装到PlcMcClient内部调用方不需要知道协议细节这是 C# 上位机与 PLC 调试工具在工程结构上最大的分界线——调试工具可以弹 MessageBox正式上位机必须返回结构化错误。4.3 写频率与 PLC 扫描周期太快的上位机才是故障源很多连不上、读写异常的问题不在协议在节奏。三菱 PLC 的 CPU 扫描周期通常在 1ms 到 20ms 之间而 Windows 上位机的线程调度粒度约 15ms两者并不同步。如果上位机用一个while(true) Thread.Sleep(10)去循环写 M100PLC 侧可能收到连续两条指令之间间隔过近的帧触发通信模块的流量保护。常见做法是在写入指令间加最小间隔单地址写建议 10ms 到 20ms如果工艺要求更高的写频率优先考虑连续写多个连续地址用一条指令替代多条指令而不是把写频率硬顶上去。超时参数也要匹配 PLC 的负载空载时 3 秒超时很充裕但批量读 100 个 D 寄存器、而 PLC 又同时跑着定位程序时请求可能排队 500ms 以上。我的建议是连接超时用 3 秒读写超时放宽到 5 秒并在超时后关闭连接重建——TCP 连接处于半开状态时重发请求大概率还是失败重建连接是最快恢复到可用状态的路径。5. 把示例变成调试工具的一个关键技巧批量读取按设备分组归纳地址SanLingMC 的示例只覆盖单地址读写实际调试时最常用的却是“一次读一片连续地址”并刷新到表格里。把单地址读扩展成批量读核心不是改协议帧而是做地址的归纳分组把 UI 上用户输入的零散地址比如 D0、D1、D2、D100按设备类型和连续性归并为若干段连续区间每段区间发一条读取请求把响应数据填回对应的行。假设 UI 层拿到了这样一组地址D0, D1, D2, D100, M0, M1归纳后的请求列表应该是两条读 D0 连续 3 点读 D100 连续 1 点以及读 M0 连续 2 点。这样原本 6 次请求变成 3 次对 PLC 通信模块的压力小一半。我在实际改造 SanLingMC 时会加一个AddressRange结构体内含设备代码、起始地址、点数和数据缓冲区然后按“设备类型 地址递增”规则排序归并。归并的粒度要设置一个阈值相邻地址间隔小于等于 16 个时合并成一段超过 16 个就断开避免为读一个 D0 和一个 D100 而跨整个地址区间扫描。这样既减少了请求次数又不会让单帧点数过大触发 4.2 节说的0xC05D超时错误。UI 刷新也是同样的思路——收到响应帧后不要逐行Text赋值而是一次性填充DataTable再让 DataGridView 整体刷新实测在 100 个寄存器、200ms 刷新周期下也能保持界面流畅。这个反向的“地址归纳”逻辑同样适用于写入需要同时写多个不连续地址时先按设备类型分组组内再按地址排序合并成连续区间写完后把所有区间的帧按顺序发出再统一轮询返回码。SanLingMC 这类示例给到的最有价值的东西其实不是那几行读写代码而是“用最小可运行程序验证协议细节”的思路——真到了现场排故把注意力放在地址区间归纳和字节序配置这两个旋钮上往往比翻手册更快。最后给你留一个可验证的改动点把TestForm里的地址输入框改成支持D100:10这种“起始地址:点数”的语法再配合上面的区间归纳逻辑你手里的这个 C# MC 协议调试程序就比大多数现成 Demo 更接近一个能交付给维护部门的工具了。本文还有配套的精品资源点击获取
返回列表