Qt Modbus 工程化封装:超时、重试、取消与资源释放,别让异步请求变成隐患

在 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 也可能继续发出信号。

取消操作至少需要完成以下工作:

  1. 将请求标记为 Cancelled。

  2. 停止超时定时器。

  3. 断开回复对象与管理器之间的信号连接。

  4. 根据 Qt 对象生命周期规则处理回复对象。

  5. 移除请求记录。

  6. 阻止迟到的响应触发业务完成回调。

一个基础实现如下:​​​​​​​

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 通信引擎。

相关推荐
ESDWAN1 小时前
SD-WAN 是什么?工作原理、适用场景与选型要点
开发语言·网络·数据库·架构·php
m0_639906941 小时前
MySQL 慢查询优化|explain执行计划详解
数据库·mysql
正儿八经的少年1 小时前
MyBatis 一对一、一对多 SQL
数据库·sql·mybatis
Elastic 中国社区官方博客2 小时前
Elastic 在《Forrester Wave™:2026 年第四季度智能运维平台》报告中被评为强劲表现者
大数据·运维·数据库·elasticsearch·搜索引擎·全文检索
Apache IoTDB2 小时前
从 IoTDB 到 CED-DB:哈尔滨工业大学探索云边端协同时序数据库
数据库·时序数据库·iotdb
闲云野鹤在人间2 小时前
头秃修炼手册:StorageClass+StatefulSet+ETCD 全套渡劫笔记
linux·数据库·笔记·云原生·kubernetes·etcd
Decay__2 小时前
03_MySQL 存储引擎与存储管理 —— InnoDB 深入、表空间/ibd、binlog/redo/undo 与双写缓冲
运维·数据库·sql·mysql·架构
瀚高PG实验室2 小时前
瀚高数据库提交数据后,是否需要COMMIT
数据库·postgresql·瀚高数据库
bksczm2 小时前
MySQL程序架构
数据库·mysql·架构