ZeroOne AI
← 返回文章列表

Qt 6.10 开发 Modbus TCP 通讯:从客户端、服务器到工业设备调度

👁 1
分类:工业互联网

本文以 Qt 6.10 + C++ + Qt SerialBus 为基础,介绍 Modbus TCP 客户端与服务端的实现方法,并结合 BagBreakerGatewayApp 拆袋机 AGV 调度项目,讲解 QModbusTcpClient/QModbusTcpServer 的使用、寄存器地址偏移问题、状态机调度、断线重连与分层架构设计,帮助开发者在工业网关、MES、设备联网场景中快速落地。

一、项目背景

本项目开发的是一个工业调度网关:

 ┌──────────────────┐
 │ BagBreakerGateway│
 │      App         │
 └────────┬─────────┘
          │
 ┌────────┼──────────────────┐
 │        │                  │
 ▼        ▼                  ▼
Modbus TCP  HTTP API       MQTT
 │        │                  │
 ▼        ▼                  ▼
 机器人   AGV服务器         EMQX

其中:

机器人提供 Modbus TCP 寄存器:

4x520  机器人1区托盘无料
4x521  机器人2区托盘无料
4x522  机器人搬运中
4x530  AGV进入机械手抓取区域
4x531  机械手上满托盘完成

网关同时承担两个角色:

Modbus TCP Client
   ↓
读取/写入机器人

Modbus TCP Server
   ↓
提供数据给AGV

AGV任务:调度程序检测机器人托盘状态后,通过 HTTP API 给 AGV 下发搬运任务。

这种架构实际上形成了:

机器人 → Modbus TCP → 调度程序 → HTTP → AGV
AGV    → Modbus TCP → 调度程序

二、Qt为什么适合开发Modbus TCP

Qt 6 提供了 Qt SerialBus 模块,其中包含:

QModbusTcpClient
QModbusTcpServer
QModbusDataUnit
QModbusReply

因此不需要自己实现 Modbus TCP 协议。

工程中加入:

QT += core gui network serialbus

如果使用 CMake:

find_package(Qt6 REQUIRED COMPONENTS
    Core
    Gui
    Network
    SerialBus
)

然后:

target_link_libraries(BagBreakerGatewayApp
    PRIVATE
    Qt6::Core
    Qt6::Gui
    Qt6::Network
    Qt6::SerialBus
)

三、Modbus TCP基本结构

Modbus TCP 一般通过 TCP 端口 502 进行通讯。

典型结构:

客户端                服务端
QModbusTcpClient
    │
    │ TCP
    ▼
              QModbusTcpServer

在我们的项目中:

BagBreakerGatewayApp
    │
    ├── Client → Robot
    │
    └── Server ← AGV

四、Modbus TCP客户端

首先定义一个自己的 ModbusClient。

不要让 Scheduler 直接操作 QModbusTcpClient。

推荐:

Scheduler
    ↓
ModbusClient
    ↓
QModbusTcpClient

这样可以把通讯和调度逻辑分离。


五、ModbusClient头文件

#ifndef MODBUSCLIENT_H
#define MODBUSCLIENT_H
#include <QObject>
#include <QModbusTcpClient>
#include <QModbusDataUnit>

struct RobotStatus
{
    bool tray1Empty = false;
    bool tray2Empty = false;
    bool moving = false;
};

class ModbusClient : public QObject
{
    Q_OBJECT
public:
    explicit ModbusClient(QObject *parent = nullptr);
    ~ModbusClient();

    //连接机器人
    bool connectToDevice(
        const QString &ip,
        int port);
    //断开机器人
    void disconnectDevice();
    //是否连接
    bool isConnected() const;
    //读取机器人状态
    void readData520();
    //写入机器人4x530开始的数据
    bool writeData530(
        const QList<quint16> &data);

signals:
    //机器人状态发生变化
    void robotStatusChanged(
        const RobotStatus &status);

private slots:
    void onStateChanged(
        QModbusDevice::State state);
    void onReadReady();

private:
    QModbusTcpClient *m_client;
    RobotStatus m_status;
};
#endif

六、创建Modbus客户端

ModbusClient::ModbusClient(QObject *parent)
    : QObject(parent)
{
    m_client = new QModbusTcpClient(this);
    connect(
        m_client,
        &QModbusClient::stateChanged,
        this,
        &ModbusClient::onStateChanged
    );
}

七、连接机器人

bool ModbusClient::connectToDevice(
    const QString &ip,
    int port)
{
    m_client->setConnectionParameter(
        QModbusDevice::NetworkAddressParameter,
        ip
    );
    m_client->setConnectionParameter(
        QModbusDevice::NetworkPortParameter,
        port
    );
    m_client->setTimeout(1000);
    m_client->setNumberOfRetries(2);
    return m_client->connectDevice();
}

例如:

connectToDevice(
    "192.168.20.30",
    502
);

八、判断连接状态

bool ModbusClient::isConnected() const
{
    return m_client->state()
        == QModbusDevice::ConnectedState;
}

机器人连接成功以后:ConnectedState;断开:UnconnectedState


九、读取机器人寄存器

例如机器人状态 4x520、4x521、4x522,可以一次读取:

void ModbusClient::readData520()
{
    if(!isConnected())
        return;

    QModbusDataUnit unit(
        QModbusDataUnit::HoldingRegisters,
        519,
        3
    );

    QModbusReply *reply =
        m_client->sendReadRequest(
            unit,
            1
        );

    if(!reply)
        return;

    if(!reply->isFinished())
    {
        connect(
            reply,
            &QModbusReply::finished,
            this,
            &ModbusClient::onReadReady
        );
    }
    else
    {
        delete reply;
    }
}

这里有一个非常重要的问题。


十、为什么代码使用519,而不是520?

这是 Modbus 项目非常容易出现的地址偏移问题

设备说明书经常写 4x520,但 Qt 的 QModbusDataUnit 使用的是从0开始的地址

所以:

设备地址    Qt地址
4x520   →  519
4x521   →  520
4x522   →  521

因此:

QModbusDataUnit(
    QModbusDataUnit::HoldingRegisters,
    519,
    3
);

实际读取 519、520、521,对应设备 4x520、4x521、4x522。这是实际项目中非常重要的一点。


十一、处理读取结果

void ModbusClient::onReadReady()
{
    QModbusReply *reply =
        qobject_cast<QModbusReply *>(sender());
    if(!reply)
        return;

    if(reply->error()
        != QModbusDevice::NoError)
    {
        qDebug()
            << "Modbus Read Error:"
            << reply->errorString();
        reply->deleteLater();
        return;
    }

    const QModbusDataUnit unit =
        reply->result();

    if(unit.valueCount() >= 3)
    {
        m_status.tray1Empty =
            unit.value(0) != 0;
        m_status.tray2Empty =
            unit.value(1) != 0;
        m_status.moving =
            unit.value(2) != 0;

        emit robotStatusChanged(
            m_status
        );
    }

    reply->deleteLater();
}

这样 Modbus 层只负责通讯,Scheduler 不需要知道寄存器地址。


十二、写机器人4x530

例如 4x530 AGV进入机械手区域、4x531 机械手上满托盘完成,可以一次写两个寄存器。

Qt 地址:4x530 → 529、4x531 → 530。

bool ModbusClient::writeData530(
    const QList<quint16> &data)
{
    if(!isConnected())
        return false;

    QModbusDataUnit unit(
        QModbusDataUnit::HoldingRegisters,
        529,
        data.size()
    );

    for(int i = 0;
        i < data.size();
        i++)
    {
        unit.setValue(i, data.at(i));
    }

    QModbusReply *reply =
        m_client->sendWriteRequest(
            unit,
            1
        );

    if(!reply)
        return false;

    if(reply->isFinished())
    {
        reply->deleteLater();
        return true;
    }

    connect(
        reply,
        &QModbusReply::finished,
        reply,
        &QObject::deleteLater
    );

    return true;
}

十三、为什么不能把所有数据放在一个类里面

很多初学者容易写成:

MainWindow
    ↓
QModbusTcpClient
    ↓
读取
    ↓
判断AGV
    ↓
创建任务

最终 MainWindow 会变成几千行。

更合理的架构:

MainWindow
    │
    ├──────── ModbusClient
    │
    ├──────── ModbusServer
    │
    ├──────── AgvHttpClient
    │
    ├──────── MqttClient
    │
    ├──────── DataManager
    │
    └──────── Scheduler

其中:

ModbusClient   → 机器人通讯
ModbusServer   → AGV通讯
AgvHttpClient  → AGV任务
MqttClient     → MES/云端
DataManager    → SQLite
Scheduler      → 核心调度逻辑

这就是比较典型的工业网关架构。


十四、Modbus TCP服务器

AGV 不直接访问机器人,AGV 访问 BagBreakerGatewayApp,因此程序需要提供 QModbusTcpServer。

ModbusServer::ModbusServer(
    QObject *parent)
    : QObject(parent)
{
    m_server =
        new QModbusTcpServer(this);
    connect(
        m_server,
        &QModbusServer::dataWritten,
        this,
        &ModbusServer::onDataWritten
    );
    initRegisters();
}

十五、初始化寄存器

void ModbusServer::initRegisters()
{
    QModbusDataUnitMap map;
    map.insert(
        QModbusDataUnit::HoldingRegisters,
        QModbusDataUnit(
            QModbusDataUnit::HoldingRegisters,
            0,
            100
        )
    );
    m_server->setMap(map);

    for(int i = 0;
        i < 100;
        i++)
    {
        m_server->setData(
            QModbusDataUnit::HoldingRegisters,
            i,
            0
        );
    }
}

十六、AGV看到的寄存器

建议定义:

地址含义
400011区托盘无料
400022区托盘无料
40003机器人搬运中
40004网关自动运行
40010AGV进入机械手区域
40011机械手上满托盘完成

实际程序内部可以统一使用:

enum
{
    REG_TRAY1_EMPTY = 0,
    REG_TRAY2_EMPTY = 1,
    REG_ROBOT_MOVING = 2,
    REG_GATEWAY_RUN = 3,
    REG_AGV_ARRIVE = 9,
    REG_FULL_TRAY_FINISH = 10
};

这样代码比直接写 setRegister(9,...) 更加清晰。


十七、更新机器人状态给AGV

void ModbusServer::updateRobotStatus(
    bool tray1Empty,
    bool tray2Empty,
    bool moving,
    bool fullTray)
{
    setRegister(
        REG_TRAY1_EMPTY,
        tray1Empty
    );
    setRegister(
        REG_TRAY2_EMPTY,
        tray2Empty
    );
    setRegister(
        REG_ROBOT_MOVING,
        moving
    );
    setRegister(
        REG_FULL_TRAY_FINISH,
        fullTray
    );
}

这样:

机器人 → ModbusClient → Scheduler → ModbusServer → AGV

形成数据闭环。


十八、AGV写入数据

AGV 进入机器人区域时写 40010 = 1。

void ModbusServer::onDataWritten(
    QModbusDataUnit::RegisterType table,
    int address,
    int size)
{
    if(table !=
        QModbusDataUnit::HoldingRegisters)
        return;

    for(int i = 0;
        i < size;
        i++)
    {
        int addr = address + i;
        quint16 value = 0;
        m_server->data(
            table,
            addr,
            &value
        );

        if(addr == REG_AGV_ARRIVE)
        {
            emit agvArrive(
                value != 0
            );
        }
    }
}

这样 AGV 的 Modbus 写入可以直接转换成 Qt 信号:

AGV写40010 → ModbusServer → agvArrive() → Scheduler

十九、调度程序

真正的工业逻辑应该放在 Scheduler 中。

例如:

Step 0   等待任务
Step 10  等待机器人空闲
Step 20  创建AGV取空托任务
Step 22  等待HTTP任务结果
Step 30  等待AGV进入机器人区域
Step 32  等待AGV离开
Step 40  创建AGV送满托任务
Step 42  等待HTTP任务结果
Step 50  等待AGV进入机器人区域
Step 52  等待AGV离开
Step 60  等待机器人完成
Step 70  任务完成

这就是典型的有限状态机 FSM。


二十、为什么要使用状态机

如果直接使用 if/else 堆逻辑,任务越来越复杂以后,非常容易出现:

AGV已经进入
机器人还没准备好
HTTP任务又重复创建
任务ID重复

状态机可以明确规定:

当前状态 + 输入条件 = 下一状态

例如:

if(m_TaskSteps == STEP_CREATE_EMPTY)
{
    CreateAgvTask();
    m_TaskSteps =
        STEP_WAIT_EMPTY_HTTP;
}

二十一、通讯周期设计

工业软件不要让一个定时器不停做所有事情。

可以采用 100ms Scheduler Timer

奇数周期 → Modbus读取
偶数周期 → 调度处理

例如:

m_timer->start(50);

然后:

if((m_TaskNumbers % 2) == 0)
{
    //调度
}
else
{
    //通讯
}

最终达到约 100ms 的完整调度周期。


二十二、通讯失败必须自动重连

工业现场最常见的问题不是代码错误,而是:

网线拔掉
交换机重启
机器人重启
AGV服务器重启

所以客户端应该:

检测连接状态 → 断线 → 重新连接 → 连接成功 → 继续读取

例如:

if(!m_robotClient->isConnected())
{
    m_robotClient->ReConnectToDevice();
}

不要因为一次通讯失败导致整个调度程序停止。


二十三、Modbus TCP服务器和客户端同时运行

Qt 允许一个程序同时运行 QModbusTcpClient 和 QModbusTcpServer:

        BagBreakerGatewayApp
 ┌──────────────┴──────────────┐
 │                             │
 ▼                             ▼
QModbusTcpClient          QModbusTcpServer
 │                             │
 ▼                             ▼
 Robot                        AGV

这是非常适合工业网关的架构。


二十四、最终软件架构

最终的 BagBreakerGatewayApp 可以设计成:

┌──────────────────────────────────────────────┐
│ MainWindow                                   │
│ 状态显示 / 日志 / 托盘数量 / 系统控制        │
└───────────────────┬──────────────────────────┘
                    │
                    ▼
┌──────────────────────────────────────────────┐
│ Scheduler                                    │
│ 自动调度状态机                               │
└───────┬──────────────┬─────────────┬─────────┘
        │              │             │
        ▼              ▼             ▼
  ModbusClient    ModbusServer  AgvHttpClient
        │              │             │
        ▼              ▼             ▼
       Robot           AGV       AGV Server
        │
        │
        ▼
┌───────────────────────┐
│ MqttClient            │
│ Status/Event/Alarm    │
└──────────┬────────────┘
           ▼
         EMQX
┌───────────────────────┐
│ DataManager           │
│ SQLite / 日志 / 计数  │
└───────────────────────┘

二十五、Modbus TCP开发中的几个关键注意事项

① 40001和程序地址不一定相同

一定确认设备厂家地址定义。常见情况:40001 → 0、40002 → 1;但也有设备直接使用 40001 → 40001。所以不要凭经验写地址,一定根据机器人或 PLC 手册确认。

② 不要在UI线程阻塞等待

错误方式:

while(!reply->isFinished())
{
}

这会导致界面卡死。应该使用 QModbusReply::finished 异步处理。

③ Reply必须释放

reply->deleteLater();

否则长期运行可能造成内存不断增长。

④ 通讯层和业务层分离

不要在 ModbusClient 里面直接写:

if(trayEmpty)
{
    createAgvTask();
}

应该:

ModbusClient → Signal → Scheduler → AGV

这样后期维护非常重要。


二十六、总结

Qt 6.10 的 Qt SerialBus 已经可以很好地完成工业 Modbus TCP 通讯。

对于实际工业项目,推荐采用:

QModbusTcpClient
+
QModbusTcpServer
+
Scheduler状态机
+
HTTP AGV任务
+
MQTT设备状态
+
SQLite数据持久化

其中 ModbusClient 负责设备通讯,ModbusServer 负责对外提供数据,Scheduler 负责业务决策。这种分层结构比把所有通讯和业务代码堆在 MainWindow 中更加稳定,也非常适合后续扩展到 MES、AGV 调度、设备联网和云端监控。

评论(0