本文是笔者使用Qt时遇到的一些难题和解决方法的记录。
QModbusTcpClient 不好用?
初探保持怀疑
在为我的 Qt 应用程序选择 Modbus Tcp 库时,我倾向于使用 Qt 官方库 QModbusTcpClient 及相关库。但搜索后发现,网络上存在大量文章说 QModbusTcpClient 不好用,存在bug。如:
- Qt基于QTcpSocket写的ModBusTcp模块,Qt自带的modbusTCP并不能用_qtmodbustcp资源-CSDN文库
- QT+ModbusTCP 全网唯一好用,基于QTcpSocket纯手搓modbustcp协议_qt modbus tcp-CSDN博客
- ……
听到这样的言论我第一反应是保持怀疑态度,因为 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
搜索网上相关资源如下:
- Qt modbus开发中遇到的Request timeout错误_qt modbus bug-CSDN博客
- Qt5.9 Modbus request timeout 0x5异常解决_qt modbus 模块存在bug-CSDN博客
- Qt Modbus request timeout异常解决_qt modbus response timeout-CSDN博客
- QT modbus长时间报Request timeout. (code: 0x5)_qt modbus总是接收超时-CSDN博客
其中大多和我遇到的情况并不相同,大多用的是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 通信模块的核心代码,调试好后用起来非常不错。
此外,除了在通信模块使用了状态机,在机器人自动流程、多关节运动控制方面也使用了状态机,留待后面详解。