
简介《QModbus TCP模式综合操作(二)》是一份面向Qt开发者和工业自动化初学者的Modbus TCP通信实践资源。压缩包基于RTUMasterTest示例工程通过16个文件完整呈现QModbus客户端在TCP模式下的连接建立、保持寄存器读写及错误处理等核心操作。资源包含4个cpp源文件、3个头文件、2个ui界面设计文件以及配套的png图片、qrc资源和pro工程配置便于直接打开工程查看代码与界面。整个压缩包仅190KB轻量易用。目前已有1360人学习下载。通过该工程读者可以快速理解QModbusTcpClient的调用流程掌握readRegisters、writeRegisters等关键接口的实际用法并参考项目自带的界面交互与寄存器模型实现为后续开发更复杂的工业通信功能提供可复用的代码框架。 最近帮客户调试一套设备上位机核心任务就一个Qt写的界面程序通过Modbus TCP协议去读三菱FX5U PLC里的生产数据同时支持下发参数。按老套路本来想用QTcpSocket自己拼报文搞到一半发现事务ID管理、超时重试、断线重连这些状态逻辑太磨人果断换成QModbus库。测试下来这一换至少省了两三天的调试量。这篇文章把QModbus TCP模式从零到可用的全套操作写出来包括连接、块读、写单点、写多点、定时轮询、断线重连以及几个实际踩过的坑。适合正在用Qt做上位机、需要对接PLC、仪表或者网关协议的开发者。不管你手里是信捷、三菱还是昆仑通态的屏这套写法的思路都是通用的。1. Modbus TCP协议基础与QModbus选型理由1.1 一帧完整的Modbus TCP报文长什么样Modbus TCP的报文结构分两块MBAP头加PDU。MBAP一共7个字节按顺序是事务标识符2字节、协议标识符2字节、报文长度2字节、单元标识符1字节。PDU部分才是真正的功能码和数据。事务标识符每次请求递增用来匹配响应这是QModbus内部自动处理的。协议标识符固定是00 00表示Modbus协议。报文长度表示从单元标识符开始到报文末尾的字节数。单元标识符就是常说的从站地址或者叫Unit ID这个值在TCP模式下仍然存在后面讲连接参数时会特别提它。如果抓包看到这样一串十六进制00 01 00 00 00 06 01 03 00 00 00 0A你要能读出来事务ID为0001协议标识符00 00长度6单元标识符01功能码03起始地址0000读10个寄存器。能看懂这一帧后面调试起码不会懵。1.2 寄存器类型与功能码对照Modbus协议里常用的功能码就那几条功能码含义QModbus中的寄存器类型01读线圈Coils02读离散输入DiscreteInputs03读保持寄存器HoldingRegisters04读输入寄存器InputRegisters05写单个线圈Coils06写单个保持寄存器HoldingRegisters15写多个线圈Coils16写多个保持寄存器HoldingRegisters绝大多数采集场景你只需要HoldingRegisters就够了。PLC的D寄存器、字软元件仪表里的参数基本都映射在保持寄存器区。功能码03是读保持寄存器功能码06和16分别是写单个和写多个保持寄存器。把这三个搞定日常80%的需求就覆盖了。1.3 为什么用QModbus而不是手写协议栈有人觉得Modbus报文又不复杂用QTcpSocket拼一下就行了。真做一遍就知道协议帧简单麻烦的是状态管理。请求发出去后要等响应超时要重试事务ID要自增TCP粘包要处理断线了要重连每一条写起来都不难但凑在一起就是一大坨容易出bug的逻辑。QModbus是Qt官方维护的库内部实现了完整的状态机。sendReadRequest发出去之后返回一个QModbusReply对象用法跟QNetworkAccessManager的reply差不多异步信号拿结果。这套东西比自己拼socket省心太多我强烈建议别重复造轮子。2. 工程搭建与通信类基础封装2.1 工程配置与版本注意事项QModbus属于Qt的SerialBus模块。在Qt 5.8之前这个模块还不成熟5.12之后才稳定下来现在用Qt 5.15或Qt 6都完全没压力。qmake工程加上serialbusQT core gui widgets serialbusCMake工程对应写成find_package(Qt6 REQUIRED COMPONENTS Widgets SerialBus) target_link_libraries(app PRIVATE Qt6::Widgets Qt6::SerialBus)如果用的是嵌入式交叉编译环境或者国产芯片自带的Qt第一件事确认serialbus模块编进去了。不然编译能过链接时报一堆undefined reference那真是浪费时间。2.2 封装一个ModbusTcpWorker通信类我习惯把整个Modbus TCP通信封装成一个独立类界面只管调用接口不管协议细节。这里给出一个基础框架#ifndef MODBUSTCPWORKER_H #define MODBUSTCPWORKER_H #include QObject #include QModbusTcpClient #include QModbusDataUnit class ModbusTcpWorker : public QObject { Q_OBJECT public: explicit ModbusTcpWorker(const QString ip, quint16 port 502, QObject *parent nullptr); void connectDevice(); void disconnectDevice(); bool readHoldingRegisters(int startAddress, int count); bool writeSingleRegister(int address, quint16 value); bool writeMultipleRegisters(int startAddress, const QVectorquint16 values); signals: void connected(); void disconnected(); void errorOccurred(const QString message); void readFinished(int startAddress, const QVectorquint16 values); void writeFinished(bool ok, const QString message); private slots: void onStateChanged(QModbusDevice::State state); void onErrorOccurred(QModbusDevice::Error error); void onReadReady(); void onWriteFinished(); private: QModbusTcpClient *m_modbus nullptr; QString m_ip; quint16 m_port; int m_unitId 1; bool m_busy false; }; #endif // MODBUSTCPWORKER_H构造函数里只需要创建客户端对象连接状态和错误信号ModbusTcpWorker::ModbusTcpWorker(const QString ip, quint16 port, QObject *parent) : QObject(parent), m_ip(ip), m_port(port) { m_modbus new QModbusTcpClient(this); connect(m_modbus, QModbusClient::stateChanged, this, ModbusTcpWorker::onStateChanged); connect(m_modbus, QModbusClient::errorOccurred, this, ModbusTcpWorker::onErrorOccurred); }2.3 连接参数与状态信号QModbusTcpClient和普通的QTcpSocket不一样它不是connectToHost直接连而是通过setConnectionParameter设置参数再调connectDevice。IP地址和端口就两个参数void ModbusTcpWorker::connectDevice() { if (!m_modbus) return; m_modbus-setConnectionParameter( QModbusDevice::NetworkAddressParameter, m_ip); m_modbus-setConnectionParameter( QModbusDevice::NetworkPortParameter, m_port); m_modbus-setTimeout(1000); m_modbus-setNumberOfRetries(2); m_modbus-connectDevice(); }setTimeout和setNumberOfRetries控制的是请求响应超时和重试次数不是TCP连接的超时。这两个值建议在1000毫秒和2次左右太短了从站稍慢就误报超时太长了界面卡顿明显。连接状态变化通过stateChanged信号通知。QModbusDevice有ConnectingState、ConnectedState、UnconnectedState三种主要状态其中UnconnectedState既包括主动断开也包括连接失败和异常掉线。3. 核心操作一次讲透读、写、轮询、重连3.1 建立TCP连接与参数细节connectDevice是异步操作返回后连接可能还没建立成功。连接结果要通过stateChanged信号来感知void ModbusTcpWorker::onStateChanged(QModbusDevice::State state) { if (state QModbusDevice::ConnectedState) { emit connected(); } else if (state QModbusDevice::UnconnectedState) { emit disconnected(); } } void ModbusTcpWorker::onErrorOccurred(QModbusDevice::Error error) { if (error ! QModbusDevice::NoError) { emit errorOccurred(m_modbus-errorString()); } }这里有一个容易忽略的点QModbusDevice::UnconnectedState在连接失败、网络断开、主动disconnect时都会触发。不要在这个信号里只做界面提示要加上重连逻辑后面专门讲。Modbus TCP的标准端口是502。但有些设备厂商会改成5020、1502这类非常规端口连接前先和设备手册确认端口。3.2 读保持寄存器异步响应解析读取保持寄存器用sendReadRequest核心是构造一个QModbusDataUnitbool ModbusTcpWorker::readHoldingRegisters(int startAddress, int count) { if (!m_modbus || m_busy) return false; if (m_modbus-state() ! QModbusDevice::ConnectedState) return false; QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, startAddress, count); if (auto *reply m_modbus-sendReadRequest(unit, m_unitId)) { if (reply-isFinished()) { reply-deleteLater(); return false; } connect(reply, QModbusReply::finished, this, ModbusTcpWorker::onReadReady); m_busy true; return true; } emit errorOccurred(QStringLiteral(发送读请求失败)); return false; }m_unitId就是前面说的单元标识符TCP直连单从站时通常设为1。但这不是绝对的信捷有些型号默认是255三菱FX5U默认是1。如果从站一直没响应先检查这个值对不对。我踩过一次坑信捷PLC配的是255代码里写死1抓包看到请求发出去了从站就是不回。响应解析在onReadReady里void ModbusTcpWorker::onReadReady() { auto *reply qobject_castQModbusReply *(sender()); if (!reply) { m_busy false; return; } if (reply-error() QModbusDevice::NoError) { const QModbusDataUnit result reply-result(); emit readFinished(result.startAddress(), result.values()); } else { emit errorOccurred(QStringLiteral(读寄存器失败: %1) .arg(reply-errorString())); } m_busy false; reply-deleteLater(); }注意sendReadRequest的返回值可能是nullptr也可能是已经finished的reply这两种情况都要处理。漏掉isFinished分支在个别Qt版本上会踩到野指针。3.3 写操作单个与批量寄存器写入写单个保持寄存器直接构造QModbusDataUnit长度1bool ModbusTcpWorker::writeSingleRegister(int address, quint16 value) { QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address, 1); unit.setValue(0, value); if (auto *reply m_modbus-sendWriteRequest(unit, m_unitId)) { connect(reply, QModbusReply::finished, this, ModbusTcpWorker::onWriteFinished); return true; } return false; }一次写多个连续寄存器values是QVector 直接传给QModbusDataUnit的构造函数bool ModbusTcpWorker::writeMultipleRegisters(int startAddress, const QVectorquint16 values) { QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, startAddress, values.size()); for (int i 0; i values.size(); i) unit.setValue(i, values.at(i)); if (auto *reply m_modbus-sendWriteRequest(unit, m_unitId)) { connect(reply, QModbusReply::finished, this, ModbusTcpWorker::onWriteFinished); return true; } return false; }写完成信号的处理void ModbusTcpWorker::onWriteFinished() { auto *reply qobject_castQModbusReply *(sender()); if (!reply) return; if (reply-error() QModbusDevice::NoError) { emit writeFinished(true, QStringLiteral(写入成功)); } else { emit writeFinished(false, reply-errorString()); } reply-deleteLater(); }经常有人问Modbus TCP怎么实现又读又写。同一连接上读写随便穿插QModbus内部靠事务ID匹配每一个响应不存在读完才能写的限制。实际工程里轮询读数据用定时器驱动写操作由界面事件触发两者互不干扰。3.4 定时轮询读写的节奏怎么控制轮询最忌讳的做法是定时器到点就发请求完全不管上一次有没有回来。Modbus是请求响应式协议同一时刻只应有一个请求在途。要让读写稳定必须加一个busy标志位控制// 在连接建立后启动轮询定时器 m_pollTimer new QTimer(this); m_pollTimer-setInterval(500); connect(m_pollTimer, QTimer::timeout, this, [this]() { if (!m_busy) readHoldingRegisters(0, 20); });轮询周期建议在100毫秒到1秒之间视从站负载来定。那台FX5U我用的500毫秒读20个连续寄存器跑一整天非常稳。有些仪表响应慢就要适当拉长周期把setTimeout也调大。还有一点经验读连续寄存器用块读一次读一大段比逐点读效率高得多。读20个浮点数逐点要20次往返块读一次搞定差距是十倍以上。3.5 断线重连不要用固定频率死磕PLC重启、网线被踢、交换机掉电各种意外都会导致连接断开。重连策略我建议用指数退避第一次等1秒第二次2秒第三次4秒最多16秒封顶。理由很简单从站重启可能要几十秒你用1秒一次的频率死磕除了刷爆日志没有任何意义。void ModbusTcpWorker::onStateChanged(QModbusDevice::State state) { if (state QModbusDevice::ConnectedState) { m_retryCount 0; m_reconnectTimer-stop(); emit connected(); } else if (state QModbusDevice::UnconnectedState) { scheduleReconnect(); emit disconnected(); } } void ModbusTcpWorker::scheduleReconnect() { if (m_retryCount 5) { int delay 1000 * (1 m_retryCount); m_reconnectTimer-start(delay); m_retryCount; } }m_reconnectTimer是QTimersingleShot模式超时后调connectDevice。m_retryCount在setConnectionParameter之后要重置不然重连间隔越拉越长。3.6 32位浮点与大小端问题Modbus寄存器长度是16位一个32位浮点数要占两个寄存器。Modbus协议本身规定高位在前也就是大端模式。但不同厂商对32位数据的排列理解不一样常见两种// 寄存器数组 quint16 regs[2] { 0x4120, 0x0000 }; // 方式一大端排列 ABCD高字在前 quint32 abcd (static_castquint32(regs[0]) 16) | regs[1]; // 方式二小端排列 CDAB低字在前 quint32 cdab (static_castquint32(regs[1]) 16) | regs[0]; // 转浮点 float value 0.0f; memcpy(value, abcd, sizeof(value));Qt提供了一套辅助函数专门处理这类字节序#include QtEndian quint32 raw qFromBigEndianquint32( reinterpret_castconst uchar *(regs)); float value 0.0f; memcpy(value, raw, sizeof(value));三菱、西门子、施耐德、信捷各自的排列方式不完全一样现场调试时先用Modbus Poll或者串口助手确认一遍排列再写死到代码里省得反复猜。4. 调试现场高频异常与排查办法4.1 常见问题速查表实际调试中反复遇到的问题整理成一张表现象可能原因排查方式connectDevice后状态一直没变化IP不通、端口被防火墙拦截pingtelnet测端口抓包请求发出去一直超时单元标识符不对、从站没开Modbus服务Wireshark看请求到没到从站errorOccurred提示连接被重置从站连接数满了、从站重启检查从站连接数稍等重连bind: only one usage of each socket address端口被上一个进程占用netstat查端口杀掉残留进程数据读到了但数值明显不对地址错误、浮点大小端不对用Modbus Poll对拍轮询偶发错误码0x02该地址不支持当前功能码查设备寄存器映射表4.2 端口被占用的经典报错有个很经典的问题在群里被问过很多次报错长这样error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address虽然是别的场景碰到的但原理完全通用端口被占用一个端口同一时刻只允许一个进程监听。开发上位机时最常见的情况是上一个调试进程没退出或者后台还有个测试程序占着502端口。排查方法很固定Windows下用netstatLinux下用ssnetstat -ano | findstr 502看出PID之后到任务管理器结束进程。但往往更隐蔽的情况是程序崩了端口还在TIME_WAIT状态。快速重启程序时会遇到bind失败解决办法是缩短短时间内的重启间隔或者代码里设置SO_REUSEADDR。QModbus在这一层没暴露底层socket选项但TCP/Unix下TIME_WAIT超时一般是几十秒等一会儿重连就好。4.3 用Wireshark验证问题出在哪一侧通信调不通第一反应别改代码先抓包。Wireshark开起来过滤条件写成tcp.port 502 modbus这样能看到两个关键信息请求有没有从本机发出去、从站有没有回响应。请求没发出去问题在Qt程序侧检查参数、连接状态。请求发出去了没有响应问题在从站侧查单元标识符、查从站设置、查网络。有响应但程序报超时检查响应时间衰减TCP延时QModbus的timeout设置太短就会误报。抓包能力可以说是Modbus调试的基本功磨刀不误砍柴工。4.4 从站连接数限制这个隐藏坑有个藏得很深的坑PLC作为Modbus TCP从站时往往限制并发客户端数量。三菱FX5U我记得最多允许几个客户端连接有些低端网关甚至只允许一个。如果上位机反复重连断开旧的连接没释放干净新连接就挤不进去表现就是连上了但没响应或者直接连接被重置。处理办法分两层从站侧把空闲超时改短让旧连接尽快释放上位机侧避免频繁重连重连间隔不要一直卡在1秒以下。从站重启后旧连接立即失效这时把重连间隔拉高几秒让从站有时间清干净连接状态再重新连成功率会高很多。5. 实测体会与下一步玩法5.1 日志和抓包配合调试效率翻倍调试这类通信程序最怕把错误吞掉只留个界面提示。我的习惯是在发送和接收侧各打一条日志包括事务ID、功能码、寄存器地址、数据长度和每个寄存器值。日志格式尽量和Wireshark对照起来出了问题两头一比对几分钟定位。给ModbusTcpWorker加日志很简单发送请求前qInfo打一条onReadReady解析结果后打一条onWriteFinished里打一条。一旦客户现场出问题把日志发回来大部分问题不用远程也能定位。5.2 从单站扩展到多站单从站跑通了多从站也没多大改动。QModbus同一客户端可以设置unitId后发送请求一个TCP连接轮询多个从站单元。不过实际工程中IP不同的多个从站就得拆成多个QModbusTcpClient实例每个实例一套读写逻辑。可以把前面的ModbusTcpWorker简单封装成一个管理器按IP加端口管理worker实例。5.3 RTU与TCP的切换红利QModbus最舒服的一点是上层读写逻辑完全一致。如果哪天设备改用串口Modbus RTU了把QModbusTcpClient换成QModbusRtuSerialMaster设置串口号、波特率和parity上层所有读写代码一行不用改。因为读写都是基于QModbusDataUnit和QModbusReply不关心底层走的是什么传输方式。当时选型的时候就是看中这个抽象层后来客户临时要求加一台只有RS485口的老仪表迁移成本几乎是零这才体会到官方库的好处。最后再分享一个小细节程序退出时记得调disconnectDevice并等待信号然后deleteLater释放QModbusReply。别小看这一步很多诡异的重连失败都是程序反复启停时旧连接没清理干净导致的。通信程序稳定性本来就是一点点细节堆出来的把连接生命周期管理好比你写多少重试逻辑都管用。本文还有配套的精品资源点击获取