简介:这份资源面向具备一定 C++ 与 Qt 基础的工控开发者,聚焦 Qt 与三菱 PLC 之间的通讯实现,借助 MX Component 完成配置后,可在 Qt 5.12.9 与 VS2019 环境下建立连接,并提示通讯是否成功、显示所连 PLC 型号,适合设备上位机开发、产线数据采集等场景的入门与参考。压缩包共 70 个文件,约 47.52MB,以 cpp、h 源码与 vcxproj、sln 工程文件为核心,辅以 ui 界面、qrc 资源、pro 工程配置及编译中间产物,完整保留了可复现的工程结构。目前已有 2094 人学习下载。读者可从中获得一套可直接编译运行的通讯示例工程,理解 MX Component 类型库的调用方式、连接状态判断与型号读取逻辑,并参考其工程组织与调试配置,减少环境搭建和联调中的试错成本。
1. 工控上位机绕不开的一道坎:Qt 与三菱 PLC 的通讯
车间里一台设备要接上位机,PLC 用的是三菱 FX 系列,工程师丢过来一句“你做个界面能读能写就行”。听起来简单,真动手才发现坑不少:协议是厂商私有的,串口参数错一位就全是乱码,读写地址的进制换算能把人绕晕。这个项目就是拿 Qt 配 VS2019,把上位机和三菱 PLC 之间的通讯链路完整跑通——读 D 寄存器、写 M 线圈、定时轮询、异常重连,一套能直接塞进产线软件的骨架。适合正在做工控上位机、MES 数据采集、设备监控的 C++ 开发者,尤其是习惯 Qt Widgets 那一套、又想用 Visual Studio 调试的人。下面按“能干什么 → 怎么跑起来 → 核心代码怎么写的 → 怎么改”的顺序拆。
2. 技术选型与工程骨架:为什么是 Qt + VS2019 + 三菱协议
2.1 这套组合解决的是什么问题
三菱 PLC 的通讯方式主要有几种:串口(RS-422/485)走 MC 协议或专用协议、以太网走 MC 协议(3E/4E 帧)、以及部分老机型用的编程口协议。上位机侧要做的无非三件事:建立连接、按地址读写软元件、处理超时和断线。Qt 负责界面和事件循环,串口用QSerialPort,网络用QTcpSocket,协议帧的拼装和解析自己写——因为三菱的帧格式没有现成的官方 C++ SDK,第三方库要么年久失修,要么只支持特定型号。
选 VS2019 而不是纯 Qt Creator,主要是调试体验:工控现场经常要单步跟协议帧,VS 的断点、内存窗口、串口数据监视配合起来更顺手。Qt 版本建议 5.12 以上(QSerialPort成熟稳定),用 Qt VS Tools 插件挂到 VS2019 里,.pro和.vcxproj双轨维护,团队里谁用哪个 IDE 都不打架。
2.2 工程目录怎么分层
一个能长期维护的工控上位机,最忌讳把协议代码和界面代码搅在一起。这个项目的目录大致是这样分的:
PlcComm/ ├── core/ │ ├── PlcProtocol.h/.cpp # 帧结构定义、校验、编解码 │ ├── PlcClient.h/.cpp # 连接管理、读写接口、超时重试 │ └── PlcAddress.h/.cpp # 软元件地址解析(D/M/X/Y) ├── transport/ │ ├── SerialTransport.h/.cpp # QSerialPort 封装 │ └── TcpTransport.h/.cpp # QTcpSocket 封装 ├── ui/ │ ├── MainWindow.h/.cpp/.ui # 主界面 │ └── MonitorWidget.h/.cpp # 实时数据展示 ├── utils/ │ └── Logger.h/.cpp # 通讯日志落盘 └── main.cppcore层不依赖任何 UI 头文件,纯 C++ 加 QtCore,这样协议逻辑可以单独写单元测试,也能被命令行工具复用。transport层做接口抽象,串口和网口实现同一套ITransport,上层PlcClient不关心底层是线还是网。这个分层是后面所有改造的基础,别省。
2.3 开发环境搭建的具体步骤
先把工具链装齐,顺序别乱:
- 安装 VS2019,勾选“使用 C++ 的桌面开发”工作负载。
- 安装 Qt 5.15(或 5.12 LTS),组件里勾 MSVC 2019 64-bit 和 Qt Serial Port。
- VS2019 里装扩展 Qt Visual Studio Tools,在“扩展 → Qt VS Tools → Qt Versions”里把 qmake.exe 路径指过去。
- 新建 Qt Widgets Application,或者直接打开现成的
.vcxproj。
.pro文件里串口模块必须显式声明,否则链接阶段报QSerialPort未定义:
QT += core gui serialport CONFIG += c++11 TARGET = PlcComm TEMPLATE = app SOURCES += main.cpp \ core/PlcClient.cpp \ core/PlcProtocol.cpp \ transport/SerialTransport.cpp HEADERS += core/PlcClient.h \ core/PlcProtocol.h \ transport/SerialTransport.hQT += serialport这一行是新手最容易漏的,漏了之后编译能过、链接报一堆 undefined reference,血泪经验。如果用 VS 的.vcxproj而不是.pro,对应要在“Qt Project Settings → Qt Modules”里勾上 SerialPort。
2.4 串口参数与连接初始化
三菱 FX 系列走编程口或 485 时,常见参数是 9600 波特率、7 位数据位、偶校验、1 位停止位(7E1),部分机型用 8E1 或 8N1,具体看 PLC 侧 D8120 寄存器的设置。代码里把这些做成可配置项,别写死:
// transport/SerialTransport.cpp bool SerialTransport::open(const SerialConfig &cfg) { m_port.setPortName(cfg.portName); // 如 "COM3" m_port.setBaudRate(cfg.baudRate); // 9600 / 19200 / 38400 m_port.setDataBits(cfg.dataBits); // QSerialPort::Data7 m_port.setParity(cfg.parity); // QSerialPort::EvenParity m_port.setStopBits(cfg.stopBits); // QSerialPort::OneStop m_port.setFlowControl(QSerialPort::NoFlowControl); if (!m_port.open(QIODevice::ReadWrite)) { qWarning() << "open failed:" << m_port.errorString(); return false; } // 清空残留缓冲,避免上次断线留下的半帧干扰 m_port.clear(QSerialPort::AllDirections); return true; }参数说明:portName在 Windows 下是COMx,Linux 下是/dev/ttySx;dataBits用QSerialPort::Data7对应 7 位;parity设EvenParity就是偶校验。clear()那一步很关键——串口断线重连时缓冲区里可能还躺着上一帧的后半截,不清掉的话第一帧解析必错,现象是“刚连上读出来全是 0 或者乱跳”。
3. 协议帧拆解:一帧读写指令从拼装到解析的完整链路
3.1 三菱 MC 协议/编程口帧的基本结构
以常见的编程口协议(FX 系列)为例,一帧读 D 寄存器的请求大致是:起始符 + 命令字 + 首地址 + 字节数 + 校验 + 结束符。不同帧格式(1E/3E)字段长度不同,但套路一致:命令码定操作类型,地址字段定软元件和偏移,长度字段定读多少,末尾校验保证完整性。写操作则把要写的数据追加在长度字段之后。
地址解析是第一个容易翻车的地方。三菱的软元件地址是“字母 + 十进制数字”,比如D100、M50、X10,但协议帧里传的是软元件编号加偏移,且 X/Y 是八进制编号。PlcAddress这个类专门干这件事:
// core/PlcAddress.cpp bool PlcAddress::parse(const QString &addr, DeviceType &type, int &number) { QRegularExpression re("^([DMXYTCR])(\\d+)$"); auto m = re.match(addr.trimmed().toUpper()); if (!m.hasMatch()) return false; QChar dev = m.captured(1).at(0); bool ok = false; int raw = m.captured(2).toInt(&ok); if (!ok) return false; switch (dev.toLatin1()) { case 'D': type = DeviceType::D; number = raw; break; // 数据寄存器,十进制 case 'M': type = DeviceType::M; number = raw; break; // 辅助继电器,十进制 case 'X': type = DeviceType::X; number = toOctal(raw); break; // 输入,八进制 case 'Y': type = DeviceType::Y; number = toOctal(raw); break; // 输出,八进制 default: return false; } return true; }逻辑说明:正则先卡住“一个字母 + 数字”的格式,toUpper()兼容小写输入。D 和 M 直接取十进制值,X/Y 要转八进制——因为 PLC 侧 X/Y 的编号本身是八进制,用户写X10指的是八进制 10(十进制 8),如果直接按十进制传,读出来的点位全错位。toOctal就是把用户输入的“看起来像十进制”的字符串按八进制解释,这个转换不做,调试时你会怀疑人生。
3.2 请求帧的拼装与校验
拼帧的核心是把命令、地址、长度按协议顺序塞进QByteArray,最后算校验和。下面是一个读 D 寄存器的简化实现:
// core/PlcProtocol.cpp QByteArray PlcProtocol::buildReadFrame(DeviceType type, int number, int count) { QByteArray frame; frame.append(char(0x02)); // STX 起始符 frame.append(cmdForRead(type)); // 命令字,如 '0' 读 D frame.append(toAsciiHex(number, 4)); // 首地址,4 位 ASCII frame.append(toAsciiHex(count, 2)); // 读取点数,2 位 ASCII frame.append(calcChecksum(frame)); // 累加和校验 frame.append(char(0x03)); // ETX 结束符 return frame; } QByteArray PlcProtocol::calcChecksum(const QByteArray &body) { quint8 sum = 0; for (char c : body) sum += static_cast<quint8>(c); return toAsciiHex(sum, 2); // 两位十六进制 ASCII }参数说明:number是软元件起始编号,count是连续读取的点数(D 寄存器一次最多读几十个,具体上限看机型)。toAsciiHex把数值转成固定位宽的十六进制 ASCII 字符串,位宽不够要补零——协议对字段长度是严格的,少一位整帧就废。校验和用累加和取低字节,再转两位 ASCII,这是三菱编程口协议的常见做法;如果走 3E 帧,校验算法和字段布局会变,但buildReadFrame这个函数签名不用动,换实现即可。
3.3 响应帧解析与超时处理
发出去之后不能傻等,串口读是异步的,QSerialPort::readyRead信号触发后把数据追加到接收缓冲,再判断是否凑够一帧。这里有两个坑:一是半包,数据分两次到达;二是粘包,两次响应挤在一起。处理方式是维护一个缓冲区,按起始符和结束符切帧:
// core/PlcClient.cpp void PlcClient::onReadyRead() { m_rxBuf.append(m_transport->readAll()); while (true) { int stx = m_rxBuf.indexOf(char(0x02)); int etx = m_rxBuf.indexOf(char(0x03), stx + 1); if (stx < 0 || etx < 0) break; // 还没凑够一帧 QByteArray frame = m_rxBuf.mid(stx, etx - stx + 1); m_rxBuf.remove(0, etx + 1); // 消费掉已解析部分 if (!verifyChecksum(frame)) { emit errorOccurred("checksum mismatch"); continue; // 丢弃坏帧,继续找下一帧 } emit frameReceived(parseResponse(frame)); } }逻辑说明:indexOf找 STX 和 ETX,找不到就退出等待下次数据;找到后切出完整帧,从缓冲区移除,再校验。校验失败只丢当前帧不中断连接,因为现场干扰导致的偶发坏帧很常见,直接断连反而让上层频繁重连。超时用QTimer单独管:发请求时启动一个 500ms 定时器,收到匹配响应就停,超时则重发或上报错误,重试次数设上限(比如 3 次),避免死循环把串口堵死。
4. 避坑与排查:现场调试最容易翻车的五个点
4.1 现象:能连上但读出来全是 0
原因通常是地址进制搞错,或者读的软元件类型和 PLC 侧实际不符。X/Y 按十进制传是最常见的错误,另外 D 寄存器编号超出 PLC 实际范围也会返回 0 而不是报错。解决:先用 PLC 编程软件确认目标地址真实存在,再在代码里打印实际发出的帧,逐字节对照协议手册。日志里把buildReadFrame的返回值以十六进制打出来,比猜快得多。
4.2 现象:偶发校验错误,重发就好
现场电磁干扰、串口线过长、屏蔽没做好都会导致坏帧。代码层面已经做了坏帧丢弃,但根子上要查硬件:485 线用双绞屏蔽线、终端电阻按需加、波特率别贪高(9600 比 38400 稳)。软件侧把重试次数和超时做成可配置,别硬编码,不同现场差别很大。
4.3 现象:界面卡死
把串口读写放在 UI 线程里同步等待,是新手最常犯的错。QSerialPort本身是异步的,但如果你在按钮槽函数里waitForReadyRead(1000),界面就冻住了。解决:所有通讯走信号槽,请求发出后立即返回,响应到了再更新界面;或者把PlcClient整个挪到独立QThread,UI 只发命令收结果。
4.4 现象:断线重连后第一帧必错
前面提过,串口缓冲区残留半帧。除了open()时clear(),重连逻辑里也要先清缓冲再发请求。另外重连不要立刻狂发,加个 200ms 延迟让 PLC 侧端口稳定,否则会出现“连上就断”的循环。
4.5 现象:VS 里编译过、运行报缺少 Qt5SerialPort.dll
部署时忘了带串口模块的动态库。用windeployqt自动拷依赖,但注意它默认可能不包含 serialport,需要手动确认Qt5SerialPort.dll在输出目录。发布前在一台干净机器上跑一遍,比在开发机上反复试靠谱。
5. 二次开发改造:从能通到好用,我一般这么动
拿到这套骨架之后,真正落到项目里通常要改三块:接口抽象、数据模型、异常策略。先说接口抽象——PlcClient现在直接依赖SerialTransport,如果现场换成网口 MC 协议,得改上层。做法是抽一个ITransport纯虚接口,SerialTransport和TcpTransport都实现它,PlcClient只持有ITransport*,构造时注入。这样换通讯介质不动业务代码,测试时还能塞个 Mock 进去。
数据模型方面,原始代码读写的是裸int和QByteArray,业务层用起来很别扭。我一般加一层PlcTag结构,把“地址字符串、数据类型(bool/int16/float)、缩放系数、工程单位”绑在一起,读写接口收PlcTag而不是散参数。批量轮询时按地址连续性分组,一次读多个寄存器再拆,比一个地址发一帧效率高一个数量级——现场几十个点位,逐点读能把轮询周期拖到秒级。
异常策略上,原始实现是超时重试三次后上报错误,但没区分“通讯故障”和“地址非法”。地址非法重试多少次都没用,应该直接标记该点位为坏点并跳过,别拖累整轮采集。改造时给错误码分个类,Timeout走重试,BadAddress走隔离,Checksum走丢弃重发。
验证方法很简单:写个模拟从站的测试程序,用虚拟串口对(Windows 下可用 com0com 这类工具)把请求帧原样回环,先验证拼帧解析的正确性,再上真机。协议层单测覆盖地址解析的边界(X7 之后是 X10 不是 X8)、校验和、半包粘包,这几块测过,现场问题能少一大半。
最后说个习惯:从那以后我每次接新 PLC 型号,都先把“发出去的帧”和“收到的帧”以十六进制完整落盘,跑通一个读一个写再动界面。协议这东西,黑匣子式调试最费时间,把帧摊开看,玄学就变成了算术。希望帮到你。
本文还有配套的精品资源,点击获取