在 Qt 工业上位机开发中,Modbus 几乎是绕不开的通信协议。
读取 PLC 寄存器、控制变频器、采集温度传感器,很多项目都会使用 Qt 自带的
Qt Serial Bus模块。但真正开发过工业项目的人都知道:能读到寄存器,只是通信开发的第一步。
一、先搞懂 Qt Modbus 的核心架构
Qt Modbus 位于 Qt Serial Bus 模块中,核心类主要包括:
-
QModbusClient:客户端公共接口,负责发起 Modbus 请求。 -
QModbusReply:表示一次请求的异步响应结果。 -
QModbusServer:服务端接口,负责接收请求并提供寄存器数据。 -
QModbusRtuSerialClient:基于串口的 RTU 客户端。 -
QModbusTcpClient:基于 TCP 的客户端。
它们并不是几个互相独立的类,而是通过继承、异步任务管理和信号槽机制组成一套完整的通信体系。
cpp
业务层
│
▼
QModbusClient
公共请求与任务管理
│
┌───────┴────────┐
▼ ▼
QModbusRtuSerialClient QModbusTcpClient
│ │
串口通信 TCP通信
│ │
└───────┬────────┘
▼
QModbusReply
请求状态与结果通知
│
▼
业务回调处理
需要注意:QModbusReply 并不是底层 Socket 或串口对象,而是一次请求对应的结果载体。不同 Qt 版本的内部任务管理和具体实现可能有所变化,分析源码时应以项目实际使用的 Qt 版本为准。
二、QModbusClient:为什么请求是异步的?
先看最常见的读取寄存器代码:
cpp
QModbusDataUnit unit(
QModbusDataUnit::HoldingRegisters,
0,
10
);
QModbusReply *reply =
client->sendReadRequest(unit, 1);
if (reply) {
connect(reply, &QModbusReply::finished,
this, [reply]() {
if (reply->error() ==
QModbusDevice::NoError) {
const auto data = reply->result();
qDebug() << "读取成功:"
<< data.values();
} else {
qWarning() << reply->errorString();
}
reply->deleteLater();
});
}
这段代码有三个关键点。
第一,sendReadRequest() 不等于同步读取。
调用该函数后,客户端会创建请求任务并将其交给底层通信实现处理。正常情况下,函数很快返回一个 QModbusReply,而不是等待设备把数据返回。
因此,调用线程不会因为等待 PLC 响应而主动阻塞。
第二,QModbusReply 是异步通信的关键。
一次请求通常会经历以下状态:
bash
创建请求
│
▼
发送请求
│
▼
等待设备响应
│
├── 收到正常响应 ──► 完成
│
├── 收到异常响应 ──► 错误完成
│
└── 超时或通信失败 ─► 错误完成
│
▼
finished()
具体状态和错误处理路径应以所用 Qt 版本的实现为准。
第三,finished() 是通知业务层的入口。
业务代码不需要自行轮询 Socket,也不必在界面线程中循环等待。
当请求结束后,通过信号槽读取结果即可。
这里有一个容易忽略的细节:sendReadRequest() 返回的指针可能为空,表示请求没有成功创建。对于已经创建的 Reply,也应当检查其是否已经完成,避免某些实现或调用场景下出现连接信号过晚的问题。
三、QModbusReply:请求结果应该如何管理?
在工业通信中,请求失败并不只有一种情况。
例如:
-
设备没有响应,等待超时。
-
串口被拔掉,底层通信失败。
-
从站返回 Modbus 异常码。
-
请求参数不合法。
-
目标设备已经断开连接。
Qt 使用 QModbusReply 统一承载请求结果。
常用接口包括:
| 接口 | 作用 |
|---|---|
isFinished() |
判断请求是否已经结束 |
error() |
获取请求错误类型 |
errorString() |
获取可读错误信息 |
result() |
获取正常响应的数据单元 |
rawResult() |
获取原始 Modbus 响应数据单元 |
finished() |
请求完成时发出的信号 |
errorOccurred() |
请求发生错误时的通知信号 |
实际可用接口及信号签名请以目标 Qt 版本的头文件为准。
这里要特别强调:错误通知和请求完成不是同一个概念。
业务层可以利用错误通知记录日志,但不应该在多个回调里重复释放同一个 Reply。更稳妥的方式是将资源释放统一放在请求结束处理流程中,并根据实际 Qt 版本确认信号触发顺序。
例如:
cpp
void handleReply(QModbusReply *reply)
{
if (!reply)
return;
if (!reply->isFinished())
return;
if (reply->error() ==
QModbusDevice::NoError) {
const auto result = reply->result();
emit readCompleted(result.values());
} else {
emit communicationError(
reply->errorString());
}
reply->deleteLater();
}
这段代码体现的是处理思路:将成功结果和错误信息统一转发到业务层。
在实际工程中,还需要考虑请求创建失败、请求取消、对象生命周期以及跨线程信号传递等问题。
四、QModbusServer:它与客户端有什么区别?
如果说 QModbusClient 负责主动发起请求,那么 QModbusServer 就负责响应客户端请求。
典型应用场景包括:
-
使用 PC 模拟 Modbus 从站。
-
为自动化测试提供虚拟设备。
-
将上位机数据映射成 Modbus 寄存器。
-
实现设备数据网关。
其数据模型通常通过 QModbusDataUnitMap 等接口管理不同类型的寄存器区域。
bash
Modbus Master
│
│ 读写寄存器请求
▼
QModbusServer
│
▼
寄存器数据模型
│
├── Coil
├── Discrete Input
├── Holding Register
└── Input Register
│
▼
返回 Modbus 响应
客户端的重点是请求调度、响应匹配和错误处理;服务端的重点则是请求接收、寄存器映射和协议响应。
不要把 QModbusServer 当成普通 TCP 服务端直接使用。它属于 Modbus 协议层,底层传输方式和服务器启动方式需要根据具体实现及配置确定。
五、RTU 与 TCP:真正的区别在哪里?
很多开发者认为 RTU 和 TCP 只是连接方式不同。实际上,它们在报文组织、传输时序和连接管理方面都有区别。
| 对比项 | RTU | TCP |
|---|---|---|
| 传输介质 | 串口 | TCP 网络 |
| 帧边界 | 依赖 RTU 帧格式和时序 | 依赖 MBAP 长度字段等协议规则 |
| 校验方式 | CRC16 | TCP Modbus 报文通常不使用 RTU CRC16 |
| 设备标识 | 从站地址 | Unit Identifier |
| 通信特点 | 需要考虑串口时序 | 需要考虑连接状态和 TCP 分包 |
| 典型故障 | 串口占用、线路干扰 | 断线、网络抖动、连接超时 |
RTU 的帧解析需要考虑帧间静默时间和串口收发节奏;TCP 则必须正确处理粘包与拆包,因为一次 readyRead() 不保证恰好收到一个完整 Modbus TCP 报文。
不过,使用 Qt 官方客户端时,这些底层细节大多由具体传输实现处理。业务层不应该再次手动拼接 RTU 帧或重复解析 MBAP 报文,否则很容易产生重复封装问题。
更合理的设计是:业务层只依赖 QModbusClient 公共接口,底层通过不同客户端实现适配 RTU 和 TCP。
六、实战:封装一个可复用的 Modbus 客户端基类
理解官方架构后,我们可以在其上面增加业务项目需要的统一接口。
这里不重新实现协议,而是采用组合方式持有 QModbusClient,将设备连接、寄存器读取、寄存器写入和错误通知统一封装。
这样既能复用 Qt 官方实现,也能避免业务代码直接依赖 RTU 或 TCP 的具体类型。
1. 定义公共接口
cpp
// ModbusService.h
#pragma once
#include <QObject>
#include <QModbusClient>
#include <QModbusDataUnit>
#include <QModbusReply>
#include <memory>
class ModbusService : public QObject
{
Q_OBJECT
public:
explicit ModbusService(
QModbusClient *client,
QObject *parent = nullptr)
: QObject(parent),
m_client(client)
{
Q_ASSERT(m_client);
connect(m_client,
&QModbusDevice::stateChanged,
this,
[this](QModbusDevice::State state) {
emit connectionStateChanged(state);
});
connect(m_client,
&QModbusDevice::errorOccurred,
this,
[this](QModbusDevice::Error error) {
emit deviceError(
error,
m_client->errorString());
});
}
bool connectDevice()
{
return m_client->connectDevice();
}
void disconnectDevice()
{
m_client->disconnectDevice();
}
bool isConnected() const
{
return m_client->state()
== QModbusDevice::ConnectedState;
}
void setTimeout(int timeout)
{
m_client->setTimeout(timeout);
}
void setNumberOfRetries(int retries)
{
m_client->setNumberOfRetries(retries);
}
QModbusReply *readHoldingRegisters(
int serverAddress,
int startAddress,
quint16 count)
{
QModbusDataUnit unit(
QModbusDataUnit::HoldingRegisters,
startAddress,
count);
return m_client->sendReadRequest(
unit, serverAddress);
}
QModbusReply *writeSingleRegister(
int serverAddress,
int address,
quint16 value)
{
QModbusDataUnit unit(
QModbusDataUnit::HoldingRegisters,
address,
1);
unit.setValue(0, value);
return m_client->sendWriteRequest(
unit, serverAddress);
}
signals:
void connectionStateChanged(
QModbusDevice::State state);
void deviceError(
QModbusDevice::Error error,
const QString &message);
private:
QModbusClient *m_client = nullptr;
};
这份代码展示的是基础封装思路,正式项目还应补充参数范围校验、Reply 生命周期管理和请求完成通知。
这里选择组合而不是继承 QModbusClient,是因为业务服务的职责是统一封装和管理官方客户端,而不是重新定义底层协议实现。
另外,m_client 的所有权需要明确:上面的构造函数不接管传入对象的所有权。实际使用时应保证客户端对象的生命周期长于服务对象,或者将所有权管理设计成统一的 RAII 方案。
2. RTU 与 TCP 如何复用?
业务层不需要关心当前使用的是串口还是网络,只需要传入不同的官方客户端实例。
RTU 示例:
cpp
#include <QModbusRtuSerialClient>
auto *client = new QModbusRtuSerialClient(this);
client->setConnectionParameter(
QModbusDevice::SerialPortNameParameter,
QStringLiteral("COM3"));
client->setConnectionParameter(
QModbusDevice::SerialBaudRateParameter,
QSerialPort::Baud115200);
client->setTimeout(1000);
client->setNumberOfRetries(2);
auto *service = new ModbusService(client, this);
if (!service->connectDevice()) {
qWarning() << client->errorString();
}
TCP 示例:
cpp
#include <QModbusTcpClient>
auto *client = new QModbusTcpClient(this);
client->setConnectionParameter(
QModbusDevice::NetworkAddressParameter,
QStringLiteral("192.168.1.100"));
client->setConnectionParameter(
QModbusDevice::NetworkPortParameter,
502);
client->setTimeout(1000);
client->setNumberOfRetries(2);
auto *service = new ModbusService(client, this);
if (!service->connectDevice()) {
qWarning() << client->errorString();
}
示例使用了 QSerialPort 的枚举,因此 RTU 代码还需要包含相应头文件。串口校验位、数据位、停止位等参数也应按实际设备配置补齐。
注意:这里的 serverAddress 在 RTU 场景中通常对应从站地址,在 TCP 场景中则用于指定 Unit Identifier。实际配置必须与目标设备一致。
3. 业务层怎样调用?
cpp
auto *reply =
service->readHoldingRegisters(1, 0, 10);
if (!reply) {
qWarning() << "请求创建失败";
return;
}
connect(reply, &QModbusReply::finished,
this, [reply]() {
if (reply->error() ==
QModbusDevice::NoError) {
qDebug() << "寄存器数据:"
<< reply->result().values();
} else {
qWarning() << "Modbus请求失败:"
<< reply->errorString();
}
reply->deleteLater();
});
通过这种方式,业务层只需要关心设备地址、寄存器地址、数据长度和结果处理,不必重复编写串口与 TCP 的底层通信代码。
如果项目还需要统一管理所有请求,建议继续封装 RequestManager,集中处理超时、请求队列、并发限制、日志、取消和设备离线策略。
七、真正的工业级框架,还差哪几步?
上面的封装解决了接口统一问题,但还不能直接称为完整的工业级通信引擎。
在真实项目中,至少还需要考虑以下四件事。
第一,请求队列与并发限制。
同一个串口上的 RTU 通信通常需要遵循严格的请求响应时序。不能简单地把所有业务请求无限并发发送出去。
应根据底层实现的任务调度机制管理请求,并避免重复实现官方客户端已经提供的排队逻辑。
第二,超时与重试策略。
setTimeout() 和 setNumberOfRetries() 可以配置基础超时与重试行为,但不同设备的响应速度、网络质量和控制指令性质不同,不能一律无限重试。
尤其是写操作,超时不一定代表设备没有执行成功。对于具有副作用的控制指令,应设计必要的状态确认机制,避免盲目重复执行。
第三,设备状态管理。
连接成功不代表设备永远在线。建议增加设备心跳、离线判定、重连退避策略和状态事件,避免网络故障后持续进行无效请求。
第四,统一日志与故障定位。
工业项目需要记录设备地址、功能码、请求时间、响应耗时、错误类型以及重试次数。出现问题时,才能区分设备故障、线路故障、参数错误和上层业务异常。
八、总结
Qt Modbus 最值得学习的地方,不只是提供了几个可以调用的 API,而是通过公共客户端接口、异步 Reply 对象和不同传输实现,将协议请求与底层通信进行了分层。
理解这套设计之后,我们就不必为 RTU 和 TCP 分别编写一套业务逻辑,也不必重复造一个协议解析引擎。