
简介工业上位机开发中三菱PLC是最常见的控制设备之一。理解其通讯协议是打通上层应用与现场设备的关键。三菱PLC根据系列不同分别支持FX编程口与MC 3E/4E等协议物理链路则分为串口和以太网。通信帧的本质是地址编码、长度与校验三者的正确组合任何一项偏差都会导致NAK或无响应。掌握这些基础后开发者可以在Visual Studio 2019中用C与Qt快速封装通讯类实现D/M/X/Y寄存器的可靠读写。这类能力适用于产线数据采集、设备监控等典型工控场景配合源码的轮询调度与异常重试机制能有效缩短上位机调试周期避免在现场反复踩坑。1. 用 C 在 VS2019 里写 QT 上位机调三菱 PLC这套通讯源码就是干这个的手里同时压着 QT 界面、C 业务逻辑和一台三菱 PLCVisual Studio 2019 里来回切窗口通讯却一封帧都发不出去——这种状态我太熟悉了。这套源码解决的正是“ QT VS2019 C 与三菱 PLC 通讯”这个组合里的最后一公里不纠结界面先把 FX 编程口和 MC 3E 帧两种协议封装成能直接调用的类串口、以太网两条链路都给你铺好。适合刚接手工控上位机的新手也适合不想再翻三菱协议文档、只想快速验证读写功能的熟手。拿到手先跑通 Demo再替换成自己的业务逻辑比从空工程一点点焊要省太多事。2. 协议选型和通讯链路FX 编程口、MC 3E 帧串口还是以太网2.1 三菱 PLC 常见通讯方式FX 编程口、MC 3E/4E、A-1E 怎么选三菱 PLC 不是一个协议打天下。FX 系列走自家编程口协议Q/L/iQ-R 系列走 MC 协议MC 协议本身又分以太网 3E 帧、4E 帧和串口 A-1E 帧。选错协议后面的帧构造全是白费。先看这张对照表基本能定方向通讯方式物理层典型命令适用系列特点FX 编程口RS-422/48530H 读、31H 写FX3U / FX3G接线简单默认 9600 8 Even 1单帧点数受限MC 3E 帧TCP / UDP0401 批量读、1401 批量写Q / L / iQ-R二进制帧紧凑以太网速率高Qna-3E 最经典MC 4E 帧TCP / UDP在 3E 基础上带 CPU 编号FX5U / iQ-R多 CPU 寻址FX5U 默认走这个变体MC A-1E 帧串口 RS-232/422ASCII 帧Q 系列串口模块报文可读性好但效率低老设备还在用我的选择标准很朴素FX3U 这种小 PLC项目里只要读写 D 寄存器、M 位、X/Y 状态直接上 FX 编程口一条 USB-RS422 线就够Q 系列或 iQ-R 这种走以太网的用 MC 3E 帧FX5U 则要留意 4E 帧——很多人在 FX5U 上套 3E 帧死活连不上其实就是少了两字节 CPU 编号。A-1E 帧现在只在改造老产线时遇到新项目基本不碰。2.2 这套源码为什么要做串口 以太网双通道仔细看这份源码的类结构你会发现它没有把宝押在一条链路上。抽象基类只管收发字节流底下挂了两个实现一个用 QSerialPort 走 FX 编程口一个用 QTcpSocket 走 MC 3E 帧。界面层通过同一个接口调读写底层切协议上层一行不用改。这样设计不是拍脑袋。实际产线里以太网口经常被交换机、防火墙、IP 冲突卡住现场工程师最后掏出老式 USB-RS422 线救急是常事。双通道意味着你在办公室用网口调试到了现场发现网线没布切换串口只是改一行配置的事。边界条件也说清楚串口是一主一从的令牌式通讯别想在一条 RS-485 总线上同时轮询好几台 FX 系列还能保证实时性重载严重时会偶发超时TCP 链路倒是可以按站号区分多台 PLC每台连接独立端口轮询各自维护。源码里默认网口端口是 6001三菱 PLC 以太网模块常见端口可改。2.3 帧格式的本质地址编码、长度、校验三件事决定能不能通为什么三菱协议比 Modbus 难上手Modbus 的寄存器地址就是一个 16 位编号三菱偏要搞“软元件代码 软元件编号”的组合编址。比如 D100 在 MC 协议里是软元件代码 0x00A8、偏移地址 0x0064到了 FX 编程口D0 的通讯地址又变成 0x1000。同一个 D 寄存器两种协议地址起点完全不同新手常在这里翻车。所以别把协议当黑匣子背。通讯成功的本质就三件事地址编码对得上、帧长度字段对得上、校验和对得上。地址错了PLC 回结束码错误长度错了整个帧解析错位校验错了直接 NAK。这套源码把三件事都封装在帧构造函数里你要做的只是传入软元件号和数量它帮你算地址、算长度、算校验。3. VS2019 QT 集成装插件、跑通第一个串口 Demo、把 DLL 送到真机3.1 工具链三件套Qt 5.15.2、VS2019 v142、Qt VS Tools先把环境固定下来。我一般固定用 Qt 5.15.2 MSVC2019 64 位、Visual Studio 2019 的 v142 工具集、Qt VS Tools 扩展。这套组合对应关系最稳网上报错也最少。安装顺序先装 VS2019确认“使用 C 的桌面开发”工作负载勾上再装 Qt 5.15.2装的时候勾上 MSVC 2019 64-bit最后在 VS 里打开“扩展 → 管理扩展”搜索 Qt Visual Studio Tools 安装。装完在“扩展 → Qt VS Tools → Qt Versions”里把 msvc2019_64 的路径指进去。新建工程不要选错模板。源码里是 Qt Console 起手把 widget 界面后加或者直接选 Qt Widgets Application。工程文件里要显式声明串口和网络模块QT core gui serialport network greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c17 DEFINES QT_DEPRECATED_WARNINGSserialport对应 QSerialPortnetwork对应 QTcpSocket 和 QUdpSocket。漏掉其中一个模块编译直接报“未定义引用”或头文件找不到。CONFIG c17是让源码里的结构化绑定、lambda 捕获这些语法能过 v142 编译器。3.2 无插件备选CMake 打开文件夹适合不想被 IDE 绑死的团队如果你的团队不用 Qt VS Tools或者 VS 版本和 Qt 库工具集对不上可以走 CMake 路线。VS2019 原生支持“打开文件夹”模式CMakeLists 写对就能跑cmake_minimum_required(VERSION 3.16) project(plc_demo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets SerialPort Network) add_executable(plc_demo main.cpp PlcClient.cpp PlcClient.h ) target_link_libraries(plc_demo Qt5::Widgets Qt5::SerialPort Qt5::Network )关键在find_package能不能找到 Qt。如果 Qt 装在 D 盘要在 CMakeSettings.json 或环境变量里加CMAKE_PREFIX_PATHD:/Qt/5.15.2/msvc2019_64否则会报“找不到 Qt5Config.cmake”。这条路径是 Qt 库的根目录不是 src 目录很多新手在这写错。CMake 路线的优点是工程文件可提交到 Git同事拉下来不用配插件路径缺点是首次配置慢且 Qt 版本切换需要手动改路径。二选一即可源码两者都支持。3.3 最小串口验证程序和部署时缺 DLL 的坑环境通了先别急着跑完整界面写个最小程序验证串口物理链路。FX 编程口默认参数是 9600、8 数据位、偶校验、1 停止位注意是 Even不是 NoParityQSerialPort ser; ser.setPortName(COM3); ser.setBaudRate(QSerialPort::BaudRate9600); ser.setDataBits(QSerialPort::Data8); ser.setParity(QSerialPort::EvenParity); // FX编程口默认偶校验 ser.setStopBits(QSerialPort::OneStop); ser.setFlowControl(QSerialPort::NoFlowControl); if (!ser.open(QIODevice::ReadWrite)) { qWarning() ser.errorString(); return false; } QObject::connect(ser, QSerialPort::readyRead, []() { qDebug() ser.readAll().toHex(); }); ser.write(frame); ser.waitForBytesWritten(500);waitForBytesWritten只保证数据进了驱动缓冲区不代表 PLC 已经收到。真正判断成功要看readyRead回调里有没有回帧。掉进 NoParity 的坑现象是写入后 PLC 完全无视因为校验位对不上串口驱动层面就已经把帧丢了。把程序拷到工控机之前一定要在开发机 Release 目录跑一遍windeployqtwindeployqt --release --no-translations .\plc_demo.exe它会自动拷入 Qt 的 platforms 插件、串口插件和依赖 DLL。最典型的部署事故是exe 拷过去了双击闪退事件查看器里报“无法加载平台插件”其实就是platforms/qwindows.dll没带。还有 MSVC 运行库目标机器没装 vc_redist 也会静默失败。4. 核心实现拆解帧结构、软元件地址映射、校验和与轮询线程4.1 通讯类架构一个接口、两个协议实现源码里最值得抄的不是某一段帧代码而是这套分层IPlcClient (抽象接口) ├── FxSerialClient // FX编程口QSerialPort ├── Mc3eTcpClient // MC 3E帧QTcpSocket └── Mc4eTcpClient // MC 4E帧FX5U变体接口只暴露readD(addr, count)、writeD(addr, values)、readM(addr, count)之类的业务方法。界面层不关心底层是串口还是网口切换协议就是换一个对象实例。这也是我自己做上位机时的固定习惯先把协议封装从界面里剥出去后面加功能才不用动 UI 代码。4.2 FX 编程口读写命令码 30H/31HSTX/ETX 与两位校验和FX 编程口帧结构不长但非常考验细节点。读命令帧格式是STX(0x02) 命令码0(0x30) 首位地址(4位ASCII) 读取点数(2位ASCII) ETX(0x03) 校验和(2位ASCII)注意地址和数量都是 ASCII 十六进制字符不是二进制数。D0 的协议地址是 0x1000则地址字段是1000。点数 2 写成02。读 8 个 D 寄存器代码如下QByteArray buildFxReadFrame(quint16 addr, quint16 count) { QByteArray body; body.append(char(0x30)); // 读命令 0 body.append(QString::number(addr, 16) .rightJustified(4, 0) .toLatin1().toUpper()); // 地址4位ASCII body.append(QString::number(count, 16) .rightJustified(2, 0) .toLatin1().toUpper()); // 点数2位ASCII body.append(char(0x03)); // ETX quint8 sum 0; for (int i 0; i body.size(); i) { sum static_castquint8(body.at(i)); // 校验CMD到ETX累加 } QByteArray frame; frame.append(char(0x02)); // STX frame.append(body); frame.append(QString::number(sum, 16) .rightJustified(2, 0) .toLatin1().toUpper()); return frame; }逻辑说明校验和是所有有效字节 ASCII 码累加的低 8 位转成两位大写十六进制。最容易错的点是校验范围和是否转大写没有转大写PLC 回 NAK把 STX 也算进去同样 NAK。所以我把 STX 放在 body 外面累加从源头避免这个坑。地址参数addr是协议地址不是 D 寄存器号。D0 对应 0x1000D100 对应 0x1064。单帧读取点数受协议限制FX3U 建议单次不超过 64 点超过就拆帧。响应数据是每个 D 值返回 4 个 ASCII 字符低字节在前解析时按 4 字符一组还原 16 位值。写命令和读命令结构几乎一样只是命令码变10x31在 ETX 前把数据按“先低字节后高字节、每个字节拆两个 ASCII 字符”塞进去。注意写 D 值是 16 位32 位数据要连续写两个 D低 D 在前。4.3 MC 3E 帧构造Little Endian 字节序与软元件代码MC 3E 帧比 FX 编程口复杂在头部长且全部走二进制小端序。以 Q 系列批量读 D 区为例最少字节的请求帧长这样副头 0x5000 网络号 00 PC号 FF IO编号 03FF 站号 00 请求数据长度 监视定时器 指令 0401 子指令 0000 软元件代码 00A8 软元件地址(3字节) 点数代码实现如下QByteArray buildMc3eRead(quint16 deviceCode, quint32 offset, quint16 points) { QByteArray req; req.append(char(0x50)); req.append(char(0x00)); // 副头 0x5000 req.append(char(0x00)); // 网络号 req.append(char(0xFF)); // PC号 req.append(char(0xFF)); req.append(char(0x03)); // IO编号 03FF, 小端 req.append(char(0x00)); // 站号 req.append(char(0x00)); req.append(char(0x00)); // 请求长度, 稍后回填 req.append(char(0x10)); req.append(char(0x00)); // 监视定时器 req.append(char(0x01)); req.append(char(0x04)); // 批量读 0401, 小端 req.append(char(0x00)); req.append(char(0x00)); // 子指令, 字访问 req.append(char(deviceCode 0xFF)); // 软元件代码低字节 req.append(char((deviceCode 8) 0xFF)); // 软元件代码高字节 req.append(char(offset 0xFF)); // 地址第1字节 req.append(char((offset 8) 0xFF)); // 地址第2字节 req.append(char((offset 16) 0xFF)); // 地址第3字节 req.append(char(points 0xFF)); // 点数低字节 req.append(char((points 8) 0xFF)); // 点数高字节 quint16 len req.size() - 9; // 长度从监视定时器开始算 req[7] static_castchar(len 0xFF); req[8] static_castchar((len 8) 0xFF); return req; }逻辑说明deviceCode是软元件代码D 区是 0x00A8M 区是 0x0090X 区是 0x009CY 区是 0x009D。offset是软元件地址D0 就是 0D100 就是 0x64。整个帧从头到尾都是小端一眼就能看到大量“先低后高”的字节排布。长度字段的回填是我踩过最多次的地方。不同版本的协议文档对“请求数据长度”的定义有差异有的从监视定时器开始算有的从指令开始算。源码统一采用“从监视定时器到帧尾的字节数”实测能通过 Q 系列通讯测试。如果你拿到的协议文档算出的长度和这里不一致不要纠结以实际回显的结束代码为准调。响应帧的结构是副头、网络号、PC 号、IO、站号、长度、结束代码再跟数据。结束代码 0x0000 代表正常0xC051 是范围外地址0xC052 是指定点数超限。解析响应时先检查结束代码再按点数解析数据别一上来就拿数据算。MC 4E 帧和 3E 帧的区别是在请求里多了一个 2 字节的 CPU 编号放在监视定时器之后、指令之前。FX5U 和 iQ-R 多 CPU 环境下必须带单 CPU 时填 0x0000 也能通。4.4 软元件地址映射表D/M/X/Y 在两种协议里的不同起点两种协议最大的混淆点就是地址起点。我整理了一张常用映射表源码的 SoftElementTable 里也是同一份软元件类型MC 3E 代码FX 编程口协议地址说明D字0x00A80x1000 D号16位数据寄存器32位用连续两个DM位0x00900x0100 M号M8000特殊继电器同样按此编址X位0x009C0x03E0 八进制地址输入注意八进制编号Y位0x009D0x0400 八进制地址输出注意八进制编号X 和 Y 的编号是八进制的X0 到 X7 是 0x03E0 到 0x03E7X10 是 0x03E8中间跳过 8 和 9。很多新手用十进制递增算地址结果读写的是别的点排查半天才发现是进制问题。D 区相对规矩D0 0x1000 这个锚点要死记MC 协议里 D0 却是 0x000000不要混。4.5 轮询调度QTimer 异步队列、超时重试和断线判定上位机读 PLC 最常见的设计错误是发完请求后用while死等响应把 UI 线程堵死。正确做法是异步队列QTimer 定时发请求等响应的窗口期用单次定时器做超时判断超时丢帧但不阻塞界面。class PlcPoller : public QObject { Q_OBJECT public: explicit PlcPoller(IPlcClient *client, QObject *parent nullptr) : QObject(parent), io(client) { timer.setInterval(100); connect(timer, QTimer::timeout, this, PlcPoller::tick); connect(io, IPlcClient::responseReady, this, PlcPoller::onResponse); } private slots: void tick() { if (busy) return; if (retry 3) { emit linkLost(); // 连续超时, 判定断线 return; } busy true; io-send(buildMc3eRead(0x00A8, 0x0000, 8)); QTimer::singleShot(500, this, [this]() { if (busy) { retry; busy false; } // 500ms无响应, 丢帧重试 }); } void onResponse(const QByteArray resp) { if (!busy) return; retry 0; busy false; emit dataReady(parseDWords(resp)); } private: IPlcClient *io; QTimer timer; bool busy false; int retry 0; };逻辑说明这个状态机只有两个状态空闲和等待响应。busy防止上一次还没回应就发下一次retry超过 3 次就通知界面层显示断线同时停止发请求等 PLC 侧链路恢复后再自动重连。这种设计下线缆松了、PLC 停电界面不会卡死只会弹状态。参数调优100ms 轮询适合人机界面显示、数据采集这类场景运动控制或高速配方切换建议把间隔压到 20ms并且把 poller 放到独立线程。注意 QTimer 的最小有效间隔是系统定时器分辨率Windows 下大约是 15.6ms所以别写 1ms 这种虚参数。5. 避坑与排查串口没反应、NAK、数据错位5 个高频坑5.1 串口能打开但 PLC 无响应多半是偶校验或 RS-485 方向控制现象COM 口能打开TX 灯在闪但 PLC 端毫无动作readyRead一直不触发。原因两种情况最常见。一是 FX 编程口默认是偶校验代码里却配了 NoParity这一项不对底层串口就把帧过滤了二是用的 RS-485 转接器没有自动方向控制数据发出去后发送使能没拉回接收状态PLC 的回应发不出来。解决先把串口参数改成 9600、8、Even、1写死在初始化代码里并标注注释方向控制问题买带“自动收发切换”的 USB-RS422 转换器或者软件层面在write之后QThread::msleep(2)让发送使能稳定切回接收再开始读。5.2 返回 NAK 15H校验和算错而且错得很隐蔽现象串口收发都正常发送读命令后立刻收到 0x15NAK或者收到帧头但数据全是乱码。原因FX 编程口的校验和是从命令码到 ETX 结束符之间所有字节 ASCII 码的累加和低 8 位转成两个大写十六进制字符。有人把 STX 纳入了校验范围有人忘了转大写有人用 Unicode 字符串求和导致每个字符多算一个 0x00。解决严格按代码里的sum static_castquint8(body.at(i))方式累加。调试时把这个校验和打印出来和 PLC 调试软件的监视窗对比一眼就能看出差异。5.3 数据高低字节错位D 值读回来变成 0x4321现象写入 D0 0x1234读回来显示 0x3412或者数值对但翻倍、变小。原因三菱协议全部是小端传输数据区先到的是低字节。界面层用qFromBigEndian解析或者从 QByteArray 取数时直接按大端索引就会错位。解决统一用qFromLittleEndianquint16解析每个 D 值不要手动(buf[0] 8) | buf[1]。如果你做的是跨平台代码还要注意不同编译器对char符号性的处理取字节前一律static_castquint8。5.4 轮询 10ms CPU 吃满、UI 卡死现象程序一启动就有一个核跑满界面拖拽明显掉帧最小化再恢复要等好几秒。原因轮询间隔设成 10ms且读写逻辑放在主线程里同步执行。Windows 下 QTimer 实际触发频率受系统计时器限制密集的短周期同步 I/O 会把 UI 事件循环饿死。解决把轮询类移到 QThread 子线程界面只通过信号槽接收最终数据轮询间隔按场景设置界面显示类 100ms 足够高速采集最低也建议 20ms。不要为了“实时性”盲调 1ms三菱 PLC 的响应周期本身就在 10ms 量级发太快只会把总线打满。5.5 VS2019 报 MSB8020 / 找不到 Qt 头文件现象解决方案资源管理器里工程有黄色叹号编译报“MSB8020 找不到 v142 生成工具”或者#include QSerialPort直接提示找不到。原因MSB8020 是 Qt 库用 v141 编译而你工程用 v142或反之找不到头文件是 Qt VS Tools 的 Qt Version 路径配错常见是把路径指到了 Qt 的安装目录而不是 msvc2019_64 库目录。解决在“项目属性 → 常规 → 平台工具集”里明确选 Visual Studio 2019 (v142)和 Qt 库一致再到 Qt VS Tools → Qt Versions 里检查路径必须是D:\Qt\5.15.2\msvc2019_64而不是D:\Qt。改完清一遍解决方案再重新生成不要只按增量编译。6. 不连 PLC 先验证帧Python 回环脚本和三件套检查法6.1 回环脚本先验证帧PLC 不在手边或者现场设备在别人产线里跑着没法反复试。这种时候我用一个 Python 回环脚本在本地模拟 PLC 的 TCP 响应先用它验证客户端发帧和解析逻辑是不是自洽import socket, binascii srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((0.0.0.0, 6000)) # 与上位机 PLC 端口保持一致 srv.listen(1) conn, _ srv.accept() while True: data conn.recv(256) if not data: break print(recv:, binascii.hexlify(data, )) # 伪造一个结束码0000、带8个D值的响应 body bytes.fromhex( 500000ff0300000c000000010 0000000010203040506070809 ) conn.send(body)逻辑说明脚本不做协议解析只把收到的帧按十六进制打印出来再返回一个结构合法的 3E 响应。这个过程用来验证三件事客户端有没有发出完整帧、长度字段是否稳定、响应解析会不会崩。如果客户端本身帧构造错了回环脚本打印出来的十六进制一眼就能看出头部偏位。6.2 真机三件套检查回环脚本通过后再上真机。我每次连真机固定走三件套检查第一确认 PLC 的 IP 和端口能 ping 通telnet 端口能建立连接第二确认 PLC 侧通讯参数里的帧类型是 3E 帧还是 4E 帧Q 系列选 Qna-3EFX5U 选 4E选错连握手都过不去第三确认软元件地址没超范围D 区范围外的地址会回 C051 结束码。回环脚本还有个额外好处可以拿来做回归测试。改了解析代码先跑一遍回环再连真机能把“协议问题”和“现场问题”隔离开。从那以后我每次接到新 PLC 型号都强制先做一遍帧回环验证再上真机调参数这套流程帮我省掉过太多现场翻车的狼狈。希望帮到你。本文还有配套的精品资源点击获取