在 Qt 工业上位机开发中,使用
QModbusClient读取 PLC 寄存器并不复杂,真正麻烦的是请求发出去之后发生的事情。
比如:
-
PLC 暂时没有响应,请求一直处于等待状态。
-
通信超时后自动重试,结果旧请求和新请求产生了交错。
-
用户切换设备或关闭窗口,之前的请求仍然没有结束。
-
QModbusReply已经销毁,但某个定时器还在触发。 -
连续点击读取按钮,同一个寄存器被重复请求,设备通信负载越来越高。
这些问题单独看都不大,但在需要连续运行、频繁读写寄存器的工业上位机中,很容易演变成难以复现的通信故障。
工程化封装的核心,不是简单地给请求加一个定时器,而是统一管理请求的生命周期。
本文基于 Qt Serial Bus 模块,设计一个具备超时、有限重试、请求取消和资源安全释放能力的 Modbus 请求管理器。
一、先明确:QModbusReply 不是请求管理器
Qt 已经提供了 QModbusClient 和 QModbusReply,为什么还要再封装一层?
因为它们解决的问题不同。
-
QModbusClient:负责发起 Modbus 请求。 -
QModbusReply:表示一次异步请求的响应结果。 -
ModbusRequestManager:负责业务请求的状态、超时、重试、取消以及资源清理。
推荐的架构如下:
bash
Business Service
│
▼
ModbusRequestManager
│
┌─────────┴─────────┐
▼ ▼
Request State Timeout Timer
│ │
└─────────┬─────────┘
▼
QModbusClient
│
▼
QModbusReply
│
▼
PLC / Modbus Device
业务层只需要提交请求、接收结果,不需要关心底层 QModbusReply 如何销毁,也不需要自己创建和管理超时定时器。
这才是封装的价值:把分散在业务代码里的异步控制逻辑,集中到一个可维护的组件中。
二、设计请求状态机
先定义请求可能经历的状态。
bash
创建请求
│
▼
Pending
│
▼
Sending
│
▼
Waiting
│
├──────────────► Completed
│
├──────────────► Failed
│
└──────────────► 重新发送
其中:
| 状态 | 含义 |
|---|---|
| Pending | 请求已创建,等待发送 |
| Sending | 正在提交底层请求 |
| Waiting | 等待设备响应 |
| Completed | 请求成功完成 |
| Failed | 请求失败或超出重试次数 |
实际工程中,还可以增加 Cancelled 状态,专门表示请求被主动取消。
需要注意,QModbusClient 的请求发送接口本身是异步的。调用 sendReadRequest() 后,通常会立即返回一个 QModbusReply,并不意味着设备已经收到请求。
因此,Sending 主要表示正在提交请求;拿到有效的 QModbusReply 后,便可以进入 Waiting。
请求对象保存哪些信息?
每个请求至少需要记录以下数据:
cpp
struct RequestContext
{
quint64 id = 0;
int retryCount = 0;
int maxRetries = 2;
int timeoutMs = 1000;
RequestState state = RequestState::Pending;
};
这里的 maxRetries = 2 表示初次请求失败后,最多再尝试两次,总共最多发送三次。
实际项目还需要根据业务补充从站地址、功能码、寄存器地址、请求数据、响应处理回调等信息。
请求 ID 用于区分不同业务请求,尤其适合日志追踪和取消操作。
三、实现统一的 Modbus 请求管理器
下面使用 Qt 6 和 QModbusTcpClient 演示核心实现。代码重点放在读取保持寄存器的请求生命周期上。
项目需要链接 Qt Serial Bus 模块。
cpp
find_package(Qt6 REQUIRED COMPONENTS Core SerialBus)
target_link_libraries(MyApp PRIVATE
Qt6::Core
Qt6::SerialBus
)
1. 定义请求状态与管理器
cpp
#include <QObject>
#include <QModbusTcpClient>
#include <QModbusReply>
#include <QModbusDataUnit>
#include <QTimer>
#include <QHash>
#include <QPointer>
enum class RequestState
{
Pending,
Sending,
Waiting,
Completed,
Failed,
Cancelled
};
class ModbusRequestManager : public QObject
{
Q_OBJECT
public:
explicit ModbusRequestManager(
QModbusTcpClient *client,
QObject *parent = nullptr);
quint64 readHoldingRegisters(
int serverAddress,
int startAddress,
quint16 count,
int timeoutMs = 1000,
int maxRetries = 2);
void cancelRequest(quint64 id);
signals:
void requestFinished(
quint64 id,
bool success,
const QString &message);
private:
struct Request
{
quint64 id = 0;
int serverAddress = 1;
int startAddress = 0;
quint16 count = 1;
int timeoutMs = 1000;
int maxRetries = 2;
int retryCount = 0;
RequestState state = RequestState::Pending;
QPointer<QModbusReply> reply;
QTimer *timer = nullptr;
};
void send(Request &request);
void onTimeout(quint64 id);
void finish(
quint64 id,
bool success,
const QString &message);
QPointer<QModbusTcpClient> m_client;
QHash<quint64, Request> m_requests;
quint64 m_nextId = 1;
};
这里有两个值得注意的设计。
第一,使用 QPointer<QModbusReply> 记录回复对象。与普通裸指针相比,当被观察的 QObject 销毁时,QPointer 会自动变为空指针。
第二,每个请求拥有独立的定时器,但由管理器统一创建、停止和销毁。请求完成后,不允许定时器继续参与后续流程。
需要强调:QPointer 只用于观察对象是否存在,不能代替线程安全机制,也不能保证跨线程调用安全。
2. 初始化管理器
cpp
ModbusRequestManager::ModbusRequestManager(
QModbusTcpClient *client,
QObject *parent)
: QObject(parent),
m_client(client)
{
}
这里约定 client 由外部创建并管理生命周期,管理器不接管它的所有权。
例如:
cpp
auto *client = new QModbusTcpClient(this);
auto *manager =
new ModbusRequestManager(client, this);
两者均由上层对象管理,避免多个组件同时认为自己拥有同一个客户端。
所有权必须明确:谁创建、谁负责释放;谁只是使用,谁就不应随意销毁。
3. 创建请求并启动发送
cpp
quint64 ModbusRequestManager::readHoldingRegisters(
int serverAddress,
int startAddress,
quint16 count,
int timeoutMs,
int maxRetries)
{
if (!m_client ||
count == 0 ||
timeoutMs <= 0 ||
maxRetries < 0) {
return 0;
}
const quint64 id = m_nextId++;
Request request;
request.id = id;
request.serverAddress = serverAddress;
request.startAddress = startAddress;
request.count = count;
request.timeoutMs = timeoutMs;
request.maxRetries = maxRetries;
request.state = RequestState::Pending;
request.timer = new QTimer(this);
request.timer->setSingleShot(true);
connect(request.timer, &QTimer::timeout,
this, [this, id]() {
onTimeout(id);
});
m_requests.insert(id, request);
send(m_requests[id]);
return id;
}
这里使用单次定时器,而不是周期性定时器。
原因很简单:一次请求只需要一个超时事件。请求完成、失败或取消时,定时器就应该停止。
同时,定时器回调只捕获请求 ID,不捕获 Request 的引用。因为请求保存在 QHash 中,插入、删除操作可能使引用失效。
这类细节很容易被忽略,却是异步代码稳定性的关键。
4. 发送请求并监听响应
cpp
void ModbusRequestManager::send(Request &request)
{
if (!m_client) {
finish(request.id, false,
QStringLiteral("Modbus client 已销毁"));
return;
}
QModbusDataUnit unit(
QModbusDataUnit::HoldingRegisters,
request.startAddress,
request.count);
request.state = RequestState::Sending;
QModbusReply *reply =
m_client->sendReadRequest(
unit, request.serverAddress);
if (!reply) {
// 无法创建请求对象,按失败处理
const quint64 id = request.id;
finish(id, false,
QStringLiteral("发送请求失败:%1")
.arg(m_client->errorString()));
return;
}
request.reply = reply;
request.state = RequestState::Waiting;
const quint64 id = request.id;
connect(reply, &QModbusReply::finished,
this, [this, id, reply]() {
auto it = m_requests.find(id);
if (it == m_requests.end()) {
return;
}
Request &req = it.value();
if (req.state != RequestState::Waiting ||
req.reply != reply) {
return;
}
req.timer->stop();
if (reply->error() == QModbusDevice::NoError) {
finish(id, true,
QStringLiteral("读取成功"));
} else {
finish(id, false,
reply->errorString());
}
});
request.timer->start(request.timeoutMs);
}
这里有三个重点。
第一,先检查 QModbusReply 是否创建成功。
sendReadRequest() 可能返回空指针。请求对象都没有创建成功,就不能继续等待响应。
第二,启动定时器后,必须有明确的完成路径。
正常响应通过 finished 信号处理;超时则由定时器处理。
**第三,必须校验当前回复对象。**
cpp
if (req.reply != reply) {
return;
}
当请求经历重试后,旧回复的延迟信号不能再影响新一轮请求。
不过,这并不能替代完整的回复对象生命周期管理。对于可能被取消或超时的请求,还需要在清理流程中断开相关连接,并明确处理旧回复。
另外,以上代码是生命周期演示骨架:生产实现应将请求从 QHash 中取出后再发出业务完成信号,并避免在 finish() 内部删除请求时继续访问此前取得的引用。
四、超时与有限重试:不要无限发送
工业通信中的超时并不罕见。
可能是 PLC 忙碌,也可能是网络丢包、设备断线,或者请求本身不符合设备协议。
但超时后无限重试,会让系统陷入另一个问题:设备越忙,请求越多;请求越多,设备越难恢复。
因此必须设置重试上限。
cpp
void ModbusRequestManager::onTimeout(quint64 id)
{
auto it = m_requests.find(id);
if (it == m_requests.end()) {
return;
}
Request &request = it.value();
if (request.state != RequestState::Waiting) {
return;
}
if (request.reply) {
disconnect(request.reply, nullptr, this, nullptr);
request.reply->deleteLater();
request.reply = nullptr;
}
if (request.retryCount < request.maxRetries) {
++request.retryCount;
request.state = RequestState::Pending;
send(request);
return;
}
finish(id, false,
QStringLiteral("请求超时,已达到最大重试次数"));
}
这段代码体现了重试策略:
-
第一次请求超时:尝试重发。
-
第二次请求超时:继续尝试,前提是重试次数尚未用尽。
-
达到上限:结束请求,通知业务层。
但这里还有一个工业通信必须考虑的问题:超时不等于设备一定没有执行请求。
对于读取寄存器,重试通常比较容易接受。
对于写寄存器、启动设备、复位控制器等操作,重试可能造成重复执行或状态变化。因此,写请求必须根据具体功能、设备协议和业务语义制定重试策略,不能把所有请求都当成可安全重放的操作。
此外,示例中的重试逻辑属于简化实现。正式版本还应统一处理立即发送失败、底层错误响应、连接断开等情况,并将可重试错误与不可重试错误区分开来。
五、请求取消:不能只停止定时器
假设用户切换了设备页面,或者上层业务已经不再需要某次读取结果。
此时只调用:
cpp
request.timer->stop();
是不够的。
因为底层请求可能仍然处于活动状态,QModbusReply 也可能继续发出信号。
取消操作至少需要完成以下工作:
-
将请求标记为
Cancelled。 -
停止超时定时器。
-
断开回复对象与管理器之间的信号连接。
-
根据 Qt 对象生命周期规则处理回复对象。
-
移除请求记录。
-
阻止迟到的响应触发业务完成回调。
一个基础实现如下:
cpp
void ModbusRequestManager::cancelRequest(quint64 id)
{
auto it = m_requests.find(id);
if (it == m_requests.end()) {
return;
}
Request &request = it.value();
request.state = RequestState::Cancelled;
if (request.timer) {
request.timer->stop();
}
if (request.reply) {
disconnect(request.reply, nullptr, this, nullptr);
request.reply->deleteLater();
request.reply = nullptr;
}
finish(id, false,
QStringLiteral("请求已取消"));
}
deleteLater() 只是安排对象在事件循环的安全时机销毁,并不等于已经取消了底层传输操作。
如果要实现严格的取消语义,应结合当前 Qt 版本的 QModbusReply 文档和实现,确认是否存在可用的取消能力;若没有,就应将业务取消与底层传输终止分开处理。
例如:业务上立即取消,不再接受结果,但底层请求可能仍需等待结束或客户端断开后才能彻底清理。
六、统一释放资源,避免定时器和请求记录泄漏
请求管理器最重要的原则之一是:所有终态都走同一个清理入口。
成功、失败、超时耗尽、主动取消,都不能各自维护一套不一致的销毁逻辑。
cpp
void ModbusRequestManager::finish(
quint64 id,
bool success,
const QString &message)
{
auto it = m_requests.find(id);
if (it == m_requests.end()) {
return;
}
Request request = it.value();
if (request.timer) {
request.timer->stop();
request.timer->deleteLater();
}
if (request.reply) {
disconnect(request.reply, nullptr, this, nullptr);
request.reply->deleteLater();
}
m_requests.erase(it);
emit requestFinished(id, success, message);
}
这段代码展示了统一清理的思路,但在生产实现中还需要补齐两个细节:
-
如果
reply的finished信号已经触发,应根据 Qt 的生命周期约定处理回复对象,避免与底层对象管理产生冲突。 -
如果取消操作已经将状态设为
Cancelled,终态通知应保留取消语义,而不是简单地把取消当作普通失败。
此外,finish() 必须具有幂等性:同一个请求即使遇到超时回调、回复回调先后触发,也只能完成一次。
实现时应先从请求表中移除记录,再发出业务信号。因为信号槽可能是直接连接,业务回调可能立刻发起新请求,甚至销毁管理器。发出信号后,不应继续访问原来的请求对象或成员状态。
对于 QModbusClient 自身销毁的情况,也应监听其 QObject::destroyed 信号,统一终止或清理尚未完成的请求。
七、工程化落地,还需要补上这几道防线
以上代码说明了基本结构,但要真正用于长期运行的工业上位机,还需要进一步完善。
1. 区分通信错误与业务错误
通信超时、连接断开、非法功能码、非法数据地址,不应该全部使用同一种重试策略。
建议将错误统一映射为:
bash
TransportTimeout
ConnectionLost
ProtocolException
InvalidRequest
Cancelled
RetryExhausted
业务层只需根据错误类型决定是否告警、切换设备状态或提示用户。
2. 限制并发请求数量
如果 PLC 的通信能力有限,就不能允许业务层无限提交请求。
可以为请求管理器增加队列和最大并发数:
bash
Business Requests
│
▼
Pending Queue
│
▼
Concurrency Limit
│
▼
QModbusClient
对于同一设备的寄存器轮询,可以合并连续地址的读取请求,减少通信次数。
3. 避免重复读取和请求风暴
例如,界面每隔 100ms 刷新一次,而设备响应需要 300ms。如果每次刷新都创建新请求,就可能出现大量重复请求。
可以采用以下策略:
-
同一设备、同一地址存在未完成请求时,不重复提交。
-
对周期性轮询设置最小间隔。
-
设备离线后暂停普通轮询。
-
重连成功后逐步恢复请求,而不是瞬间全部重发。
4. 明确线程归属
QModbusClient、QModbusReply、请求管理器及其定时器,应按照 Qt 的线程归属规则使用。
通常将它们放在同一个工作线程的事件循环中,业务线程通过信号槽提交请求。
不要从多个线程直接操作同一个 QModbusClient,也不要通过给对象加锁就认为跨线程调用一定安全。
八、最后总结
Qt Modbus 的异步通信并不难,难的是让每个请求都有明确的开始、等待、结束和销毁过程。
一个可靠的请求管理器,至少应该具备:
-
状态机:明确请求处于哪个阶段。
-
超时控制:防止请求无限等待。
-
有限重试:避免异常情况下无限发送。
-
主动取消:允许业务终止不再需要的请求。
-
统一清理:避免定时器、回复对象和请求记录遗留。
-
错误分类:让业务层能够采取正确的恢复策略。
-
并发控制:避免大量请求挤占有限的设备通信资源。
对于工业上位机而言,Modbus 通信模块不是一个简单的读写工具,而是整个设备控制系统的重要基础设施。
当请求状态、资源所有权和异常处理都被统一管理后,业务代码才可以真正摆脱大量重复的异步处理逻辑。
下一步建议: 在这个请求管理器的基础上,继续增加请求队列、并发上限、轮询调度和设备离线检测,逐步构建一套可以长期运行、便于排障、支持多设备接入的 Qt Modbus 通信引擎。