简介:这是一份面向Qt网络编程初学者与进阶开发者的UDP文件传输实战源码包,聚焦无连接协议下图片、文档等文件的可靠收发问题。资源围绕QUdpSocket套接字创建、数据分包与序列号封装、接收端重组、超时重传与CRC校验等核心环节展开,并引入QTcpSocket辅助传输文件头与确认信息,兼顾实时性与可靠性。压缩包共12个文件,约11KB,包含4个cpp源文件、2个h头文件、2个ui界面文件、2个pro工程文件及2个user配置,客户端与服务端工程结构完整,可直接编译运行。目前已有1770人学习下载。通过研读代码,读者能掌握UDP数据报的打包发送、readyRead信号驱动的接收重组、多线程避免UI阻塞以及丢包与乱序的排错思路,适合作为课程设计、网络实验或小型文件传输工具的参考实现。
1. Qt UDP 传输文件:为什么“能跑通”和“能交付”之间隔着一整套分片协议
很多人第一次用 Qt 写 UDP 传文件,代码不到五十行就能在局域网里把一张图片从 A 发到 B,于是得出一个结论:UDP 传文件很简单。但真把它放到实际场景里——传一个几十兆的日志包、跨一段有丢包的无线链路、或者接收端是个处理速度跟不上的嵌入式板子——立刻就会翻车:文件缺字节、顺序错乱、大包直接触发Message too long,甚至程序收着收着就卡死。问题不在 QUdpSocket 这个类,而在于 UDP 本身只提供“数据报”语义,不保证到达、不保证顺序、不保证不重复,文件传输需要的可靠性得由应用层自己补上。
这篇笔记讲的就是这件事:用 Qt 的 QUdpSocket 做文件传输,怎么从“能发出去”走到“能完整还原”。核心是三块——分片与重组、确认与重传、发送节奏控制。适合已经会写 Qt 网络基础代码、但被大文件或弱网坑过的开发者;如果你只是想在本地回环传个字符串,这里的东西会显得重,但一旦文件超过单个数据报能承载的大小,这套结构就是绕不开的。下面按“先立协议、再写代码、最后排坑”的顺序展开,所有代码都是可直接编译的 Qt Widgets/Console 工程片段。
2. 分片、包头与重组:UDP 传文件的应用层协议怎么设计
UDP 单次发送的数据报有大小上限,这个上限不是固定的 65507 字节那么简单。它受路径 MTU 影响,以太网常见 1500,减去 IP 头 20 字节和 UDP 头 8 字节,留给应用层的安全值通常在 1400~1472 之间。超过这个值,IP 层会分片,而 IP 分片一旦丢一片,整个数据报就废了,重传代价极高。所以正确做法是应用层主动分片,把每个 UDP 包控制在 MTU 以内,让 IP 层不再分片。
2.1 自定义包头:把“第几片、共几片、属于哪个文件”写进去
接收端要重组文件,必须知道每个数据报在文件中的位置。最直接的设计是固定长度的包头,后面跟负载。包头里至少要有:文件标识、分片序号、分片总数、负载长度。下面是一个 16 字节包头的定义,用QDataStream序列化,避免手动处理字节序。
// packet.h #pragma once #include <QByteArray> #include <QDataStream> // 固定 16 字节包头:fileId(4) + seq(4) + total(4) + payloadLen(2) + flags(2) struct PacketHeader { quint32 fileId; // 本次传输的唯一标识,接收端用它区分不同文件 quint32 seq; // 分片序号,从 0 开始 quint32 total; // 分片总数,接收端据此判断是否收齐 quint16 payloadLen; // 本包负载字节数 quint16 flags; // 位标志:bit0=最后一片,bit1=ACK,bit2=重传 }; inline QByteArray encodePacket(const PacketHeader &h, const QByteArray &payload) { QByteArray out; QDataStream ds(&out, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); // 统一网络字节序 ds << h.fileId << h.seq << h.total << h.payloadLen << h.flags; out.append(payload); return out; } inline bool decodePacket(const QByteArray &raw, PacketHeader &h, QByteArray &payload) { if (raw.size() < 16) return false; // 包头都不够,直接丢弃 QDataStream ds(raw); ds.setByteOrder(QDataStream::BigEndian); ds >> h.fileId >> h.seq >> h.total >> h.payloadLen >> h.flags; if (raw.size() < 16 + h.payloadLen) return false; // 负载不完整 payload = raw.mid(16, h.payloadLen); return true; }这里有几个参数需要说清楚。fileId用QDateTime::currentMSecsSinceEpoch()的低 32 位或者QRandomGenerator生成,保证同一次会话里不重复即可。seq用quint32而不是quint16,是因为大文件分片数可能超过 65535——一个 100MB 文件按 1400 字节分片,大约 75000 片,quint16会溢出。payloadLen用quint16足够,因为单包负载不会超过 65535。flags里 bit0 标记最后一片,接收端不用等超时就能知道文件发完了。
2.2 发送端分片:读文件、切片、控制发送间隔
发送端的逻辑是:打开文件,按固定负载大小循环读取,每读一块就组一个包发出去。关键点是发送间隔——如果在一个for循环里连续writeDatagram几千次,网卡缓冲区会瞬间打满,writeDatagram返回 -1,包就丢了。常见做法是每发若干个包就QThread::msleep一下,或者用QTimer驱动发送。
// sender.cpp 片段 void Sender::sendFile(const QString &path, const QHostAddress &addr, quint16 port) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { emit error("无法打开文件"); return; } const quint32 fileId = QRandomGenerator::global()->generate(); const int payloadSize = 1400; // 留足余量,避免 IP 分片 const qint64 fileSize = file.size(); const quint32 total = static_cast<quint32>((fileSize + payloadSize - 1) / payloadSize); quint32 seq = 0; while (!file.atEnd()) { QByteArray payload = file.read(payloadSize); PacketHeader h; h.fileId = fileId; h.seq = seq; h.total = total; h.payloadLen = static_cast<quint16>(payload.size()); h.flags = (seq == total - 1) ? 0x01 : 0x00; // 最后一片置位 QByteArray pkt = encodePacket(h, payload); qint64 sent = socket->writeDatagram(pkt, addr, port); if (sent != pkt.size()) { // 发送失败,记录待重传 pendingRetransmit.insert(seq); } ++seq; // 每 64 个包让出一次事件循环,避免缓冲区打满 if (seq % 64 == 0) { QCoreApplication::processEvents(); QThread::msleep(1); } } file.close(); }payloadSize取 1400 是保守值,实际可以根据QSslSocket或者系统 MTU 探测调整,但不要超过 1472。processEvents加msleep(1)这个组合看起来有点土,但在单线程 GUI 程序里是最省事的节流方式;如果发送端是独立线程,直接用QThread::msleep即可,不需要processEvents。pendingRetransmit是一个QSet<quint32>,记录发送失败的序号,后续重传定时器会扫描它。
2.3 接收端重组:用 QHash 管理乱序到达的分片
UDP 不保证顺序,接收端可能先收到第 5 片再收到第 3 片。重组的数据结构要能按seq存分片,等收齐或者超时后再拼成文件。用QHash<quint32, QByteArray>按序号存,配合一个QBitArray或者计数器判断是否收齐。
// receiver.cpp 片段 void Receiver::onReadyRead() { while (socket->hasPendingDatagrams()) { QByteArray raw; raw.resize(socket->pendingDatagramSize()); QHostAddress sender; quint16 senderPort; socket->readDatagram(raw.data(), raw.size(), &sender, &senderPort); PacketHeader h; QByteArray payload; if (!decodePacket(raw, h, payload)) continue; // 坏包直接丢 auto &ctx = sessions[h.fileId]; if (ctx.total == 0) { ctx.total = h.total; ctx.received.resize(h.total); ctx.received.fill(false); } if (h.seq >= ctx.total) continue; // 越界序号,丢弃 if (!ctx.received.testBit(h.seq)) { ctx.received.setBit(h.seq); ctx.chunks.insert(h.seq, payload); ctx.gotCount++; } // 每收到一片回一个 ACK,让发送端知道这片到了 PacketHeader ack; ack.fileId = h.fileId; ack.seq = h.seq; ack.total = h.total; ack.payloadLen = 0; ack.flags = 0x02; // ACK 标志 socket->writeDatagram(encodePacket(ack, QByteArray()), sender, senderPort); if (ctx.gotCount == ctx.total) { assembleFile(h.fileId); // 收齐,落盘 } } }sessions是QHash<quint32, SessionContext>,每个fileId一个上下文。received用QBitArray标记哪些片到了,chunks存实际数据。gotCount是已收片数,等于total时触发落盘。落盘时按seq从小到大拼接,因为QHash遍历顺序不确定,必须显式排序。
void Receiver::assembleFile(quint32 fileId) { auto it = sessions.find(fileId); if (it == sessions.end()) return; QList<quint32> keys = it->chunks.keys(); std::sort(keys.begin(), keys.end()); // 按序号排序,保证字节顺序正确 QFile out(QString("recv_%1.bin").arg(fileId)); if (!out.open(QIODevice::WriteOnly)) return; for (quint32 k : keys) { out.write(it->chunks.value(k)); } out.close(); sessions.erase(it); // 释放内存 }这套结构在局域网里基本够用,但一旦有丢包,接收端会永远等不到某一片,gotCount卡住。所以必须有超时机制和重传请求,这就是下一章的内容。
3. 确认、重传与超时:让 UDP 文件传输在丢包环境下活下来
纯 UDP 发完就完,丢一片整个文件就废了。要让它可靠,必须在应用层实现一套轻量的 ARQ(自动重传请求)。不需要做到 TCP 那么复杂,但至少要覆盖三件事:接收端告诉发送端“哪些片没到”、发送端重传缺失片、双方都有超时兜底。
3.1 ACK 与 NACK:两种反馈策略怎么选
ACK 是接收端每收到一片就回一个确认,发送端维护一个已确认集合,超时未确认的重传。NACK 是接收端检测到缺片后主动发“否定确认”,发送端只重传被点名的片。两者各有适用场景:ACK 实现简单,但反馈包数量大,传 75000 片就是 75000 个 ACK,反向通道压力大;NACK 反馈包少,但接收端需要知道“应该收到多少片”才能判断缺哪些,依赖包头里的total字段。
实际工程里常见做法是混合:接收端每收到一片不立即回 ACK,而是攒一批(比如每 32 片)回一个位图 ACK,告诉发送端这一批里哪些到了。发送端收到位图后,把没到的片加入重传队列。这样反馈包数量降到 1/32,同时发送端能精确知道缺哪些。
// 位图 ACK:用 QByteArray 存位图,每 bit 代表一片 QByteArray buildBitmapAck(quint32 fileId, quint32 baseSeq, const QBitArray &bits) { QByteArray bitmap((bits.size() + 7) / 8, 0); for (int i = 0; i < bits.size(); ++i) { if (bits.testBit(i)) { bitmap[i / 8] |= (1 << (i % 8)); } } PacketHeader h; h.fileId = fileId; h.seq = baseSeq; // 位图起始序号 h.total = bits.size(); // 位图覆盖的片数 h.payloadLen = bitmap.size(); h.flags = 0x04; // 位图 ACK 标志 return encodePacket(h, bitmap); }发送端收到位图后,逐 bit 检查,为 0 的位置就是缺失片,加入pendingRetransmit。baseSeq让接收端可以分批反馈,比如第一批反馈 0~1023 片,第二批 1024~2047 片,避免单个位图过大。
3.2 重传定时器:超时时间怎么定、重传几次放弃
发送端需要一个QTimer周期性扫描pendingRetransmit,把里面的片重新发一遍。超时时间不能太短,否则正常网络抖动会触发大量无谓重传;也不能太长,否则传输尾延迟很高。局域网里 200ms 是个常用起点,跨公网可以放到 500ms~1s。
// 重传定时器,每 300ms 触发一次 void Sender::onRetransmitTimer() { if (pendingRetransmit.isEmpty()) return; QFile file(currentPath); if (!file.open(QIODevice::ReadOnly)) return; const int payloadSize = 1400; QList<quint32> seqs = pendingRetransmit.values(); for (quint32 seq : seqs) { file.seek(static_cast<qint64>(seq) * payloadSize); QByteArray payload = file.read(payloadSize); PacketHeader h; h.fileId = currentFileId; h.seq = seq; h.total = currentTotal; h.payloadLen = static_cast<quint16>(payload.size()); h.flags = 0x04; // 重传标志 socket->writeDatagram(encodePacket(h, payload), targetAddr, targetPort); } file.close(); // 重传次数统计,超过 5 次认为链路不可用 retryCount++; if (retryCount > 5 && !pendingRetransmit.isEmpty()) { emit error("重传超过上限,传输失败"); stopTransfer(); } }retryCount是全局重传轮次,不是单片重传次数。更精细的做法是给每个片记一个重传计数,超过阈值单独放弃并上报。但工程上全局轮次更简单,5 轮约 1.5 秒还没补齐,基本可以判定链路有问题。
3.3 接收端超时兜底:等不到最后一片怎么办
接收端不能无限等。如果某个fileId的会话超过一定时间没有新片到达,且gotCount < total,就应该主动发 NACK 或者直接放弃。用一个QTimer定期扫描sessions,记录每个会话的最后活动时间。
void Receiver::onSessionTimeout() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); for (auto it = sessions.begin(); it != sessions.end(); ) { if (now - it->lastActive > 10000) { // 10 秒无新片 // 发 NACK 请求缺失片 QBitArray missing(it->total); for (quint32 i = 0; i < it->total; ++i) { missing.setBit(i, !it->received.testBit(i)); } QByteArray nack = buildBitmapAck(it.key(), 0, missing); socket->writeDatagram(nack, it->senderAddr, it->senderPort); it->lastActive = now; // 重置,避免连续发 NACK } ++it; } }这里把位图 ACK 复用成 NACK:位为 1 表示缺失。发送端收到后按位重传。lastActive在每次收到该fileId的片时更新,保证活跃会话不被误判。
4. 避坑与排查:Qt UDP 传文件最常见的 5 个翻车现场
这一章是我自己踩过、也帮别人定位过的真实问题。每条按“现象 → 原因 → 解决”写,不绕弯子。
4.1 现象:小文件正常,超过 8KB 就收不全
原因:发送端单包负载设得太大,比如直接file.readAll()然后一次writeDatagram。超过 MTU 后 IP 层分片,任何一片丢失整个数据报就废了,而接收端只看到“没收到”,不知道是哪个 IP 分片丢的。
解决:应用层强制分片,单包负载不超过 1400 字节。在发送端加一行断言Q_ASSERT(payload.size() <= 1400),把问题挡在编码阶段。
4.2 现象:接收端pendingDatagramSize返回 -1 或 0
原因:在readyRead信号槽里先做了耗时操作(比如写文件、更新 UI),等回来读的时候数据报已经被内核丢弃或者状态变了。UDP 的接收缓冲区是有限的,读得慢就丢。
解决:readyRead里只做一件事——把数据报读进内存队列,后续处理交给另一个线程或QTimer::singleShot(0, ...)。不要在信号槽里直接落盘。
4.3 现象:发送端writeDatagram返回 -1,错误是Message too long
原因:单包超过系统允许的最大 UDP 数据报。不同系统上限不同,Windows 上常见 65507,但路径 MTU 更小。如果代码里负载设成 60000,必然触发。
解决:把负载降到 1400 附近。如果确实需要传大块数据,分片是唯一正路。另外检查socket->state(),未绑定端口的 socket 在某些平台上写会失败。
4.4 现象:接收端重组出来的文件比原文件大,或者内容错位
原因:QHash遍历顺序不确定,落盘时没有按seq排序。或者包头里的payloadLen和实际负载长度不一致,mid截取错位。
解决:落盘前对keys排序,如 2.3 节所示。同时在decodePacket里严格校验raw.size() == 16 + h.payloadLen,不等就丢弃,不要“尽量解析”。
4.5 现象:传输到 99% 卡住,最后一片永远不到
原因:最后一片的flags没有正确置位,或者接收端判断收齐的条件写成了gotCount == total - 1。也有可能是最后一片特别小,发送端file.read返回空,循环提前退出。
解决:发送端用while (!file.atEnd())而不是for (int i = 0; i < total; ++i),保证最后一片被读到。接收端收齐条件严格用gotCount == total。在最后一片的包头里把flags的 bit0 置 1,接收端可以据此提前触发落盘,不用等超时。
5. 进阶技巧:把吞吐量再提一档的 3 个参数与一个验证方法
前面四章把“能完整传完”这件事讲透了。如果你已经跑通,接下来关心的多半是“怎么更快”。UDP 传文件的吞吐瓶颈通常不在带宽,而在发送节奏、接收缓冲和重传策略。下面三个参数是我调过之后收益最明显的,附一个验证方法。
5.1 发送窗口:从“发完再等”改成“边发边等”
2.2 节的发送逻辑是发完整个文件再处理重传,这在丢包率低时没问题,但一旦有丢包,尾部会等很久。更好的做法是维护一个发送窗口,比如窗口大小 256 片,窗口内允许未确认,窗口满了就停下来等 ACK 推进。这样重传和发送重叠,整体时间缩短。
// 滑动窗口发送:窗口内未确认数达到上限就暂停 const int windowSize = 256; while (seq < total) { if (inFlight.size() >= windowSize) { QCoreApplication::processEvents(); QThread::msleep(1); continue; // 等 ACK 释放窗口 } // ... 组包发送 ... inFlight.insert(seq); ++seq; }inFlight是已发未确认的序号集合,收到位图 ACK 后从中移除已确认的片。窗口大小 256 在局域网里比较稳,跨公网可以降到 64,避免突发流量被中间设备丢弃。
5.2 接收缓冲区:setSocketOption调大读写缓冲
Qt 的QUdpSocket默认接收缓冲区不大,高速发送时容易丢包。可以在绑定端口后调大:
socket->setSocketOption(QAbstractSocket::ReceiveBufferSizeSocketOption, 4 * 1024 * 1024); socket->setSocketOption(QAbstractSocket::SendBufferSizeSocketOption, 4 * 1024 * 1024);4MB 是个经验值,嵌入式设备上可能不支持这么大,设置失败也不影响功能,只是缓冲小一些。调完之后用socket->socketOption(...)读回来确认实际生效值。
5.3 负载大小:1400 不是唯一答案
1400 是保守值。如果确认链路 MTU 是 9000(比如某些内网),负载可以提到 8000 以上,分片数减少,包头开销占比下降。但跨公网时路径 MTU 往往只有 1500,甚至更小,贸然加大负载会触发 IP 分片。稳妥做法是先用小负载跑通,再逐步加大,观察丢包率。
| 负载大小 | 适用场景 | 分片数(100MB 文件) | 风险 |
|---|---|---|---|
| 1400 | 跨公网、无线 | 约 75000 | 低,兼容性好 |
| 4096 | 局域网有线 | 约 25600 | 中,需确认 MTU |
| 8192 | 内网大 MTU | 约 12800 | 高,路径 MTU 不匹配会分片 |
5.4 验证方法:用校验和确认重组结果
传完之后怎么确认文件没坏?在发送端算一个 MD5 或 CRC32,放在最后一个包里,接收端落盘后重新算一遍对比。Qt 里用QCryptographicHash很方便:
QFile f("recv.bin"); f.open(QIODevice::ReadOnly); QCryptographicHash hash(QCryptographicHash::Md5); hash.addData(&f); QByteArray digest = hash.result(); // 和发送端传来的 digest 对比这个校验步骤在调试阶段一定要加,它能立刻告诉你“是传输丢了片”还是“重组逻辑写错了”,省下大量猜的时间。
我自己现在的习惯是:任何 UDP 文件传输的代码,第一版一定带 MD5 校验和重传日志,哪怕只是本地测试。因为 UDP 的问题往往不是“完全不通”,而是“大部分通、偶尔坏”,没有校验你根本不知道它坏了。希望帮到你。
本文还有配套的精品资源,点击获取