Qt Modbus 源码剖析:从 QModbusClient 到工业级通信框架

在 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 分别编写一套业务逻辑,也不必重复造一个协议解析引擎。

相关推荐
星夜离尘561 小时前
MATLAB 角度解缠遇到丢样:先分段,再说明圈数为什么不可恢复
开发语言·matlab
我命由我123451 小时前
6-2.Python 异常处理 - 异常捕获(捕获指定异常、常见异常类型、异常信息)
java·运维·开发语言·python·http·https·java-ee
ZoeLandia1 小时前
React08:自定义 Hook
开发语言·前端·react.js·typescript
GreenHandBruce1 小时前
基于Qt 6.12.0 + FFmpeg 9.0.2的4k视频播放器
qt·ffmpeg·音视频
染指11101 小时前
157.Agent-LangGraph-工具(实现ReAct Agent模式)
开发语言·人工智能·python·langchain·agents
程序猿乐锅2 小时前
【从0-1学习Python | 第九篇】继承
开发语言·后端·python·学习·fastapi
Jmyd01232 小时前
博物馆文物建模设备系统架构设计:固定式与手持式协同采集方案
系统架构·智慧文博
wdfk_prog2 小时前
lwip教程 38:从 `lwipopts.h` 到 Port Contract——lwIP 裁剪、构建选择、OS Port 与 Network Port
java·开发语言
开开心心就好2 小时前
证件照排版打印工具,免费可自定义尺寸
服务器·开发语言·算法·随机森林·pdf·音视频·散列表