Qt与VS2019实现三菱PLC MC协议通讯上位机开发实战

发布时间:2026/11/5 4:43:22
Qt与VS2019实现三菱PLC MC协议通讯上位机开发实战

简介:这份资源面向需要在 Windows 平台下用 Qt 与三菱 PLC 建立通讯的工控开发者与上位机软件学习者,基于 Qt 5.12.9、VS2019 与 MX Component S4.19 环境,演示配置完成后如何完成与三菱 PLC 的通讯,并在界面上提示通讯是否成功、显示所连接的 PLC 型号,适合作为入门参考或项目原型。压缩包共 70 个文件,约 47.52MB,包含 cpp、h 源码与 Qt 界面文件、工程与解决方案配置、编译中间产物及日志等,覆盖从工程搭建到运行调试的完整过程。已有 2094 人学习下载,说明该方案在实际开发中有一定参考价值。读者可据此了解 MX Component 的调用方式、Qt 与 VS 联合工程的目录组织,以及通讯状态反馈与型号读取的实现思路,减少自行摸索环境配置与接口对接的时间。

1. 工控上位机通讯需求:为什么选 Qt + VS2019 对接三菱 PLC

产线上位机要跟三菱 PLC 打交道,最常被问到的不是“能不能通”,而是“换台电脑还能不能跑、产线改个地址要不要重编”。很多同学做毕设或课程设计时,第一反应是拿现成的组态软件拖控件,界面是快,可一旦要求自己写逻辑、做数据记录、接第三方算法,组态软件就开始卡脖子。这个项目的需求很具体:用 Qt 做界面、用 VS2019 做编译环境、用 C++ 直接跟三菱 PLC 建立通讯,读写软元件、做实时监控、留出扩展接口。适合正在做工业通讯方向毕设、课程设计,或者要给某台设备做配套上位机的读者。Qt 的跨平台信号槽加上 VS2019 的调试体验,是这套方案里比较反直觉但很实用的组合——很多人默认 Qt 就该配 Qt Creator,可实际交付时 VS 的断点和内存窗口更顺手。

2. 技术选型与通讯协议:为什么走 MC 协议而不是串口裸写

2.1 三菱 PLC 的几种通讯方式对比

三菱 PLC 常见的上位机通讯路径有三条:串口(RS-232/485)走专用协议、以太网走 MC 协议、以及部分型号支持的 SLMP。串口裸写的问题是帧格式随型号变,FX 系列和 Q 系列的报文结构不一样,换一台设备就得改代码;而且串口速率低,做实时监控时刷新一快就丢包。MC 协议(MELSEC Communication Protocol)跑在以太网上,报文结构相对统一,Q 系列、L 系列、iQ-R 系列都支持,读写软元件的命令码固定,扩展性好。这个项目选 MC 协议,本质上是拿“一次协议适配”换“多型号复用”。

通讯方式适用型号速率报文复杂度扩展性
串口专用协议FX 系列为主低随型号变差
MC 协议(以太网)Q/L/iQ-R高固定命令码好
SLMP较新型号高与 MC 接近好

选型时还要考虑一个现实问题:实验室或产线现场给不给开端口。MC 协议默认走 TCP 5000 端口(部分型号可改),如果现场网络策略严,得提前确认。通常的做法是先在本地用 PLC 仿真或一台闲置 PLC 打通,再上现场。

2.2 Qt 与 VS2019 的工程组织方式

Qt 和 VS2019 结合有两种常见做法:一是用 Qt VS Tools 插件,在 VS 里直接建 Qt 工程,编译时调用 Qt 的 moc/uic;二是用 qmake 或 CMake 生成 VS 工程文件。这个项目用第一种,原因是调试时能在 VS 里直接看 Qt 对象的值,断点打在信号槽里也正常。工程结构上,通讯层单独拆一个类,界面层只负责展示,避免把 socket 读写散落在各个窗口里。

// plcclient.h 通讯层接口,界面层只依赖这个头文件 class PlcClient : public QObject { Q_OBJECT public: explicit PlcClient(QObject *parent = nullptr); bool connectToPlc(const QString &ip, quint16 port = 5000); void disconnectFromPlc(); // 读软元件:deviceCode 如 "D"、"M"、"X",address 起始地址,count 数量 bool readDevice(const QString &deviceCode, int address, int count); bool writeDevice(const QString &deviceCode, int address, const QVector<quint16> &values); signals: void dataReady(const QString &deviceCode, int address, const QVector<quint16> &values); void errorOccurred(const QString &message); private: QTcpSocket *m_socket; QByteArray buildReadFrame(const QString &deviceCode, int address, int count); };

这段接口设计的关键点在于:读写方法只暴露“设备码 + 地址 + 数量”,不暴露底层报文。界面层调用readDevice("D", 100, 10)就能拿到 D100 到 D109 的值,报文拼装全在通讯层内部完成。这样做的直接好处是,将来如果换成 SLMP 或别的协议,界面代码一行不用改。参数上,deviceCode用字符串而不是枚举,是为了让配置表能直接写“D”“M”“X”,现场调试时改配置比改代码快。

2.3 MC 协议报文结构拆解

MC 协议走 TCP 时,报文分请求和响应两部分。请求帧一般包括副头部(5000 固定)、网络号、PLC 号、目标模块 IO 号、目标模块站号、请求数据长度、CPU 监视定时器、命令码、子命令码、起始软元件地址、软元件代码、读取点数。响应帧则把命令码之后的部分换成结束代码和数据区。这里最容易翻车的是软元件地址的编码方式:三菱的地址不是直接填十进制,而是按“地址 × 16 + 设备码”的规则拼成三字节。比如 D100,设备码是 0xA8,地址部分要算成100 * 16 + 0xA8再拆成低中高三字节。

// 把 D100 这类地址转成 MC 协议的三字节软元件地址 QByteArray PlcClient::encodeDeviceAddress(const QString &deviceCode, int address) { quint16 code = deviceCodeMap.value(deviceCode); // D->0xA8, M->0x90, X->0x9C quint32 raw = static_cast<quint32>(address) * 16 + code; QByteArray out(3, 0); out[0] = static_cast<char>(raw & 0xFF); // 低字节 out[1] = static_cast<char>((raw >> 8) & 0xFF); // 中字节 out[2] = static_cast<char>((raw >> 16) & 0xFF); // 高字节 return out; }

这段编码逻辑是 MC 协议里最“玄学”的地方,网上很多示例只贴了 D 寄存器的算法,换成 M 或 X 就错。deviceCodeMap里要按实际型号查手册填设备码,不同系列可能有差异。raw用 32 位是为了防止地址较大时溢出,实际报文只取低三字节。写完这段建议先用一个已知地址做单点读,拿响应数据跟 PLC 编程软件里的监视值对一遍,对上了再批量读。

3. 总体架构设计:三层结构与四个模块的职责划分

3.1 分层思路与模块边界

这套上位机按三层拆:界面层(Qt Widgets)、业务层(数据解析与业务逻辑)、通讯层(socket 与报文)。四个模块分别是连接管理、报文编解码、数据缓存、界面展示。连接管理负责 TCP 建链、心跳、断线重连;报文编解码负责请求帧拼装和响应帧解析;数据缓存存最近一次读到的软元件值,供界面定时刷新;界面展示只做绑定和显示。分层的目的是让“通讯断了”和“界面卡了”能分开排查,而不是一锅粥。

+------------------+ +------------------+ +------------------+ | 界面层 | | 业务层 | | 通讯层 | | 主窗口/曲线/表格 |<--->| 数据缓存/解析 |<--->| TCP/报文编解码 | +------------------+ +------------------+ +------------------+ ^ ^ ^ | | | 定时刷新 信号槽通知 QTcpSocket

上图里,界面层不直接碰 socket,业务层不关心界面控件,通讯层不知道数据要显示在哪。这种边界在后期加功能时特别省事:要加历史曲线,只在业务层多存一份;要换协议,只动通讯层。

3.2 连接管理模块的实现要点

连接管理要处理三件事:建链、保活、重连。建链时用QTcpSocket::connectToHost,连上后发一条读命令验证链路;保活靠定时器周期性读一个固定地址,读失败就触发重连;重连要加退避,不能一秒连十次把 PLC 连接数占满。通常的做法是首次 1 秒、之后翻倍、上限 30 秒。

void PlcClient::onSocketError(QAbstractSocket::SocketError err) { Q_UNUSED(err) m_reconnectTimer.start(m_reconnectInterval); // 退避间隔,初始 1000ms m_reconnectInterval = qMin(m_reconnectInterval * 2, 30000); emit errorOccurred(m_socket->errorString()); } void PlcClient::onReconnectTimeout() { if (m_socket->state() == QAbstractSocket::UnconnectedState) { m_socket->connectToHost(m_ip, m_port); } }

m_reconnectInterval在连接成功后要重置回 1000,否则下次断线会从 30 秒开始等。这个细节很多示例没写,现场调试时表现为“第一次断线恢复很快,后面越来越慢”,查半天以为是网络问题。

3.3 数据缓存与界面刷新节奏

界面刷新不能跟着 socket 的readyRead走,否则数据一密界面就卡。做法是通讯层收到数据后只更新缓存并置脏标志,界面用 100~200ms 的定时器统一刷新。刷新周期太短没意义,PLC 扫描周期本身就在毫秒级;太长又显得迟钝。我一般设 150ms,曲线控件另算。

// 业务层缓存结构 struct DeviceCache { QHash<QString, QVector<quint16>> values; // key: "D100" 这类地址 QMutex mutex; bool dirty = false; };

加锁是因为 socket 回调在 IO 线程,界面定时器在主线程,跨线程读写缓存必须保护。如果嫌锁麻烦,也可以把通讯层整个放到独立线程,用信号槽把数据拷到主线程,但那样报文解析也得跟着搬,初期不建议。

4. 关键模块实现:报文编解码与读写流程

4.1 请求帧拼装与响应帧解析

请求帧拼装按固定顺序写字节,长度字段要最后回填,因为前面的字段长度固定、后面的数据区长度可变。响应帧解析先看结束代码,非 0 就是 PLC 返回了错误,常见的是地址越界或设备码不对。结束代码为 0 时,数据区按字或按位解析,读位软元件时每个点占一个字节的低位。

QByteArray PlcClient::buildReadFrame(const QString &deviceCode, int address, int count) { QByteArray frame; frame.append(QByteArray::fromHex("5000")); // 副头部 frame.append(char(0x00)); // 网络号 frame.append(char(0xFF)); // PLC 号 frame.append(QByteArray::fromHex("03FF")); // 目标模块 IO 号 frame.append(char(0x00)); // 目标模块站号 QByteArray data; data.append(QByteArray::fromHex("0C00")); // CPU 监视定时器 data.append(QByteArray::fromHex("0104")); // 命令码:批量读 data.append(QByteArray::fromHex("0000")); // 子命令:按字 data.append(encodeDeviceAddress(deviceCode, address)); data.append(deviceCodeMap.value(deviceCode) > 0xFF ? char(0x00) : char(0x00)); data.append(char(count & 0xFF)); data.append(char((count >> 8) & 0xFF)); frame.append(char(data.size() & 0xFF)); frame.append(char((data.size() >> 8) & 0xFF)); frame.append(data); return frame; }

这里data.size()回填的是请求数据长度,不含副头部和长度字段本身。命令码0x0401是批量读,写是0x1401,子命令0x0000表示按字、0x0001表示按位。读位软元件时子命令要改,点数按位算。写完先用 Wireshark 抓一次本机回环,对照手册逐字节核,比盲猜快得多。

4.2 粘包处理与超时控制

TCP 是流,一次readyRead可能收到半帧,也可能收到两帧粘一起。处理方式是维护一个接收缓冲区,每次追加新数据后循环判断:缓冲区长度够不够头部、头部里的长度字段说还要多少字节、够了就切一帧出来解析,不够就等下次。超时控制用QTimer,发请求时启动,收到对应响应就停,超时则记一次失败并触发重连判断。

void PlcClient::onReadyRead() { m_recvBuffer.append(m_socket->readAll()); while (m_recvBuffer.size() >= 9) { // 响应头至少 9 字节 int dataLen = (quint8)m_recvBuffer[7] | ((quint8)m_recvBuffer[8] << 8); int frameLen = 9 + dataLen; if (m_recvBuffer.size() < frameLen) break; QByteArray frame = m_recvBuffer.left(frameLen); m_recvBuffer.remove(0, frameLen); parseResponse(frame); } }

dataLen的偏移量 7、8 是按响应帧结构定的,不同子命令下响应头长度可能差一两个字节,实际要以手册为准。粘包处理写错的表现是“偶尔丢一帧”或“数据错位”,很难复现,所以缓冲区逻辑要单独写单元测试,喂半帧、喂两帧、喂一帧半各测一遍。

4.3 写操作的确认机制

写软元件不能发出去就当成功,要等响应里的结束代码为 0 才算数。批量写时如果中间某个地址越界,整帧会被拒,所以写之前最好先读一次目标地址确认范围。写位软元件时值要按位打包,一个字节放 8 个点,顺序是低位在前。

bool PlcClient::writeDevice(const QString &deviceCode, int address, const QVector<quint16> &values) { if (values.isEmpty()) return false; QByteArray frame = buildWriteFrame(deviceCode, address, values); m_socket->write(frame); // 实际确认在 parseResponse 里根据结束代码回填结果 return m_socket->waitForBytesWritten(500); }

waitForBytesWritten只保证数据进了系统缓冲,不代表 PLC 收到了,真正的确认在响应解析。这个区别在调试时很关键:如果只看write返回值,会误以为写成功,实际 PLC 那边结束代码非 0。

5. 避坑与常见问题排查:现场最容易翻车的五个点

5.1 连得上但读不到数据

现象是 socket 状态已连接,发读命令后要么没响应、要么结束代码非 0。原因通常是 PLC 侧没开 MC 协议、端口不对,或者网络号/PLC 号填错。解决顺序:先用 PLC 编程软件确认以太网参数里 MC 协议已启用、端口号一致;再用抓包看请求帧有没有发出去、PLC 有没有回;回了但结束代码非 0,就查设备码和地址编码。这一步别跳,我见过直接改代码改一下午,最后发现是 PLC 端口没开。

5.2 地址编码差一位

现象是读 D100 拿到的值跟监视软件对不上,或者读 M 点全错。原因是软元件地址编码里“地址 × 16 + 设备码”的规则没吃透,尤其是 X/Y 这类八进制地址,填十进制会偏。解决是拿手册里的编码表逐一对,先单点读验证,再批量。通常的做法是写一个地址转换的小工具函数,配一张设备码表,所有读写都走它,不手拼。

5.3 界面越跑越卡

现象是运行几分钟后界面响应变慢,CPU 占用升高。原因是readyRead里直接更新控件,或者缓存没加锁导致主线程反复重绘。解决是把数据更新和界面刷新解耦,通讯层只写缓存,界面定时器统一刷;曲线控件限制点数,超过上限就丢最旧的。这个坑在毕设演示时特别致命,跑十分钟就卡住,答辩现场很尴尬。

5.4 断线重连把 PLC 连接数占满

现象是网络闪断后上位机连不上,PLC 侧显示连接数满。原因是重连没有退避,或者旧 socket 没正确关闭就新建。解决是重连加指数退避,重连前先abort()旧连接并清空接收缓冲;PLC 侧如果有连接数上限,要在程序退出时主动断开。血泪经验是调试时反复启动程序,旧连接没释放,最后只能重启 PLC。

5.5 多线程下信号槽跨线程传参出错

现象是偶发崩溃或数据错乱,日志里看不出规律。原因是自定义类型没注册元类型,跨线程信号槽传QVector<quint16>时被当成本地指针。解决是在main里qRegisterMetaType<QVector<quint16>>("QVector<quint16>"),或者干脆用QVariantList传。这个坑隐蔽性强,单线程测不出来,一上多线程就翻车。

6. 验收与答辩:怎么证明这套通讯真的通了

验收分三层:链路层、数据层、业务层。链路层看能不能稳定建链、断线能不能自动恢复,做法是拔网线再插回,观察重连时间和日志;数据层看读写值跟 PLC 监视软件是否一致,做法是选 D、M、X 各三个地址,写一个已知值再读回比对;业务层看界面刷新是否流畅、长时间运行是否卡顿,做法是连续跑 30 分钟以上,记录 CPU 和内存曲线。答辩时别只演示“读到一个数”,要演示“改一个数、断一次网、恢复后继续读”,这三步能覆盖大部分评委的追问。

验收项方法合格标准
建链与重连拔插网线30 秒内自动恢复,日志有记录
读写一致性写已知值再读回D/M/X 各三点全部一致
长时间运行连续跑 30 分钟界面无卡死,内存无持续增长
异常处理填错 IP/端口有明确错误提示,不崩溃

答辩时被问“为什么不用组态软件”,回答落到扩展性和可控性:组态软件做标准监控快,但接自定义算法、做数据落库、改通讯细节时受限,这套方案通讯层是独立类,换协议只动一个文件。被问“报文怎么保证对”,就现场打开抓包或日志,指着结束代码说“0 才算成功”。我一般还会准备一个“故意填错地址”的演示,让评委看到错误处理是活的,不是摆设。

从那以后我每次做通讯类项目,验收前都强制走一遍“拔网线、填错地址、连跑半小时”这三步,少一步都不敢交。希望帮到你。

本文还有配套的精品资源,点击获取