0%

QUdpSocket 使用注意事项

QUdpSocket

使用上存在几个注意事项,当其结果不符合预期时看看这里有没有记录。

QUdpSocket::readyRead()

记录一个关于 信号的 bug ,有数据到达但此信号不再 emit :官方相似 bug 描述

其内部实现依赖 hasPendingData 标志位判断是否发出信号(并调用关联的槽函数) ,但此标志位容易出错, Qt 将此标志位的复位操作交给了用户:

Note: An incoming datagram should be read when you receive the readyRead() signal, otherwise this signal will not be emitted for the next datagram.

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
// 摘自 Qt5.15.15 的 qabstractsocket.cpp +740
bool QAbstractSocketPrivate::canReadNotification()
{
else
{
if (hasPendingData) {
// socket 可读事件不再通知
socketEngine->setReadNotificationEnabled(false);
return true;
}
// 此标志位需要重置为 false 才会再次 emitReadyRead
hasPendingData = true;
}

emitReadyRead();
}

// 摘自 Qt5.15.15 的 qudpsocket.cpp +468
QNetworkDatagram QUdpSocket::receiveDatagram(qint64 maxSize)
{
//...
// 不调用此函数或者 readDatagram() 就
// 不会更新 hasPendingData 标志位,socket 可读事件也不再上报
d->hasPendingData = false;
d->socketEngine->setReadNotificationEnabled(true);
//...
}

通过以上代码实现,可以推断出以下时序:

  1. 内部实现响应了 socket 的可读事件,执行了 canReadNotification()

  2. 未执行 QUdpSocket::readDatagram()QUdpSocket::receiveDatagram() 修改标志位

  3. 内部实现响应了 socket 的可读事件,执行了 canReadNotification()

  4. 不调用 QUdpSocket::readDatagram()QUdpSocket::receiveDatagram() 就再也不会 emitReadyRead()

如何复现问题呢?

新建的 QUdpSocket 对象如果不关联 readyRead()槽函数,是不会主动接收数据的,也就不会执行 canReadNotification()

  1. readyRead() 信号关联槽函数,且数据到达时才会执行 canReadNotification()
  2. 槽函数中不调用 receiveDatagram() 等相关接口;或某分支中未执行相关接口;或 disconnect 槽函数
  3. 数据到达,但不再 emitReadyRead()

针对此问题的 workaround 围绕 receiveDatagram() 展开:

  • 槽函数中要保证所有的分支都会执行 receiveDatagram() 函数
  • 如果中途删除槽函数,重新关联槽函数时调用 receiveDatagram() 保证状态复位

datagram without payload

hasPendingDatagrams() 内部实现中存在以下注释:UDP 有长度为零的报文 (udp datagram without payload ),所以额外提供了此接口,在收到零长度报文时也返回 true

1
2
3
4
5
6
/*!
Returns \c true if there is at least one datagram pending. This
function is only called by UDP sockets, where a datagram can have
a size of 0. TCP sockets call bytesAvailable().
*/
bool QNativeSocketEngine::hasPendingDatagrams() const

参考 Qt 手册 qint64 QUdpSocket::pendingDatagramSize() const ,没有报文时返回 -1 ,而非 0 。

Returns the size of the first pending UDP datagram. If there is no datagram available, this function returns -1.

bad-checksum

需要强调的是,以下 Qt 手册中例子也是存在问题的:hasPendingDatagrams() 返回值可能是 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Server::initSocket()
{
udpSocket = new QUdpSocket(this);
udpSocket->bind(QHostAddress::LocalHost, 7755);

connect(udpSocket, &QUdpSocket::readyRead,
this, &Server::readPendingDatagrams);
}

void Server::readPendingDatagrams()
{
while (udpSocket->hasPendingDatagrams()) {
QNetworkDatagram datagram = udpSocket->receiveDatagram();
processTheDatagram(datagram);
}
}

生产环境下遇到过 bad checksum 报文触发 readyRead() 信号执行槽函数,但 hasPendingDatagrams() 不成立,未能执行 receiveDatagram() 函数,后续收到新报文不再触发 readyRead() 信号。

因为数据是收到了的,只是不再触发 readyRead() 信号,所以当时的解决方案是改用定时器轮询。

todo

一般来说,bad checksum 会被内核过滤并丢弃。如何监控、查看此过程?什么情况下会不再过滤/过滤失败?

POSIX socket() using SOCK_DGRAM 会过滤掉校验和错误的包,难道 QUdpSocket 不是基于 SOCK_DGRAM,而是封装的 SOCK_RAW 吗 ?

‘SOCK_RAW’ option in ‘socket’ system call

socket SO_NO_CHECK

除了在生产环境录取的异常报文,如何模拟 bad checksum 报文呢?

workaround

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Q_ASSERT(nullptr != socket);
while(socket->hasPendingDatagrams())
{
socket->receiveDatagram(0); // discard
}
// emit readyRead() 信号依赖 receiveDatagram() 重置标志位
QObject::connect(socket, &QUdpSocket::readyRead, this, [=](){
if(!socket->hasPendingDatagrams())
{
QNetworkDatagram datagram = socket->receiveDatagram(); // workaround for bad checksum
QString msg("** readyRead emitted, but nothing to get :(");
QString warning = QString(R"(<font color="red">%1</font>)").arg(msg);
ui->plainTextEditRecv->appendHtml(warning);
processTheDatagram(datagram);
}
while(socket->hasPendingDatagrams()){
QNetworkDatagram datagram = socket->receiveDatagram();
processTheDatagram(datagram);
}
});
socket->receiveDatagram(); // workaround for connect() again

QNetworkDatagram

数据报 QNetworkDatagram 接口有坑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
QNetworkDatagram datagram(getPayload(), destinationAddress, destinationPort);
if(QString("不要使用以下接口").isEmpty())
{
const QHostAddress address("192.168.50.221");
// setSender() 参数和 bind() 绑定的地址及端口必须一致,否则 writeDatagram(const QNetworkDatagram &) 执行失败
// 建议不要调用 setSender() 此接口:此接口冗余,且容易错用。
// writeDatagram(const QNetworkDatagram &) 之前的 bind() 操作不会使用 setSender() 的参数
datagram.setSender(address);
// 在 Windows 平台下,不能调用 setHopLimit(),因为底层配置 IP_TTL 是非法项造成 writeDatagram(const QNetworkDatagram &) 执行失败
// https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2
// Return code/value: WSAEINVAL
// https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-wsasendmsg
// Error code: WSAEINVAL
// https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
// Options: IP_TTL
// https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-getsockopt
// Return value: WSAEFAULT
datagram.setHopLimit(7);
// setInterfaceIndex() 和 bind() 冲突似乎没有不良影响。
// 但如果不是 QNetworkInterface::allInterfaces() 集合内的索引,就会发送失败
datagram.setInterfaceIndex(119);
qDebug() << "interfaceIndex is" << datagram.interfaceIndex();
}

抓包过滤字节

tcpdump 和 wireshark 过滤字节的语法存在差异:

1
2
3
tcpdump udp[34:2]=0xa753
udp.payload[26:2] == A753 # wireshark
tcpdump -i enaphyt4i0 udp[34:2]=0xa753 and dst 224.8.50.5

网卡 offload 特性

Udp Checksum Offload

查看网卡的 offload 特性 ethtool -k enaphyt4i0

接收网络报文关闭校验 ethtool -K enaphyt4i0 rx off

How to disable checksums on ethernet card in Windows 10?

网卡属性 - 配置 - 高级 - UDP 校验和分载传输(IPv4)