简介:这是面向Qt网络开发初学者的UDP通信示例项目,集中解决UDP消息收发、数据接收与文件分块传输三方面的实现问题,可直接用于理解QUdpSocket、QNetworkDatagram等核心类的典型用法。压缩包共17个文件、约814KB,包含3个C++源文件、3个头文件、界面文件(.ui)、Qt工程文件(.pro)以及已编译的debug/release目录和可执行文件,既能查看源码学习,也能直接运行观察效果。资源已有1786人浏览学习,适合需要快速落地UDP通信功能或准备网络编程实践的中级开发者参考。示例覆盖端口绑定、等待读取、发送消息、文件分块发送与接收端重组等内容,同时保留了编译产物与工程配置,便于对照调试;读者可在此基础上扩展错误处理、并发收发等健壮性机制,快速迁移到自己的Qt项目中。
1. 用 QUdpSocket 把“收到消息”变成可落地的接收链路
很多人第一次在 Qt 里做 UDP 接收,都是照着教程写一个QUdpSocket,然后bind一个端口,结果readyRead永远不触发。问题往往不在 Qt,而在对 UDP 接收模型的理解:UDP 是面向数据报的,bind之后到达的每个报文都会触发一次readyRead,但读多读少、读快读慢、丢不丢包,取决于你绑的是哪个地址、缓冲设了多大、读取时有没有把readDatagram当作“一次读完”的原子操作。
这篇文章围绕“Qt UDP 接收”这个主题,从QUdpSocket的绑定细节、readyRead的触发时机,到消息解析和文件分片重组,最后落到接收性能排查与乱序处理。内容按“先立住接收模型,再给能抄的代码,最后讲坑”的顺序展开,适合正在做设备通信、上位机数据采集、局域网文件传输的 Qt 开发者,也适合刚从 TCP 转到 UDP 的人快速对齐接收侧的思路。
2. QUdpSocket 绑定与 readyRead 接收机制
2.1 为什么 UDP 接收的第一动作是 bind 而不是 connect
TCP 的connect是面向连接的,它要做三次握手、维护状态、保证序和重传;UDP 没有这些,connect在 UDP 里只是给系统一个默认对端地址,用于write时不带目的地址,并没有真正建立链路。所以你要接收数据,必须先把本地IP + 端口绑好,内核才会把到达该端口的数据报放到接收队列里。很多新手把 TCP 的思路带过来,直接connect(host, port)就想收消息,结果readyRead一个都不触发,因为connect只约束了发送方向,接收侧根本没有监听。
udpSocket = new QUdpSocket(this); bool ok = udpSocket->bind(QHostAddress::AnyIPv4, 8888); if (!ok) { qDebug() << "bind failed:" << udpSocket->errorString(); }绑定模式上,我一般用QHostAddress::AnyIPv4配合QUdpSocket::ShareAddress,这样本机多个进程或网络调试助手能同时监听 8888 端口,方便联调。官方QUdpSocket::bind有四种模式:DefaultForPlatform、ShareAddress、DontShareAddress、ReuseAddressHint,区别在于是否允许多个 socket 绑定同一端口、以及SO_REUSEADDR的语义。
| 模式 | 行为 | 典型场景 |
|---|---|---|
DefaultForPlatform | 平台默认,通常不允许二次绑定 | 正式接收程序 |
ShareAddress | 允许多个 socket 绑同端口,且不独占 | 与网络调试助手并存调试 |
DontShareAddress | 独占地址,端口被其他进程占用则失败 | 严格要求单实例 |
ReuseAddressHint | 设置SO_REUSEADDR,快速重启时端口可重绑 | 频繁改代码重启的开发期 |
端口冲突时bind返回false,通过errorString()能看到是AddressInUseError还是权限问题。Linux 下低于 1024 的端口需要 root 权限,Windows 下避开系统保留端口;建议开发阶段固定用一个 1024 以上的端口,比如 8888、9000。
2.2 readyRead 触发时机与最小接收代码
readyRead在数据报到达接收队列时逐个触发,不是一个包一个信号,而是“队列从空变为非空”时发一次。也就是说,如果对方一次发了 10 个包、而你处理不够快,这 10 个包可能合并成一次readyRead信号。正确的做法是在信号槽里用while循环排空队列,而不是每次只读一个datagram就退出。
connect(udpSocket, &QUdpSocket::readyRead, this, [this]() { QByteArray buffer; QHostAddress sender; quint16 senderPort; while (udpSocket->hasPendingDatagrams()) { buffer.resize(static_cast<int>(udpSocket->pendingDatagramSize())); udpSocket->readDatagram(buffer.data(), buffer.size(), &sender, &senderPort); qDebug() << "from" << sender.toString() << senderPort << "size:" << buffer.size() << "data:" << buffer.toHex(); } });这段代码的关键是pendingDatagramSize()先取当前数据报的字节数、再resize那个长度、然后用readDatagram整体读出。UDP 数据报有边界,一次readDatagram只会读出一个完整的报文,绝不会像 TCP 流那样出现半个报文或被拼接两个报文。sender和senderPort给出的是对端地址,多设备接入时这是区分设备身份的唯一可靠依据。
hasPendingDatagrams()是排空循环的出口判断。while里每次都会查询队列是否还有未读数据报,如果没有就退出,防止readyRead在极端高并发下重复触发导致槽函数重入。buffer.resize放在循环内部,因为每个数据报的长度不同,错误复用上一次的长度会读到脏数据。
2.3 多网卡绑定、端口复用与防火墙拦截
多网卡主机上直接bind(QHostAddress::AnyIPv4, port)会监听所有 IPv4 网卡,这在绝大多数场景下是省事的。但如果你只想收某一块网卡(比如局域网 VLAN 接口)的包,必须显式指定网卡 IP:
QList<QNetworkInterface> interfaces = QNetworkInterface::allInterfaces(); for (const QNetworkInterface &iface : interfaces) { if (iface.name().contains("eth0")) { QList<QNetworkAddressEntry> entries = iface.addressEntries(); for (const QNetworkAddressEntry &entry : entries) { if (entry.ip().protocol() == QAbstractSocket::IPv4Protocol) { udpSocket->bind(entry.ip(), 8888); break; } } } }QNetworkInterface::allInterfaces()可以枚举本机所有网卡,addressEntries()返回该网卡上的全部 IP 地址;这里用entry.ip().protocol()过滤掉 IPv6 地址段,只取 IPv4。指定网卡后,AnyIPv4收不到的本网段广播包也能正常接收,比如 192.168.1.255 的广播报文在AnyIPv4绑定下有些系统需要额外设置QAbstractSocket::ShareAddress才能收到。
防火墙是另一个高发问题。Windows 接到 UDP 数据报时,防火墙首次弹窗如果被忽略,后续readyRead一直不触发,而netstat -an | findstr 8888又看得到端口处于 LISTENING。可以先在 PowerShell 用管理员身份执行New-NetFirewallRule -DisplayName "QtUdpDemo" -Direction Inbound -Protocol UDP -LocalPort 8888 -Action Allow放行端口,再回到代码联调。Linux 下用ufw allow 8888/udp放行,记得确认ufw status是 active 状态。
3. 用 readDatagram 完成消息接收与数据解析
3.1 读懂 UDP 数据报语义:边界即消息
TCP 里你要处理粘包和拆包,因为它是字节流;UDP 不粘包也不拆包,一个sendto发出去的 buffer,在接收端恰好是一个readDatagram读回来的 buffer。这个特性让 UDP 的消息协议天然简单:一个数据报就是一条完整消息。代价是数据报有 65507 字节(IPv4 下 65535 减 20 字节 IP 头减 8 字节 UDP 头)的上限,要么接受“消息不能超过 64K”,要么做文件传输时自己分片。
在设计接收解析时,我会把一个数据报的前 4 个字节预留为消息 ID,前 2 个字节预留为消息类型,后面才是负载。这个头结构不依赖具体业务,接收端先读类型字段决定走哪条解析分支。用 Qt 的QDataStream读二进制时,要注意字节序默认是大端(BigEndian),而很多物联网设备默认小端,统一在setByteOrder(QDataStream::LittleEndian)上对齐。
struct UdpHeader { quint16 msgType; quint16 msgLen; }; // 接收端解析 QByteArray payload = buffer.mid(4); QDataStream stream(buffer); stream.setByteOrder(QDataStream::LittleEndian); quint16 msgType; quint16 msgLen; stream >> msgType >> msgLen; if (msgLen != payload.size()) { qWarning() << "length mismatch, protocol error:" << msgLen << payload.size(); return; }这里把msgLen做成校验字段,用来识别“对方协议版本不一致”或“解析错位”两种情况。UDP 本身不校验应用层内容的正确性,msgLen和实际载荷不一致时直接丢弃,避免把错位的数据当合法消息继续处理。mid(4)取出的payload才是真正要交给业务逻辑的部分。
3.2 高频消息接收时用 pendingDatagramSize 控制缓冲
pendingDatagramSize()返回的是能预见的下一数据报字节数。注意它不是“缓冲区总大小”,也不是“下一个包的最大长度”,而是一个精确的数值。读取前先buffer.resize(n)再readDatagram,比resize(65535)再readDatagram再truncate少一次内存拷贝。小消息较多时,这个差异对 CPU 占用挺明显。
高频场景下,我会额外开一个QByteArray池子,预分配 4096 字节的 buffer,只有pendingDatagramSize()超过当前容量时才重新resize(会触发重新分配),避免每个包都调用一次内存分配:
QByteArray rxBuffer; rxBuffer.reserve(4096); while (udpSocket->hasPendingDatagrams()) { qint64 n = udpSocket->pendingDatagramSize(); if (rxBuffer.capacity() < n) { rxBuffer.reserve(static_cast<int>(n)); } rxBuffer.resize(static_cast<int>(n)); udpSocket->readDatagram(rxBuffer.data(), rxBuffer.size()); processMessage(rxBuffer); }reserve只是预留容量,不改变size();resize把size()改成实际数据长度。循环里先reserve再resize,保证readDatagram写入的字节数不会超过buffer.data()指向的可用内存。processMessage拿到的是完整数据报,不要在槽函数里再做耗时的文件写入或数据库操作,否则readyRead会排队,QEventLoop 阻塞期间到达的数据报会堆积在 socket 接收队列里,极端情况下触发QUdpSocket::socketError的SocketResourceError。
3.3 接收调试:用网络调试助手与 iperf3 验证链路
写完接收代码后,先用网络调试助手做单包验证,再用iperf3做持续流量打底验证。网络调试助手(NetAssist)设置本地端口为发送端 UDP 端口,目标 IP 填接收机 IP,目标端口填 Qt 程序的bind端口,点击“发送”即可。收不到时先扣第一点:发送端的目标 IP 和端口是否与bind的地址一致,AnyIPv4虽能收所有网卡的包,但发送端若发到127.0.0.1而程序绑定在某个物理网卡 IP 上,某些平台也会收不到。
iperf3 -u -c 192.168.1.100 -p 8888 -b 10M -t 10 # 服务端在 192.168.1.100 上执行 iperf3 -u -s -p 8888-u指定 UDP 模式,-b设目标带宽 100M,-t持续 10 秒。如果用iperf3的默认端口(5201)而 Qt 程序bind了 8888,收到的数据报会被系统丢弃,因为端口不匹配。iperf3服务端在运行期间会占用 UDP 端口,此时再启动 Qt 程序bind同一端口会冲突,要么把 Qt 的端口改成 5202,要么用iperf3 -s -p 8888指定端口。验证通过后,Qt 程序收到的payload前 32 字节是 iperf3 控制连接的 JSON 数据,后续才是纯带宽测试流,解析时报文长度不均匀是正常的。
4. Qt UDP 接收文件的分片重组与完整性校验
4.1 为什么 UDP 传输文件必须自建分片协议
UDP 数据报上限 65507 字节,实际传输时链路 MTU(以太网普遍 1500 字节)会强制 IP 分片。IP 分片是网络层行为,接收端重组发生在 IP 层,如果分片丢失,整包数据报直接丢掉,readDatagram根本不会触发。在民用路由器、Wi-Fi 场景下,超过 MTU 的大数据报丢包率显著上升。所以真正传文件时,我一般把单包载荷控制在 1400 字节以内(以太网 MTU 1500 减 IP 头 20 减 UDP 头 8),留出协议头部空间,避免 IP 分片。
分片协议至少要包含四样:文件 ID、总片数、当前片序号、当前片长度。文件 ID 用于识别属于同一个文件的片段;总片数让接收端预先分配重组缓冲区;当前片序号用于排序和判断是否收齐;片长用于最后一片不是满片时正确截断。加上前文的 4 字节消息 ID 字段,一个数据报的完整结构可以设计为:2 字节消息 ID + 4 字节文件 ID + 4 字节总片数 + 4 字节片序号 + 2 字节片长 + N 字节数据。
struct FileChunk { quint32 fileId; quint32 totalChunks; quint32 chunkIndex; quint16 chunkLen; QByteArray data; }; FileChunk parseChunk(const QByteArray &packet) { FileChunk chunk; QDataStream stream(packet); stream.setByteOrder(QDataStream::BigEndian); quint16 msgId; stream >> msgId; stream >> chunk.fileId >> chunk.totalChunks >> chunk.chunkIndex; quint16 dataLen; stream >> dataLen; if (dataLen != packet.size() - 16) { // 头16字节 chunk.chunkIndex = UINT32_MAX; // 标记无效 return chunk; } chunk.chunkLen = dataLen; chunk.data = packet.right(dataLen); return chunk; }QDataStream默认大端序,与网络序一致,通信双方都按这个规则解析就没有字节序问题。这里 16 字节头由 2 + 4 + 4 + 4 + 2 组成,packet.size() - 16拿到数据区长度并与头里的dataLen对比,不一致就标记无效片。UINT32_MAX这个哨兵值避开正常片序号,因为真实片序号从 0 开始且不会达到 4.29 亿。
4.2 分片重组缓冲区的设计
接收端要维护一个“按文件 ID 索引的重组上下文”。我用QHash<quint32, FileReassembly*> m_reassemblyMap保存,FileReassembly里包含总片数、已收到片数、一个QByteArray数组和一个quint32掩码用于标记哪些片到了。顺序到达和乱序到达都能处理,收满后再拼接写盘。
class FileReassembly { public: quint32 fileId; quint32 totalChunks; quint32 receivedCount = 0; QByteArray chunks[1024]; // 按片序号存,最多1024片 public: void appendChunk(quint32 index, const QByteArray &data) { if (chunks[index].isEmpty()) { chunks[index] = data; receivedCount++; } } bool isComplete() const { return totalChunks == receivedCount; } QByteArray assemble() const { QByteArray file; file.reserve(static_cast<int>(totalChunks) * 1400); for (quint32 i = 0; i < totalChunks; ++i) { file.append(chunks[i]); } return file; } };chunks固定最大 1024 片,单片 1400 字节,理论上可组装 1.4MB 的文件;超过这个大小要么增加数组长度,要么改用QHash<quint32, QByteArray>做动态存储。appendChunk里用isEmpty()判断该片是否已到达,防止重复包覆盖已收到的数据——这是 UDP 接收文件最容易踩的坑,因为 UDP 有可能重复。收到同一 fileId 的重复片时只保留第一份,receivedCount不重复累加,assemble才不会被重复数据撑大。
4.3 文件接收完成后的完整性校验
分片收齐不代表文件正确。UDP 不保证数据不损坏,网络设备偶发位翻转需要应用层兜底。常见的做法是发送端在发送前计算整个文件的 MD5 或 CRC32,追加为最后一包的特殊消息,接收端重组完文件后重新计算哈希值并比对。Qt 的QCryptographicHash支持 MD5、SHA-1、SHA-256,计算速度足够快,建议用 MD5 做快速校验,若业务安全要求更高再上 SHA-256。
QByteArray fileData = reassembly->assemble(); QByteArray md5 = QCryptographicHash::hash(fileData, QCryptographicHash::Md5).toHex(); if (md5 == expectedMd5) { QFile outFile("received_" + QString::number(reassembly->fileId)); if (outFile.open(QIODevice::WriteOnly)) { outFile.write(fileData); outFile.close(); } } else { qWarning() << "MD5 mismatch, file discarded, receive failed."; }assemble()返回的QByteArray直接交给QCryptographicHash::hash,这一步在内存中完成,不写临时文件。比对失败时直接丢弃该文件的重组上下文(从m_reassemblyMap里移除),不保留残缺文件。后续若发送端设计成支持断点续传,要在剔除上下文前把已收到的分片信息持久化到本地状态文件,否则重传得从零开始。
5. 接收性能排查与乱序、重复包处理
5.1 用 pendingDatagramSize 统计丢包与流量抖动
iperf3报告里的 lost/total 是网络层的丢包率,但应用层看到的丢包率往往更高——因为 socket 接收队列有上限,程序单次readyRead处理过慢时队列溢出,新数据报被内核直接丢弃。要确认是否发生了应用层丢包,可以在接收端做统计:
static quint64 totalReceived = 0; static quint64 totalBytes = 0; // 在排空循环里 totalReceived++; totalBytes += buffer.size(); // 每秒打印一次 static QElapsedTimer timer; if (!timer.isValid()) { timer.start(); } if (timer.elapsed() >= 1000) { qDebug() << "pps:" << totalReceived << "KB/s:" << totalBytes / 1024.0; totalReceived = 0; totalBytes = 0; timer.restart(); }QElapsedTimer在 Unix 上基于CLOCK_MONOTONIC,不受系统时间跳变影响。把qDebug换成实际计数上报到界面或日志文件后,配合iperf3 -u -b 50M发的固定流量,能看到程序是否跟得上线速。若pps(每秒包数)明显低于发送端,说明readyRead槽函数里业务处理太重,需要把数据报拆包放到独立线程的队列里。UDP 接收本身是轻量的,重活别放在接收线程里做。
5.2 Wireshark 筛选 UDP 数据报验证乱序与重复
网络层乱序是 UDP 的常态,尤其经过多路径路由或 Wi-Fi 转发时。收到乱序包后,重组逻辑里用chunkIndex排序即可,不必依赖到达顺序。但程序里要先确认到底是“真乱序”还是“发送端没按顺序发”,这时用 Wireshark 抓包最直接:
wireshark -Y "udp.port == 8888 && !icmp" -i eth0 -k-Y是显示过滤语法,udp.port == 8888只过滤和平共处 8888 端口相关的 UDP 报文,!icmp排除 ICMP 的端口不可达报文——UDP 收端没监听时路由器会回 ICMP,那会污染追踪流。过滤栏里还能加udp.length > 1400看是不是有超大包被 IP 分片,分片后的第一个分片有 UDP 头,后续分片没有,Wireshark 里能看到Fragmented IP protocol标记。如果有大量分片标记,说明发送端没控制好包长,要回头调整分片大小。
用 Wireshark 的统计功能还能判断重复包:在Statistics -> Capture File Properties里看总包数与期望包数的差值。UDP 在局域网内重复包极少,但某些 NAT 设备或双链路热备会出现,接收端的chunks[index].isEmpty()判断就是为了这一刻留住正确数据。
5.3 接收端对 UDP 缓冲的调优参数
Linux 下 Udp 接收缓冲默认值经常不够高吞吐场景用。sysctl net.core.rmem_max可以查到上限,运行时用setsockopt调整接收缓冲区到合理值:
sysctl -w net.core.rmem_max=8388608int rcvBufSize = 8 * 1024 * 1024; setsockopt(udpSocket->socketDescriptor(), SOL_SOCKET, SO_RCVBUF, &rcvBufSize, sizeof(rcvBufSize));socketDescriptor()返回 Qt socket 对应的原生套接字 fd,SOL_SOCKET和SO_RCVBUF是 POSIX 常量,Windows 下同样可用但定义在 WinSock2 头文件里。调大接收缓冲只能缓解突发流量造成的抖动,不能解决发送端一直超速的问题。所以实际部署我会同时做两件事:把缓冲调到 8MB,并在业务层使用“漏桶”机制,超过接收处理能力的数据报直接丢弃,优先保证控制的实时性,比把所有数据都收下来再排队处理更可靠。
另外,Windows 下QUdpSocket绑定的端口如果收包频率非常高,建议在readyRead里调用udpSocket->readDatagram后立刻再次hasPendingDatagrams判断,不要依赖readyRead的再次触发——Windows 定时器粒度会导致高频率包到达时信号合并。接收循环写成while (true)直到队列排空,比一次信号读一个包更稳。最终一个可投入使用的接收线程应当是:readyRead触发 →while排空 → 每条消息按类型分发到独立处理队列 → 文件分片单独进重组上下文。这样顺序和性能都能兼顾,不至于被一次突发流量击穿整个 UI 线程。
本文还有配套的精品资源,点击获取