简介:这份资源面向需要在 Windows 平台下用 Qt 与三菱 PLC 建立通讯的工控开发者与上位机软件学习者,基于 Qt 5.12.9、VS2019 与 MX Component S4.19 环境,演示配置完成后实现通讯连接、提示通讯是否成功并读取显示 PLC 型号的完整流程,属于可直接编译运行的入门级实战工程。压缩包共 70 个文件,约 47.52MB,以 cpp、h 源码与 Qt 界面文件为核心,辅以 vcxproj、sln 等 VS 工程配置、obj、pdb 等编译中间产物及日志文件,工程结构与依赖关系一目了然。目前已有 2094 人学习下载,说明该方案在同类需求中具备一定参考价值。读者可从中获得 MX Component 的调用方式、Qt 与 VS 混合工程的搭建思路,以及通讯状态判断与 PLC 型号读取的具体实现,便于在此基础上扩展读写寄存器等实际功能,并借助日志与工程配置排查环境问题。
1. 工控上位机通讯的硬骨头:为什么 QT + VS2019 接三菱 PLC 值得单独拆一遍
产线上跑着三菱 FX5U 或 Q 系列 PLC,上位机却还停在一套老旧的组态软件里,界面改不动、数据拿不出来、想接个 MES 或者本地数据库就得求人——这是不少做设备集成的朋友真实撞上的墙。QT 加 VS2019 这套组合,恰好能把界面自由度、C++ 性能和 Windows 下的调试体验捏在一起,再通过三菱的 MC 协议(MELSEC Communication Protocol)直接跟 PLC 的软元件打交道,读写 D、M、X、Y 这些寄存器不再依赖中间层。这份资源就是围绕这条链路做的可运行工程:QT 负责界面与线程,VS2019 负责编译调试,底层走 TCP 上的 MC 协议帧。适合已经会点 C++、被组态软件卡过脖子、想自己掌控数据流的工控上位机开发者。下面按「协议怎么立住 → 工程怎么搭 → 帧怎么拼 → 坑在哪」一路拆下去。
2. MC 协议先立住:三菱 PLC 通讯的帧结构与选型理由
动手写代码之前,得先搞清楚上位机跟三菱 PLC 到底在聊什么。三菱的以太网通讯主流走 MC 协议,它本质是一套二进制请求/响应帧:上位机发一帧「我要读 D100 开始的 10 个寄存器」,PLC 回一帧「这是你要的 20 个字节」。协议本身不复杂,复杂的是帧头格式、软元件代码、字节序这几处细节,任何一处对不上,PLC 要么不回、要么回错误码,调试时就是黑匣子。这一章把帧结构和选型理由讲透,后面写代码才有依据。
2.1 为什么选 MC 协议而不是别的路子
三菱 PLC 的以太网通讯常见有几条路:MC 协议、SLMP(其实是在 MC 基础上扩展的)、以及各家封装的 OPC Server。选 MC 协议的直接理由是它够底层、够轻——不需要装任何中间件,一个 QTcpSocket 就能发帧收帧,部署到客户机器上不用额外授权。OPC 那套虽然「标准」,但多一层服务、多一份配置,现场机器一出问题排查链路就长了。SLMP 兼容 MC 的 3E 帧格式,很多 FX5U 默认就支持,所以工程里按 3E 帧来实现,兼容面最广。
另一个理由是可控性。MC 协议的读写是批量式的,一次请求能拿连续一片软元件,比逐个读效率高一个量级。做数据采集时,把要读的地址按连续性归并成几批,一轮采集可能就三五个请求,这对刷新频率要求高的场景很关键。代价是你得自己管帧的拼装和解析,没有现成 API 帮你兜底——这正是这份工程要解决的部分。
2.2 3E 帧的二进制结构拆解
3E 帧分请求帧和响应帧,请求帧的头部固定 21 字节(二进制模式),结构如下:
| 字段 | 长度(字节) | 典型值 | 含义 |
|---|---|---|---|
| 副头部 | 2 | 0x50 0x00 | 3E 帧固定标识 |
| 网络号 | 1 | 0x00 | 通常 0 |
| PLC 号 | 1 | 0xFF | 站号,直连一般 0xFF |
| 请求目标模块 IO 号 | 2 | 0xFF 0x03 | 访问 CPU |
| 请求目标模块站号 | 1 | 0x00 | 多级站时用 |
| 请求数据长度 | 2 | 变长 | 从「指令」到帧尾的字节数 |
| 监视定时器 | 2 | 0x10 0x00 | 单位 250ms,0x0010 即 4 秒 |
| 指令 | 2 | 0x01 0x04 | 0x0401 批量读,0x1401 批量写 |
| 子指令 | 2 | 0x00 0x00 | 字单位读为 0x0000 |
| 起始软元件号 | 3 | 变长 | 低字节在前 |
| 软元件代码 | 1 | 见下表 | D=0xA8, M=0x90 等 |
| 软元件点数 | 2 | 变长 | 读写的数量 |
软元件代码是容易翻车的地方,常用的几个记牢:D 寄存器 0xA8、M 内部继电器 0x90、X 输入 0x9C、Y 输出 0x9D、W 链接寄存器 0xB4。注意 X/Y 是位软元件,按位读时子指令要用 0x0001,返回的是打包后的位;按字读 X/Y 在某些型号上不支持,得先确认 PLC 型号手册。
响应帧头部同样是 21 字节,副头部变成 0xD0 0x00,末尾多一个结束代码(2 字节),0x0000 表示成功,非零就是错误码。响应数据紧跟在结束代码之后,字单位读时每点 2 字节,字节序是低字节在前——这一点跟很多人的直觉相反,解析时如果按大端读,数值会整个错位,是血泪经验级别的坑。
2.3 字节序与软元件地址的换算规则
地址换算有两层。第一层是软元件号本身:D100 就是 100,直接转成 3 字节小端塞进帧里。第二层是位软元件的字地址换算,比如想按字读 M 区,M100 落在第 100/16=6 个字、第 4 位,实际起始字地址是 6,读出来再按位拆。X/Y 的八进制问题更隐蔽:三菱的 X/Y 地址是八进制编址,X10 不是十进制的 10,而是八进制的 8,换算时得先按八进制解析再转十进制,否则读出来的永远是隔壁那个点。
字节序方面,帧头里的多字节字段(数据长度、定时器、点数)都是小端,响应数据里的寄存器值也是小端。工程里统一封装两个函数appendLE16、appendLE24来拼帧,解析时对称地写readLE16,避免到处手写移位导致某处漏了字节。这套约定立住之后,后面所有读写都复用,出错面就收窄到协议本身而不是拼帧代码。
3. VS2019 + QT 工程搭建:从环境到第一个能编译的通讯类
协议清楚了,接下来是把工程立起来。VS2019 配 QT 的坑主要集中在版本匹配和调试器上,这一章按「装什么 → 怎么配 → 类怎么分层」走一遍,目标是编译出一个能发帧、能收帧、界面能显示结果的最小可运行版本。
3.1 QT 版本与 VS2019 的匹配选择
QT 在 Windows 下跟 VS 配合,走的是 Qt VS Tools 插件这条路。版本上,QT 5.15 的 msvc2019 64 位套件跟 VS2019 是官方匹配的,装完 QT 后在 VS 里装 Qt VS Tools 扩展,指向 QT 安装目录下的 msvc2019_64,新建项目时就能选 Qt Widgets Application 模板。QT6 也能用,但部分老工程依赖的模块在 QT6 里挪了位置,如果手头有存量代码,5.15 更省心。
调试器要单独确认:VS2019 默认装的是它自带的调试引擎,QT 这边只要保证 Qt VS Tools 里配置的 QT 版本是 msvc 编译的(不是 mingw),断点就能正常进。曾经见过有人装了 mingw 版 QT 配 VS,编译能过但一调试就崩,本质是两套运行时混用,这种翻车完全可以在选套件那一步避免。
3.2 工程目录与通讯类的分层设计
工程不要把所有逻辑堆进 MainWindow。合理的分层是三层:McProtocol负责纯协议拼帧解析、不依赖 QT 网络;PlcClient继承 QObject、持有 QTcpSocket、负责连接管理与收发调度;界面层只调PlcClient的读写接口、拿信号槽更新 UI。这样协议层可以脱离网络单独写单元测试,网络层可以脱离界面单独压测,排查问题时能快速定位是帧错了还是连接断了。
目录结构大致这样:
PlcDemo/ McProtocol/ # 纯 C++,帧拼装与解析 mc_frame.h mc_frame.cpp PlcClient/ # QT 网络封装 plc_client.h plc_client.cpp UI/ mainwindow.h mainwindow.cpp main.cppMcProtocol不 include 任何 QT 头,输入输出都是std::vector<uint8_t>,这样它能在任何环境复用。PlcClient里把 socket 的 readyRead 信号接到一个缓冲区累积函数,因为 TCP 是流式的,一帧可能分几次到达,必须自己按「先读头部拿数据长度、再等够整帧」的方式切包,不能假设一次 readyRead 就是一整帧——这是新手最容易忽略、现场偶发丢包时最抓狂的点。
3.3 连接管理与超时重连的最小实现
连接部分要处理三件事:建连、超时、断线重连。建连用QTcpSocket::connectToHost,连上后发一帧读测试地址验证链路。超时用 QTimer 做请求级看门狗:每发一帧启动定时器,收到对应响应就停,超时未回就标记该请求失败并触发重连。重连不要用死循环,用 QTimer 单次触发延迟 3 秒再试,避免 PLC 重启期间疯狂刷连接把端口占满。
// PlcClient 连接与重连的核心逻辑(节选) void PlcClient::connectToPlc(const QString &ip, quint16 port) { m_ip = ip; m_port = port; m_socket->connectToHost(ip, port); } void PlcClient::onSocketError(QAbstractSocket::SocketError) { // 连接失败或断开,延迟重连,避免高频重试 m_reconnectTimer->start(3000); } void PlcClient::onConnected() { m_reconnectTimer->stop(); emit connectionChanged(true); // 连上后发一帧读 D0 验证链路 readWords("D", 0, 1); }这段逻辑的关键参数是重连间隔 3000ms 和验证帧。重连间隔太短会在 PLC 未就绪时反复失败,太长又影响恢复速度,3 秒是现场比较稳的经验值。验证帧读一个确定存在的地址(比如 D0),能区分「TCP 通了但协议不对」和「根本没连上」两种情况——前者 socket 是连上的但收不到正确响应,后者直接触发 error 信号,排查方向完全不同。
4. 读写帧的拼装与解析:批量读 D 区、位读 M 区的完整代码
工程骨架有了,这一章落到最核心的帧拼装。批量读和位读是两种典型场景,前者考验地址归并,后者考验位拆包,把这两个写透,写单个寄存器就是它的特例。
4.1 批量读 D 寄存器的请求帧构造
批量读的请求帧按 2.2 节的结构拼。下面是一个读 D100 起 10 个字的完整构造,用std::vector<uint8_t>累积:
// 构造 3E 帧批量读请求:读 D 区 start 起 count 个字 std::vector<uint8_t> McFrame::buildReadWords(const std::string &dev, uint32_t start, uint16_t count) { std::vector<uint8_t> f; f.push_back(0x50); f.push_back(0x00); // 副头部 f.push_back(0x00); // 网络号 f.push_back(0xFF); // PLC 号 f.push_back(0xFF); f.push_back(0x03); // 目标模块 IO f.push_back(0x00); // 目标站号 // 请求数据长度 = 定时器2 + 指令2 + 子指令2 + 地址3 + 代码1 + 点数2 = 12 appendLE16(f, 12); appendLE16(f, 0x0010); // 监视定时器 4s appendLE16(f, 0x0401); // 批量读 appendLE16(f, 0x0000); // 字单位 appendLE24(f, start); // 起始软元件号,小端 f.push_back(deviceCode(dev)); // D=0xA8 appendLE16(f, count); // 点数 return f; }逻辑说明:请求数据长度字段必须精确等于其后所有字节数,算错一个字节 PLC 就回错误码 0xC051(数据长度不符)。定时器 0x0010 对应 4 秒,现场网络抖动大时可以调到 0x0028(10 秒),但别设 0,0 表示无限等待,一旦 PLC 不回,上位机线程就挂死。appendLE24把 24 位地址按低中高字节顺序压入,这是三菱的约定,写成大端会读到完全不相干的地址。
4.2 位读 M 区与位拆包
位读跟字读的差别在子指令和返回数据。子指令用 0x0001,返回的是按位打包的字节,每 8 个点占 1 字节,低位在前。读 M100 起 16 个点,返回 2 字节,第一字节的 bit0 对应 M100、bit1 对应 M101,依此类推。拆包代码:
// 解析位读响应:每字节 8 位,低位在前 std::vector<bool> McFrame::parseBits(const std::vector<uint8_t> &data, uint16_t count) { std::vector<bool> bits; bits.reserve(count); for (uint16_t i = 0; i < count; ++i) { uint8_t byte = data[i / 8]; bits.push_back((byte >> (i % 8)) & 0x01); } return bits; }参数说明:data是响应帧里结束代码之后的数据段,count是请求的点数。注意位读的点数上限,3E 帧单次位读最多 7168 点、字读最多 960 点,超过要分批。工程里封装一个readBits接口,内部按 7168 自动切片,调用方不用关心上限。位拆包这里如果按高位在前写,读出来的 M 点会整体错位 7 位,现象是「明明 M100 是 ON,界面显示 M107 是 ON」,这种错位靠肉眼很难发现,必须拿 PLC 编程软件对照着验。
4.3 响应帧校验与错误码对照
响应帧到手后先校验三处:副头部是不是 0xD0 0x00、结束代码是不是 0x0000、数据长度跟预期是否一致。结束代码非零时,把错误码翻译成人话再抛给上层,比直接弹「通讯失败」有用得多。常见错误码对照:
| 结束代码 | 含义 | 常见原因 |
|---|---|---|
| 0xC051 | 数据长度不符 | 请求数据长度字段算错 |
| 0xC056 | 软元件号超范围 | 地址超出 PLC 实际范围 |
| 0xC059 | 指令/子指令不支持 | 型号不支持该操作 |
| 0xC05B | 无法对指定软元件执行 | 该软元件只读或不存在 |
| 0xC061 | 请求数据长度超出上限 | 单次读写点数过多 |
把这张表做成一个errorText(uint16_t code)函数,日志里直接打中文含义,现场排查效率能翻倍。曾经有一次读 Q 系列某个地址一直回 0xC056,查了半天发现是地址写成了十进制但那个区实际按十六进制编址,这种问题有错误码提示就能少走弯路。
5. 避坑与排查:现场联调最容易翻车的五件事
协议和代码都写完了,真正上现场才是考验。这一章按「现象 → 原因 → 解决」记五条踩坑记录,都是联调阶段高频出现的,提前知道能省下大半天。
5.1 现象:TCP 连上了但一帧响应都收不到
原因通常是帧头或数据长度错了,PLC 收到非法帧直接丢弃、不回错误码。也可能是 PLC 侧没开 MC 协议端口,或者端口号不是默认的 5000/5001。解决:先用网络调试工具手动发一帧已知正确的读 D0 请求,确认 PLC 会回;不回就查 PLC 参数里的以太网设置,确认「MC 协议」或「SLMP」已启用、端口对得上。这一步能快速把问题切成「PLC 侧没配好」还是「上位机帧错了」。
5.2 现象:读回来的数值整体偏大或偏小一个数量级
原因是字节序或软元件地址换算错了。字读返回小端,按大端解析数值会错位;X/Y 八进制地址按十进制算,读到的就是隔壁点。解决:拿一个已知值(比如在 PLC 里给 D100 写 1234)对照读回值,1234 的十六进制是 0x04D2,小端字节是 D2 04,如果读成 0xD204 就是字节序反了。地址问题则用 PLC 编程软件在线监视,确认你算出来的地址跟实际点对得上。
5.3 现象:偶发丢包,跑几小时后数据突然断一截
原因是 TCP 流式处理没做粘包/半包切分,把两次 readyRead 当成两帧,或者一帧分两次到达时只解析了前半截。解决:在PlcClient里维护一个接收缓冲区,每次 readyRead 追加数据,然后循环「缓冲区够 21 字节 → 读数据长度 → 够整帧 → 切出解析 → 剩余留到下次」,直到不够一帧为止。这个循环是通讯稳定的命门,写对了偶发丢包基本消失。
5.4 现象:界面卡顿,PLC 响应慢的时候整个窗口假死
原因是网络读写跑在 UI 线程里,同步等待响应把事件循环堵死了。解决:PlcClient用 QTcpSocket 的异步信号槽本身就是非阻塞的,但如果你在槽里用waitForReadyRead同步等,就退化成阻塞了。正确做法是发帧后立即返回,响应到了在 readyRead 里处理,用信号槽把结果抛给界面。批量采集时再开一个工作线程跑PlcClient,界面线程只收结果,卡顿问题根除。
5.5 现象:换一台 PLC 或改个型号,同样的代码读不到数据
原因是软元件代码或支持范围随型号变。FX5U 和 Q 系列在部分软元件代码上不完全一致,某些型号不支持按字读 X/Y。解决:把软元件代码做成可配置的映射表,而不是硬编码在函数里;上线前拿目标型号的手册核对一遍支持的操作和点数上限。工程里留一个「型号配置」入口,换机型只改配置不改代码,这是长期维护的后悔药。
6. 进阶:把通讯层做成可复用的采集引擎
单点读写跑通只是起点,真实项目里往往是几十上百个地址按周期采集、还要写回控制。这一章讲怎么在现有工程上再走一步,把PlcClient升级成一个带地址表、周期调度、批量归并的采集引擎,并给出验证方法。
6.1 地址表驱动的批量归并
把要采集的地址写成一张表,每条记录包含软元件类型、起始地址、点数、采集周期。引擎启动时按「同类型 + 地址连续 + 周期相同」把地址归并成批,一轮采集只发归并后的几帧,而不是每个地址一帧。归并逻辑的核心是排序后扫描:同软元件类型按起始地址排序,相邻且间隔小于阈值的合并,合并后点数不超过单帧上限就并进去。这一步能把请求数从几十降到个位数,对 PLC 的通讯负载影响立竿见影。
// 地址归并的简化逻辑:同类型、地址连续则合并 struct AddrItem { std::string dev; uint32_t start; uint16_t count; int periodMs; }; std::vector<AddrItem> mergeAddrs(std::vector<AddrItem> items) { std::sort(items.begin(), items.end(), [](const AddrItem &a, const AddrItem &b) { return a.dev == b.dev ? a.start < b.start : a.dev < b.dev; }); std::vector<AddrItem> out; for (auto &it : items) { if (!out.empty() && out.back().dev == it.dev && it.start <= out.back().start + out.back().count && out.back().count + it.count <= 960) { // 地址连续且不超上限,合并 uint32_t end = std::max(out.back().start + out.back().count, it.start + it.count); out.back().count = end - out.back().start; } else { out.push_back(it); } } return out; }参数说明:960 是字读单帧上限,位读要换成 7168;合并条件里it.start <= back.start + back.count允许地址有重叠或紧邻,重叠部分读回来取一次即可。归并后每个批次记录原始地址到批次内偏移的映射,解析时按映射回填,界面拿到的还是原始地址的值。
6.2 周期调度与写操作的优先级
采集用 QTimer 按最小周期触发,每轮只发到期的批次。写操作单独走一个高优先级队列,写帧插在采集帧前面发,避免写指令被一堆读请求堵在后面导致控制延迟。写和读共用同一个 socket 时,必须保证「一问一答」的节奏——发一帧等响应再发下一帧,不能并发发多帧,否则响应会串。工程里用一个请求队列加「当前是否有在途请求」的标志位来串行化,这是保证数据不错位的底线。
6.3 验证方法:用 PLC 编程软件做对照
验证采集引擎不能只看界面数字跳动,要拿 PLC 编程软件在线监视做对照。做法是:在 PLC 里给几个 D 寄存器写入固定值,引擎采集后打印原始字节和解析值,逐字节核对;位软元件则在编程软件里强制 ON/OFF,看引擎读回的位是否同步翻转。再做一个压力验证:把采集周期压到 100ms 跑半小时,观察是否有超时累积、内存是否增长、重连是否被误触发。这套对照流程我每次上线新地址表都强制走一遍,因为界面显示「看起来对」和「真的对」之间,往往就差一个字节序。
从那以后我每次接新 PLC,都先把「手动发一帧读 D0」这一步走通再写业务代码,帧不通就绝不往下堆功能。希望帮到你。
本文还有配套的精品资源,点击获取