ARTICLE DETAIL

资讯详情

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

三菱Q系列PLC以太网通信:C#手写MC协议3E帧报文与采集

三菱Q系列PLC以太网通信:C#手写MC协议3E帧报文与采集 简介C#与三菱Q系列PLC通信实例源码由“达摩老生出品”定位为一份可参考的通信程序开发资源适用于工业自动化上位机监控等场景适合新手及有一定经验的开发人员快速理解C#与三菱Q系列PLC的通信实现方式解决实际项目中的上位机与PLC数据交互问题。压缩包内共154个文件整体大小约975KB内容以C#源文件、可执行程序、依赖动态库为主同时包含XML配置文件、文本说明文档、调试符号等辅助文件既便于直接运行体验也方便对照源码进行二次开发与研究。目前已有1371人学习使用。通过该资源读者可获得完整的项目工程与编译产物结合源码结构、配置文件和说明文档能够较快掌握通信建立、数据读写等关键环节对入门工业通信开发具有较好的借鉴价值。1. 三菱Q系列PLC的以太网通信先绕开组件直接看报文产线机台上Q系列PLC的网口灯闪个不停上位机那侧变量列表却全是红的。这种故障十个里有八个不是网络断了而是上位机发的报文格式和PLC对不上。三菱Q系列QJ71E71以太网模块最稳的路子不是先装MX Component商业组件而是直接用Socket按MC协议3E帧二进制格式收发报文。这套协议公开、字段固定抓包能对上、出错能定位而且代码量比想象中小得多。本文按“报文结构 → C#实现 → 循环采集UI卡顿 → 排错对照”的顺序给出一套可以直接落地到产线的实例源码级方案适合正在做C#上位机开发、但被三菱PLC通信磨过几天的工程师。2. 三菱MC协议3E帧二进制报文结构拆解2.1 为什么不用MX Component和NModbus4做C#上位机开发时搜到最多的两个方案是三菱官方MX Component和NModbus4。先给结论NModbus4是为Modbus TCP/RTU设计的对三菱MC协议无能为力现场常有人拿它去连Q系列连不上就在群里问问题不在代码在协议本身。MX Component能用但要装运行时、要授权部署到客户机器时经常被杀毒软件拦截版本和PLC固件还容易不对付。我一般只在时间极紧的演示项目里用它正式产线一律走原生Socket。三菱Q系列QJ71E71模块默认开放5000端口二进制和5555端口ASCII上位机作为TCP客户端直连即可。二进制格式报文短、无需校验和适合高频采集ASCII格式报文可读但长了近一倍还要算总和校验。本文全部以二进制格式为例。2.2 读D100共12个字的请求与响应报文逐字节拆开MC协议3E帧二进制格式的请求包分两段前面是固定的10字节报头后面是请求体。报头里除了长度字段其余基本都是常量。以一个最常见的操作——批量读取D100开始的12个字——为例完整请求报文是下面18个字节偏移值含义0-150 003E帧请求副头部200网络号单CPU填03FFPC号4-503 00请求目标模块IO编号以太网模块固定600请求目标模块站号7-80B 00请求体长度小端0x000B119-100A 00监视定时器0x000A1000ms11-1201 04批量读取指令13-1400 00子指令00 00字单位00 01位单位15-1764 00 A8软元件起始地址低2字节是地址值高字节A8是D类型码18-190C 00读取点数小端0x000C12这个报文里最容易出错的是长度字段和地址的字节序。0B 00是从监视定时器开始数到报文末尾的字节数即2字节监视定时器 2字节指令 2字节子指令 3字节地址 2字节点数 11字节小端存放。如果在报文里把长度算成整个TCP包的长度PLC会回C05F格式错误。响应报文格式对称副头部从50 00变成D0 00第7-8字节是响应体长度第9-10字节是监视定时器第11-12字节是结束代码00 00为正常之后才是数据区。读12个字数据区24字节整个响应体长度是2 2 24 28即1C 00。2.3 软元件类型码与地址换算X/Y的八进制坑MC协议里每种软元件有独立类型码批量读写时地址三字节中的高字节填类型码低两字节填“真地址”。下面是Q系列最常用的一批软元件类型码地址规则D0xA8十进制如D100填100M0x90十进制如M100填100X0x9C八进制输入号先转十进制再填Y0x9D八进制输出号先转十进制再填SM0x91特殊M如SM4000填4000SD0xA9特殊D如SD0填0W0xB4十进制链接寄存器R0xAF十进制文件寄存器ZR0xB0十进制文件寄存器ZRX和Y按八进制编号这是新手踩得最狠的地方。X37的下一个点是X40而不是X38三菱的X/Y输入输出号是八进制递增的真实地址里没有8和9两个数字。上位机软件里如果按十进制解析“X37”得到37直接封包发给PLC访问的就变成了X47结果要么读到别的点要么返回C050地址超出范围。提示X/Y地址必须按八进制转十进制再进报文。X37真地址是3*8731X40真地址是4*832。位软元件的读取返回数据是按位打包的。读M0到M15共16点响应数据区只有2字节M0在这2字节的最低位M7在第一个字节的最高位M8在第二个字节的最低位。解包时逐位判断而不是按字节偏移。3. C#实例源码手写MCProtocolClient完成批量读写3.1 最小可用的MCProtocolClient类用一个类封装连接、组包、收包和解析。核心思路是TcpClient建立连接后所有请求走同一个NetworkStream读响应时先读10字节固定报头拿到长度字段后再读满响应体这一步能避免TCP粘包半包问题。using System.Net.Sockets; using System.Text; public sealed class McProtocolClient : IDisposable { private readonly TcpClient _tcp; private readonly NetworkStream _ns; private static readonly byte[] RequestHeader { 0x50, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0x00 }; public McProtocolClient(string ip, int port 5000) { _tcp new TcpClient(); _tcp.NoDelay true; // 关闭Nagle降低小包延迟 _tcp.ReceiveTimeout 1000; _tcp.Connect(ip, port); _ns _tcp.GetStream(); } public short[] ReadDWords(int startAddress, int count) { var req BuildReadRequest(0xA8, startAddress, count, isBit: false); WriteRequest(req); var body ReadResponseBody(); var data body.AsSpan(4); // 跳过监视定时器2字节 结束代码2字节 var result new short[count]; for (int i 0; i count; i) { // D寄存器内部高字节在前不要反转 result[i] (short)((data[i * 2] 8) | data[i * 2 1]); } return result; } public bool[] ReadMBits(int startAddress, int count) { var req BuildReadRequest(0x90, startAddress, count, isBit: true); WriteRequest(req); var body ReadResponseBody(); var data body.AsSpan(4); var result new bool[count]; for (int i 0; i count; i) { result[i] (data[i / 8] (1 (i % 8))) ! 0; } return result; } public void WriteDWord(int startAddress, short value) { var req BuildReadRequest(0xA8, startAddress, 1, isBit: false); req[11] 0x14; // 读指令0x01 0x04改为写指令0x01 0x14 req.AddRange(new byte[] { (byte)(value 8), (byte)value }); WriteRequest(req); ReadResponseBody(); } private byte[] BuildReadRequest(byte devCode, int address, int count, bool isBit) { var body new Listbyte(); body.AddRange(new byte[] { 0x01, 0x04 }); // 批量读 body.AddRange(isBit ? new byte[] { 0x00, 0x01 } : new byte[] { 0x00, 0x00 }); body.Add((byte)address); // 地址低字节 body.Add((byte)(address 8)); // 地址高字节 body.Add(devCode); // 软元件类型码 body.Add((byte)count); // 点数低字节 body.Add((byte)(count 8)); // 点数高字节 var packet new Listbyte(RequestHeader); packet.Add((byte)body.Count); packet.Add(0x00); packet.AddRange(new byte[] { 0x0A, 0x00 }); // 监视定时器1000ms packet.AddRange(body); return packet.ToArray(); } private void WriteRequest(byte[] packet) { _ns.Write(packet, 0, packet.Length); _ns.Flush(); } private byte[] ReadResponseBody() { var header ReadExactly(10); int bodyLen (header[9] 8) | header[8]; // 长度字段小端 var body ReadExactly(bodyLen); ushort endCode (ushort)((body[2] 8) | body[3]); // 结束代码高字节在前 if (endCode ! 0) throw new McProtocolException($PLC返回错误码 0x{endCode:X4}); return body; } private byte[] ReadExactly(int count) { var buffer new byte[count]; int offset 0; while (offset count) { int read _ns.Read(buffer, offset, count - offset); if (read 0) throw new IOException(连接被PLC关闭); offset read; } return buffer; } public void Dispose() _tcp.Dispose(); } public sealed class McProtocolException : Exception { public McProtocolException(string message) : base(message) { } }这个类有三个关键点。第一ReadExactly必须循环读TCP一次Read不一定返回完整报文只读一次在数据量大或网络抖动时会少字节然后整个解析全乱。第二bodyLen是从响应字节流偏移8、9取的小端值它只代表监视定时器以后的长度不要再叠加报头的10字节。第三D寄存器内部按BigEndian存放(byte)(value 8)先发高字节解析时也按高字节在前组合这块和MODBUS的字节序习惯是反的混用就会数值对但含义错。3.2 读32位浮点数时的字序处理Q系列PLC里一个32位浮点数占用两个连续D寄存器比如D100和D101。三菱的内存排列是低地址存高字所以组Int32或浮点数时要先拼接D101 16 | D100而不是按地址顺序直接拼接。public float ReadFloat(int startAddress) { var words ReadDWords(startAddress, 2); int raw (words[1] 16) | (words[0] 0xFFFF); return BitConverter.Int32BitsToSingle(raw); }words[0]来自低地址D100words[1]来自高地址D101把高字放到高位再拼。这个顺序反了读出来的数会是一个离谱的大数或NaN而且单看报文很难察觉属于最隐蔽的坑。3.3 多台PLC连接管理与启动诊断现场通常不止一台Q系列。我一般用一个字典按IP缓存连接实例启动时并行读取每台PLC的SD0到SD26判断CPU状态是否在RUN、有没有报警再开始采集任务。var clients new Dictionarystring, McProtocolClient(); foreach (var ip in plcIps) { var plc new McProtocolClient(ip, 5000); clients[ip] plc; var diag plc.ReadDWords(0, 27); // SD0-SD26 Console.WriteLine(${ip} 状态字: {diag[0]:X4}); }SD0是CPU运行状态字发生严重故障时读出非零值。启动阶段先读诊断区比直接读工艺数据更容易暴露“连上了但协议不对劲”的问题。4. C#循环数据采集和UI刷新卡顿队列削峰加批量刷新4.1 UI卡顿的根因不是数据量大是两个错误叠加C#上位机里“循环数据采集和UI刷新卡顿”几乎成了固定话题。卡顿的根因通常是两个错误同时发生第一在UI线程里同步调用ReadDWords网络等待100ms界面就冻结100ms第二采到一次数据就Dispatcher.Invoke一次采样周期100ms时每秒刷新10次频率不算高但每次刷新都触发DataGrid全量重绑、Chart清空重绘UI线程忙于布局计算看起来就是整个窗口发飘。实际的解决路径不是提高采样频率而是把“采集”和“刷新”彻底拆开后台线程只负责采集入队UI线程用定时器批量取队列一次刷新只画新点。消息从高频变为低频UI线程每次干活的时间从几毫秒降到几百微秒。4.2 后台采集线程加ConcurrentQueue的采样服务下面这个采样服务类独立运行在后台任务里用ConcurrentQueue做采集数据和UI之间的缓冲Last属性保存最近一次值供UI侧直接取用。using System.Collections.Concurrent; using System.Diagnostics; public sealed class PlcSampleSource { private readonly McProtocolClient _plc; private readonly ConcurrentQueue(DateTime ts, short[] values) _queue new(); private readonly CancellationTokenSource _cts new(); private Task? _loop; public int SampleIntervalMs { get; set; } 200; public short[] Last { get; private set; } Array.Emptyshort(); public void Start() { var sw Stopwatch.StartNew(); _loop Task.Run(async () { while (!_cts.IsCancellationRequested) { long t0 sw.ElapsedMilliseconds; try { var values _plc.ReadDWords(100, 8); // 一次读8个字减少小报文 Last values; _queue.Enqueue((DateTime.Now, values)); } catch (Exception ex) { Console.WriteLine($采集异常: {ex.Message}); } long cost sw.ElapsedMilliseconds - t0; int wait SampleIntervalMs - (int)cost; if (wait 0) await Task.Delay(wait); // 按实际耗时补等待避免周期漂移 } }); } public List(DateTime, short[]) DrainAll() { var list new List(DateTime, short[])(); while (_queue.TryDequeue(out var item)) list.Add(item); return list; } public void Stop() _cts.Cancel(); }这段代码里值得注意的不是采集本身而是wait的计算。用SampleIntervalMs - cost决定实际等待时间可以让循环周期稳定在设定值附近。如果直接固定Task.Delay(200)单次通信耗时超过200ms时循环会越来越慢最后变成“卡顿式采集”群里问得最多的采样周期不准大多是这么来的。实际项目里如果cost经常接近甚至超过SampleIntervalMs不要调算法把采样周期放大两倍或者把读点数拆小。UI侧采用DispatcherTimer定时批量取队列var timer new System.Windows.Threading.DispatcherTimer { Interval TimeSpan.FromMilliseconds(150) }; timer.Tick (s, e) { var batch _source.DrainAll(); if (batch.Count 0) return; var latest batch[^1]; // 取最新一组 TxtD100.Text latest.values[0].ToString(); // Chart控件只追加latest对应的点不要重新设置整个DataSource }; timer.Start();UI的刷新周期设150ms到200ms之间人眼看不出延迟但UI线程的负载只有原来的几十分之一。DrainAll取出队列里所有缓存如果网络瞬断恢复后队列积压了几百条UI端也只取最后一次历史数据直接丢弃不会把图表撑爆。提示队列是无上限的断网期间会积压。实际使用时给ConcurrentQueue加个计数判断超过5000条就清空一次避免内存一直涨。4.3 采样周期与刷新周期的参数设置依据参数不是拍脑袋定的核心依据是“一次完整轮询的耗时”。单台PLC读8个字本地网络下典型耗时5到15ms10台PLC串行轮询一轮就是50到150ms。采样周期至少要大于一轮耗时的两倍否则采集永远跟不上。参数建议值说明单次读点数8128字点数越大单帧耗时略增但帧数减少综合效率更高采样周期200500ms大于一轮轮询耗时的2倍UI刷新周期150200ms每帧刷新6帧左右人眼足够顺滑接收超时1000ms低于500ms在PLC繁忙时会误报断线监视定时器10003000ms大于PLC扫描周期的10倍多台PLC串行采集时加一行耗时打印比任何分析都直观long cost sw.ElapsedMilliseconds - t0; Console.WriteLine($[{DateTime.Now:HH:mm:ss}] 本轮采集{clients.Count}台PLC耗时 {cost}ms);如果打印出来一轮耗时波动大优先检查网络交换机的端口协商和PLC以太网模块的负载而不是改代码。4.4 断线重连用指数退避别用固定100ms重试产线网络偶尔抖动上位机要在PLC重启或网线重插后自动恢复。用固定间隔狂重试是最差的做法PLC CPU的通信处理器会被重连风暴拖慢本来正常的通讯也变卡。我一般用指数退避第1次重试等1秒第2次2秒第3次4秒最大30秒封顶恢复后立即归零。int retry 1; while (true) { try { await ConnectWithRetryAsync(); // 内部循环退避 retry 1; break; } catch { await Task.Delay(Math.Min(30000, 1000 * retry)); retry * 2; } }Q系列以太网模块对半开连接的处理并不激进频繁重建连接会让模块的TCP控制块被占满表现为“能ping通但就是连不上”。保持长连接、失败时按退避重连是稳定的关键。5. 报文对照表与结束代码快速排错5.1 读D100共12个字的完整请求响应对照真机联调时最有效的办法是抓包后把原始字节和下面这张表逐字节对照。用网络调试助手或Wireshark抓到实际报文一眼就能看出问题出在报头、地址还是长度。方向完整报文说明请求50 00 00 FF FF 03 00 00 00 0B 00 0A 00 01 04 00 00 64 00 A8 0C 00读D10012个字响应D0 00 00 FF FF 03 00 00 00 1C 00 0A 00 00 00 24字节数据结束代码0000正常对照时重点看三处请求报头固定10字节里长度字段0B 00是否正确地址三字节64 00 A8中类型码A8是否放到了第三字节响应头部第7、8字节1C 00是否等于2 2 数据长度。这三处对上了协议就通了一大半。5.2 结束代码速查表PLC返回非零结束代码时不要只看“通信失败”按代码定位是地址问题还是报文格式问题。结束代码含义排查方向0000正常无C050软元件地址超出范围检查类型码和地址值X/Y是否忘了八进制转换C051点数超出上限或为0单次读/写点数是否超过960位读取是否超过256C055指令或子指令组合非法读写了不支持的软元件类型或位/字单位标志错C05F请求帧格式错误逐字节对照上表重点看长度字段和字节对齐C056网络路径异常PLC侧网络模块或CPU繁忙检查以太网模块指示灯C050是出现频率最高的一个十次里有八次是X/Y地址用成了十进制。C05F则意味着上位机组包时把哪个字段多算了一字节用上表对照很快能定位。5.3 用GX Simulator2做本地回环测试以及ASCII格式的校验没有真机时我一般先在开发机上装GX Works2的GX Simulator2把仿真器的以太网虚拟通信打开程序里目标IP填127.0.0.1、端口5000直接跑上面的代码。模拟器对报文格式的校验和真机一致地址、长度、指令错误在本地就能暴露。唯一差别是模拟器对时序不敏感真机上要注意别把监视定时器设得太短。如果调试时对面PLC配置的是ASCII格式端口5555注意它的报文是把每个二进制字节转成两个十六进制字符末尾追加2字节总和校验。总和校验的算法是从第一个字符到校验位之前所有ASCII字节相加取低8位再转成两个大写十六进制字符。C#里可以这样算static string AsciiChecksum(byte[] asciiFrameWithoutChecksum) { int sum 0; foreach (var b in asciiFrameWithoutChecksum) sum b; return (sum 0xFF).ToString(X2); }算的时候不要把回车换行算进去很多调试助手自动追加的\r\n会造成校验永远失败。二进制格式不需要算校验这也是以太网场景下优先选择二进制格式的原因。最后强调一个实用习惯上位机报错时先用网络调试助手发一条测试报文收到响应后拿真实字节和5.1节的表对照不要直接改代码重试报文对齐一次后面所有问题都会变得很清晰。本文还有配套的精品资源点击获取
返回列表