Qt使用笔记

Posted by wsxq2 on 2024-10-12
TAGS:  Qt

本文最后一次编辑时间:2024-10-12 09:00:00 +0800

本文是笔者使用Qt时遇到的一些难题和解决方法的记录。

QModbusTcpClient 不好用?

初探保持怀疑

在为我的 Qt 应用程序选择 Modbus Tcp 库时,我倾向于使用 Qt 官方库 QModbusTcpClient 及相关库。但搜索后发现,网络上存在大量文章说 QModbusTcpClient 不好用,存在bug。如:

听到这样的言论我第一反应是保持怀疑态度,因为 Qt 作为一个成熟的平台,其大量模块都非常稳定可靠,而 Modbus Tcp 也并非很难的功能,怎么会存在不好用的问题。而且关键在于这些言论并没有提出详细严谨的论据,因此我并没有相信这些言论,而选择相信 Qt。依然选择 QModbusTcpClient 作为项目的 Modbus Tcp 库。

实操遇见问题

实际使用过程中发现,写代码还是非常方便的,但调试期间确实出现了一个问题:当Windows上的Qt程序和汇川PLC进行Modbus TCP通信时出现 request timeout 的错误。这里明确下,我是在 Windows 平台上开发的,Qt版本为5.9.5,Qt Creator 版本为 4.5.2(就是和 Qt 5.9.5 绑定的那个版本)。

问题原因分析

通过Wireshark抓包分析,发现当一个请求报文中包含了多个 ADU 时才会出现超时现象,比如连续调用了两次QModbusTcpClient类的sendWriteRequest方法。请求报文类似下图所示(图中不只两个):

QModbusTcpClient连续两次调用sendWriteRequest时发出的报文.png

搜索网上相关资源如下:

其中大多和我遇到的情况并不相同,大多用的是Modbus RTU,最后一个用的虽然是 Modbus TCP,但原因不一样,所以均无法参考相应的解决方案。当然,也参考过升级Qt版本的解决方法,但并无作用。

后续搜索其它关键字时找到了这篇文章(前面也提及过):

该文章中提到的原因和我的相似:

于是自己写了一个tcp server,抓取QModbusTcpClient写数据的报文,和modbuspoll上的对比,果然对不上,qt中的报文比modbuspoll上的多出来一截,想必是协议错误了。

它这里简单描述为多出一截,没有更多细节,我这边抓包后发现其实多出的那截也是合法的 ADU。只是这种一个报文包含两个ADU的情形是否合法确实是个问题,即它是符合协议标准的,Qt没错,还是不符合协议标准,是Qt的bug。通过一翻搜索,我找到了这个:

该问题正是我想问的,下面有个回答非常严谨,引用了官方协议标准:

Yes - Modbus TCP runs at the application layer and supports multiple simultaneous transactions over a single connection (see page 10 of the spec)

由此可见,并非 Qt 的 bug,而汇川 PLC 实现Modbus TCP Server 时并没有严格按照此标准来。当然,不严格执行标准并非罕见的事,我感觉Qt也应该加个配置或者选项,可以修改此行为,从而提高其兼容性,回头可以试着提下这个请求。总而言之,就此问题而言,Qt 不背主要的锅。

那么问题的根因是什么呢?为什么 Qt 在连续调用 sendWriteRequest 时会出现前述现象?通过分析 Qt SerialBus 模块的源码,找到了原因:sendWriteRequest 底层直接调用 QTcpSocket 的 write 方法,写入了ADU,而后没有调用 flush 方法确保当前 Tcp payload 被发出,从而当再调用 sendWriteRequest 时调用的 write 方法会将另一个 ADU 追加到 Tcp payload,然后 Qt 的事件机制在下次发送事件时就一次性发送了包含这两个 ADU 的报文。

寻找解决方法

问题的根本原因找到后解决方法也是显而易见的,即在 write 调用后添加一个 flush 调用,确保当前 ADU 被发出。但事实上,这并不算一个优秀的解决方法,优秀的方法应该是添加一个选项配置此行为,因为不 flush 也有它的好处,且协议标准本身是支持的。修改相应代码后再提交一个 MR 给 Qt 官方。但目前没那么多时间,后续再说吧。

先说回当前的简单解决方法,修改代码后怎么构建并应用到自己的程序中呢?其实非常简单,构建的话直接用 Qt Creator 中的构建功能即可,然后将生成的 Qt5SerialBusd.dll 覆盖部署后的程序所在目录中同名的文件(部署后才会生成程序依赖的 dll 文件)

依然发现问题

前面我自认为已经明确了原因,并找到了解决方法,实测后依然发现有问题。表现出来的现象就是下发到 PLC 的指令有时并未成功,抓包后进一步发现,依然存在一个包中附着多个读写指令的现象,另外,当未收到回复时,后面发的指令通常会在超时后才发出,而 TCP 底层或许认为此时使用附着方式同时发送更加高效,然而,这不是我期望的。

探索解决之道

总之,问题的关键在于 TCP 协议本身,在 Modbus TCP Server 回复该指令前不应立即发送下一个读写指令。问题来了,如何解决呢?最简单的方法是加延时,但众所周知,程序中使用延时是大忌。更好的方法是发送后等待回复,回复后再发送下一指令。然而,由于 Qt 默认的线程采用的是异步的编程思想,所以不能编写阻塞式代码,那么该如何实现呢?在折腾了很久且发现效果不好后,最后发现,状态机就完美解决此问题。

我们需要使用一个工作频率尽可能快的状态机,然后在发送请求后等待回复,等待回复后再发送下一次请求,从而保证工作有序进行。问题又来了,我们是不是需要缓存请求?由于是异步的,调用者不知道什么时候可以读写,就算知道了当前不可读写,也不可能放弃此次的读写,因此我们需要使用一个队列,缓存调用者的请求,在状态机中取出请求,逐个处理。另外对于读请求,我们在收到回复后还需要通知调用者去取数据,这个数据也是需要缓存的。

状态机代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
void Communication::fsmProcess()
{
    static RwTask t;
    static QModbusReply *reply = nullptr;
    static bool isSuccess;
    static QElapsedTimer lastSuccessTimer;
    static QElapsedTimer waitFinishTimer;

    switch (fsmState_) {
    case FsmState::kIdle:
        Q_ASSERT(modbusDevice_);
        if((lastSuccessTimer.isValid() && lastSuccessTimer.elapsed() >= CHECK_UNCONNECTED_TIMEOUT)
            || modbusDevice_->state()!= QModbusDevice::ConnectedState) {
            qWarning() << "lost connection, try to reconnect";
            UserLog::getInstance().logFault(tr("连接断开,尝试重连"));
            fsmState_ = FsmState::kUnConnected;
            modbusDevice_->disconnectDevice();
            emit unConnected();
        } else {
            if(!rwTaskQueue_.isEmpty()) {
                fsmState_=FsmState::kGetNext;
            }
        }
        break;
    case FsmState::kUnConnected:
        if(modbusDevice_->state()== QModbusDevice::UnconnectedState) {
            if(!modbusDevice_->connectDevice()) {
                qCritical()<<"reconnect failed:"<<modbusDevice_->errorString();
            }
        } else if(modbusDevice_->state()== QModbusDevice::ConnectedState) {
            rwTaskQueue_.clear(); //for safety, this is needed
            lastSuccessTimer.start();
            qInfo() << "success connected";
            writeWord(kHeartBeat, 1000); //must write heartbeat first to notify plc we are already
            emit connected();
            fsmState_ = FsmState::kIdle;
        }
        break;
    case FsmState::kGetNext:
        if(rwTaskQueue_.isEmpty()) {
            fsmState_=FsmState::kIdle;
        } else {
            if(rwTaskQueue_.length() > 1000) {
                qWarning("rwTaskQueue too big: %d, will clear it", rwTaskQueue_.length());
                rwTaskQueue_.clear();
            }
            t=rwTaskQueue_.dequeue();
            fsmState_ = FsmState::kReadWrite;
        }
        break;
    case FsmState::kReadWrite:
#if DEBUG_VERBOSE >= 2
        qDebug()<<"isWrite:"<<t.isWrite<<"startAddress: "<<t.dataUnit.startAddress()<<", count: "<<t.dataUnit.valueCount()<<", values: "<<t.dataUnit.values();
#endif
        isSuccess = false;
        if(t.isWrite) {
            reply = modbusDevice_->sendWriteRequest(t.dataUnit, serverId_);
        } else {
            reply = modbusDevice_->sendReadRequest(t.dataUnit, serverId_);
        }
        if(!reply) {
            fsmState_ = FsmState::kGetNext;
        } else {
            waitFinishTimer.start();
            fsmState_ = FsmState::kWaitReadWriteFinished;
        }
        break;
    case FsmState::kWaitReadWriteFinished:
        if(reply->isFinished()) {
            if (reply->error() == QModbusDevice::ProtocolError) {
                qCritical("protocol error: %s (Mobus exception: 0x%02x)",
                          qPrintable(reply->errorString()), reply->rawResult().exceptionCode());
            } else if (reply->error() != QModbusDevice::NoError) {
                qCritical("other error: %s (code: 0x%02x)",
                          qPrintable(reply->errorString()), reply->error());
            } else {
                isSuccess = true;
                if(t.isWrite) {
                    emit writeFinish(isSuccess);
                } else {
                    lastReadData_=reply->result();

                    emit readFinish(isSuccess, lastReadData_);
                }
                lastSuccessTimer.start();
#if DEBUG_VERBOSE >= 2
                if(t.isWrite) {
                    qDebug()<<"write success";
                } else {
                    qDebug()<<"read success, result is:"<<lastReadData_.startAddress()
                           <<lastReadData_.valueCount()<<lastReadData_.values();
                }
#endif
            }
            reply->deleteLater();
            fsmState_ = FsmState::kGetNext;
        } else if(waitFinishTimer.elapsed() > WAIT_FINISH_TIMEOUT) {
            qCritical()<<"wait read write finish timeout";
            rwTaskQueue_.clear();
            fsmState_ = FsmState::kIdle;
        }
        break;
    default:
        break;
    }
}

该代码除了前面提到的三个状态(从队列中取出任务、发送读写请求、等待回复)之外,还添加两个状态:空闲状态和断连状态。空闲状态通常是状态机必须的,断连状态为连接断开后进入的状态,判断依据包括底层发现 TCP 收到挥手、复位、超时等,还额外添加了超时机制,以应对特殊情况(具体是什么有点忘了,反正不加就会出问题)。

初始化函数中状态机相关代码:

1
2
3
4
5
6
    QTimer *fsmTimer= new QTimer(this);
    fsmTimer->setInterval(0);
    connect(fsmTimer, &QTimer::timeout, this, [this](){
        fsmProcess();
    });
    fsmTimer->start();

注意其中的 fsmTimer->setInterval(0),这里的 0 是特殊的定时器间隔,它表示只要 QT 的事件处理空闲了就会立即执行此定时器中的代码。

相应地,读写函数就变为读写请求入队列,而非立即发送数据,由状态机去逐个处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool Communication::read(const QModbusDataUnit &dataUnit)
{
    if(!(modbusDevice_ && modbusDevice_->state() == QModbusDevice::ConnectedState)) return false;

    RwTask t;
    t.isWrite = false;
    t.dataUnit = dataUnit;

    rwTaskQueue_.enqueue(t);

    return true;
}

bool Communication::write(const QModbusDataUnit & dataUnit)
{
    if(!(modbusDevice_ && modbusDevice_->state() == QModbusDevice::ConnectedState)) return false;

    RwTask t;
    t.isWrite = true;
    t.dataUnit = dataUnit;

    rwTaskQueue_.enqueue(t);

    return true;
}

以上就和 PLC 通信模块的核心代码,调试好后用起来非常不错。

此外,除了在通信模块使用了状态机,在机器人自动流程、多关节运动控制方面也使用了状态机,留待后面详解。