
简介面向具备一定 C 基础、希望掌握 Qt 网络编程的开发者这套 QTUDP 通信示例围绕 UDP 无连接、低开销、低延迟的特点演示客户端与服务端之间的数据报收发可用于音视频流、实时控制等场景的入门验证。压缩包共 12 个文件约 10KB含 4 个 cpp、2 个 h 源代码及 2 个 ui 界面文件另有 2 个 pro 工程配置和 2 个 user 用户配置便于导入 Qt Creator 对照运行。资源已有 823 人学习读者可借此理解 QUdpSocket 的端口绑定、数据报发送与接收、readyRead 信号处理及基础错误检查并梳理轻量 UDP Demo 的工程组织方式为后续扩展多线程收发、编解码与稳定性处理打基础。1. 局域网里那点事用 QUdpSocket 其实很短刚上手 Qt 网络模块的人往往是在一个局域网小工具里第一次碰到 QUdpSocket上位机要同时给十几台下位机推配置用 TCP 得为每台维护一条连接、处理断线重连换成 UDP 之后一次 writeDatagram 就把包扔出去了。这份 QTUDP通信Demo 就是这类需求的骨架它没把协议做全而是把 Qt 下 UDP 通信的完整路径摊开套接字初始化、端口绑定、数据报收发、广播与组播以及在 readyRead 信号里把裸字节还原成业务消息。适合三类人刚上手 Qt 想找个能直接编译运行的起点、做过 TCP 被粘包和多连接折腾过、需要在局域网做设备发现和状态上报。它真正回答的不是“怎么写一个 UDP 程序”而是“UDP 这一层在你的工程里该长什么样”。2. 工程结构与技术选型三层拆分让网络代码能离开界面活着一个能直接编译的 Demo最容易写坏的恰恰是结构。所有逻辑塞进 MainWindow 的槽函数里二十行能跑通等到要加心跳、加广播、加文件分片界面类会膨胀到八百行想抽出来复用的时候发现到处是 ui- 指针。这个 Demo 的价值一半在收发本身另一半在它把“界面”“网络”“协议”这三件事拆开了。2.1 目录拆分界面、协议、网络各管一摊我一般会把这类项目摆成下面这个样子src 下按职责分目录而不是按文件类型分目录QTUdpDemo/ ├── QTUdpDemo.pro qmake 工程文件 ├── CMakeLists.txt CMake 构建Qt6 优先 ├── src/ │ ├── main.cpp │ ├── ui/ │ │ ├── mainwindow.h │ │ ├── mainwindow.cpp 只负责输入框、按钮、日志展示 │ │ └── mainwindow.ui │ ├── net/ │ │ ├── udpreceiver.h 绑定端口、收包、抛信号 │ │ ├── udpreceiver.cpp │ │ ├── udpsender.h 组包、发送、解析目标地址 │ │ └── udpsender.cpp │ ├── proto/ │ │ ├── packet.h 协议头结构体与常量 │ │ └── packet.cpp 打包 / 解包 / 校验 │ └── model/ │ └── peerlist.h 在线对端列表IP、端口、最后活跃时间 └── res/ └── icons.qrcnet 层只认 QByteArray 和 QHostAddress不 include 任何界面头文件proto 层只做字节层面的打包解包纯函数可以单独写单元测试ui 层拿到的是已经解析好的消息对象。这条边界守住之后后面换成命令行程序、换成无界面服务net 和 proto 一行都不用改。这是我在自己项目里踩出来的规矩网络层只要开始 emit 带 QString 的“已经格式化好的日志”基本就废了它会被界面需求牵着走。具体到类的职责UdpReceiver 持有 QUdpSocket、负责 bind 和 readyRead 槽UdpSender 持有另一个 QUdpSocket负责 writeDatagram 和发送失败的 errorString 上报Packet 提供 encode/decode 两个静态方法返回一个可选的业务消息结构。PeerList 用 QHashQString, PeerInfo 存活跃对端key 用 “ip:port”定时器扫过期。这几个类加起来不到五百行但每一块都能单独拎走。2.2 QUdpSocket 的能力边界以及 Qt5 与 Qt6 的差异选 QUdpSocket 而不是裸 socket理由很实际它把 Qt 的事件循环接上了readyRead 信号天然和界面线程同频不需要自己写 select 循环也不需要处理 EINTR。跨平台方面 Windows 的 Winsock 初始化和 Linux 的 socket 选项差异都被抹平了。代价是它只暴露 UDP 的传输层能力不帮你做任何可靠性保证这一点必须提前想清楚。维度QUdpSocketQTcpSocket连接模型无连接每次带目标地址先 connect之后只写数据消息边界一条数据报就是一条消息流式需要自己切包送达保证不保证到达、不保证顺序保证有序到达单包上限约 65507 字节受 MTU 影响更小无上限典型场景设备发现、状态上报、音视频帧文件传输、命令通道Qt5 和 Qt6 在这块 API 上的差异不大但有三个点要注意。第一QNetworkDatagram 是 Qt 5.8 才有的如果工程要兼容更老的版本接收侧只能退回 readDatagram(char*, qint64, QHostAddress*, quint16*)。第二错误信号在 Qt 5.15 之前叫 error(QAbstractSocket::SocketError)5.15 起改名为 errorOccurredQt6 里只有后者写 connect 的时候要么按版本做条件编译要么直接用旧名字去连。第三Qt6 的 CMake 侧需要用 qt_add_executable 和 qt_standard_project_setupqmake 的 .pro 则几乎原样能编。提示如果从 Qt5 工程迁到 Qt6先看 QTextCodec 相关的代码Qt6 把它挪到了 core5compat 模块而 UDP 本身的收发逻辑一般不需要改。2.3 两份构建配置.pro 与 CMakeListsqmake 那份是给习惯 Qt Creator 直接打开的人准备的四行就够QT core gui widgets network CONFIG c17 TARGET QTUdpDemo TEMPLATE app SOURCES src/main.cpp src/ui/mainwindow.cpp src/net/udpreceiver.cpp \ src/net/udpsender.cpp src/proto/packet.cpp HEADERS src/ui/mainwindow.h src/net/udpreceiver.h \ src/net/udpsender.h src/proto/packet.h FORMS src/ui/mainwindow.ui关键就在 QT network 这一行没有它链接阶段会报 QUdpSocket 未定义。CMake 那份对应 Qt6cmake_minimum_required(VERSION 3.16) project(QTUdpDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 的类必须开 find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network) qt_standard_project_setup() qt_add_executable(QTUdpDemo src/main.cpp src/ui/mainwindow.cpp src/net/udpreceiver.cpp src/net/udpsender.cpp src/proto/packet.cpp ) target_link_libraries(QTUdpDemo PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network)参数上要留意 find_package 里的 Network 组件少写一个就是编译期找不到 QUdpSocket 头文件。CMAKE_AUTOMOC 必须为 ON否则自定义类里继承 QObject 的 Q_OBJECT 宏不会被处理链接时报 vtable 未定义这个错误提示对新手极不友好是典型的入门翻车点。Qt5 用户把 Qt6:: 换成 Qt5::、find_package 里写 Qt5 即可qt_add_executable 换成普通的 add_executable。3. 核心链路拆解从 bind 到 readyRead 的一次完整收发结构摆好之后真正要读透的是那一两百行网络代码。UDP 的收发在 Qt 里被压缩成了四五个函数但每个函数的参数都藏着坑尤其 bind 和 receiveDatagram 的参数含义写法上差一点行为就差很远。3.1 构造与绑定地址、端口和 BindMode 三件事接收端必须绑固定端口否则对端不知道往哪发。构造函数里就该把 bind 和信号连接做完避免对象构造成功但实际不可用的半残状态// src/net/udpreceiver.cpp UdpReceiver::UdpReceiver(quint16 port, QObject *parent) : QObject(parent) , m_socket(new QUdpSocket(this)) { // AnyIPv4 表示监听本机所有 IPv4 网卡用 Any 会同时尝试 IPv6 const bool ok m_socket-bind(QHostAddress::AnyIPv4, port, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); if (!ok) { qWarning() bind port failed: m_socket-errorString(); return; // 后续 readyRead 不会触发调用方需检查 isListening() } connect(m_socket, QUdpSocket::readyRead, this, UdpReceiver::onReadyRead); } bool UdpReceiver::isListening() const { return m_socket-state() QAbstractSocket::BoundState; }参数逐个说。第一个参数是监听地址写 QHostAddress::AnyIPv4 会绑到 0.0.0.0广播和组播都能收写成某个具体网卡地址就只有那块网卡收得到本机 127.0.0.1 发来的包会被丢掉——这是“本机能发不能收”最常见的原因。第二个参数是端口0 表示让系统分配但接收端绝不能用 0。第三个参数是 BindMode默认值 DefaultForPlatform 在 Linux 上对应独占两个进程先后绑同一端口时第二个直接失败加上 ShareAddress | ReuseAddressHint 之后同一台机器上跑两个实例调试才不会互相打架。工程上允许重复绑定是为了调试方便正式部署时一个端口只应该有一个接收者。3.2 发送路径writeDatagram 的返回值能告诉你什么发送端不一定要 bind因为 sendto 时内核会自动分配源端口。但如果对端会回包就必须 bind 一个固定端口否则回包打到随机端口上没人收。发送逻辑本身很短难的是目标地址的解析// src/net/udpsender.cpp qint64 UdpSender::sendText(const QString text, const QHostAddress to, quint16 port) { QByteArray packet Packet::encode(Packet::TypeText, text.toUtf8()); const qint64 written m_socket-writeDatagram(packet, to, port); if (written ! packet.size()) { // UDP 下要么整包写入要么 -1出现部分写入说明包超长 qWarning() send failed, want packet.size() got written m_socket-errorString(); } return written; } bool UdpSender::sendTextToHost(const QString text, const QString host, quint16 port) { QHostAddress addr; if (!addr.setAddress(host)) { // 先按 IP 字面量解析 // 域名解析是阻塞的示例里放主线程真实项目应换成 lookupHost 异步版 const QHostInfo info QHostInfo::fromName(host); if (info.error() ! QHostInfo::NoError || info.addresses().isEmpty()) { return false; } addr info.addresses().first(); } return sendText(text, addr, port) 0; }writeDatagram 的返回值是实际写入的字节数UDP 语义下它只有两种结果等于数据报长度或者 -1。如果出现“小于包长”的返回值通常意味着数据报超过了协议栈允许的上限IPv4 下大约 65507 字节或者是缓冲区瞬间被打满。返回值不是 -1 就以为发成功了是个常见的错觉——UDP 发送成功只代表数据交给了内核不代表对方收得到这一点在后面心跳和重传设计里会再提。3.3 接收路径readyRead 只说明“至少有一个包”readyRead 的语义是“有数据可读”不是“你的消息到了”所以槽函数里一定要用 while 循环把缓冲区读空否则剩下的包要等下一次数据到达才触发信号// src/net/udpreceiver.cpp void UdpReceiver::onReadyRead() { while (m_socket-hasPendingDatagrams()) { const qint64 size m_socket-pendingDatagramSize(); if (size 0) { break; } QNetworkDatagram dg m_socket-receiveDatagram(static_castint(size)); if (!dg.isValid()) { continue; } const Packet::Message msg Packet::decode(dg.data()); if (!msg.valid) { qWarning() drop malformed packet from dg.senderAddress().toString(); continue; // 脏包直接丢弃不要让异常数据进业务层 } emit messageReceived(dg.senderAddress().toString(), dg.senderPort(), msg); } }三个函数的关系要理清hasPendingDatagrams 判断队列里还有没有pendingDatagramSize 返回队首那个包的长度receiveDatagram 按给定长度取出并返回一个 QNetworkDatagram里面同时带 senderAddress、senderPort 和 data。传参时把 pendingDatagramSize 的结果原样传进去不要图省事传一个大值否则可能把后面的包截断。emit 出去的是已经解包并校验过的结构体界面层只负责展示不做字节操作。3.4 应用层协议给每个数据报加个头UDP 保留了消息边界所以不需要像 TCP 那样处理粘包。但实际项目里仍有两种情况需要自己定协议一是同一个端口上要区分文本、心跳、文件块等多种消息二是单条业务消息可能超过 MTU。这个 Demo 里我用一个固定八字节的头来解耦字段类型字节数说明magicquint162固定 0x5154快速识别是不是本程序的包versionquint81协议版本方便后续兼容typequint811 文本 2 心跳 3 文件块seqquint324递增序号仅用于日志和丢失统计payloadSizequint324负载字节数用于校验// src/proto/packet.cpp QByteArray Packet::encode(quint8 type, const QByteArray payload) { QByteArray out; QDataStream s(out, QIODevice::WriteOnly); s.setByteOrder(QDataStream::BigEndian); // 网络字节序跨平台必须显式指定 s.setVersion(QDataStream::Qt_5_15); // 两端版本要一致否则 QString 序列化格式不同 s quint16(0x5154) quint8(1) type quint32(m_seq) quint32(payload.size()); out.append(payload); return out; }参数上有两个必须显式设定的东西字节序和 QDataStream 版本号。默认字节序在小端机器上是 LittleEndian接收方如果是大端设备就全乱QDataStream 的版本决定了 QString 等类型的序列化格式两端不一致时读出来的长度字段都是错的。负载部分直接用 append 塞原始字节不经过 QDataStream避免二次编码带来的额外开销。单包建议控制在 1400 字节以内这个数和以太网 MTU 1500 减去 IP 头 20 字节、UDP 头 8 字节后的余量有关超过就要自己分片加序号重组那是另一个话题了。4. 广播、组播与对端发现一份代码覆盖三种投递方式单播只能发给一个已知地址但设备发现这类需求是一对多的Demo 里把广播和组播都放进来正是因为它俩是局域网场景下最省事的发现手段。4.1 广播setBroadcast 与子网边界广播的写法几乎是单播加一行开关// 发送端允许广播 m_socket-setBroadcast(true); QByteArray probe Packet::encode(Packet::TypeHeartbeat, QByteArray()); // QHostAddress::Broadcast 等价于 255.255.255.255 m_socket-writeDatagram(probe, QHostAddress::Broadcast, 45454);这里有两个容易翻车的点。其一不调用 setBroadcast(true) 直接往广播地址发writeDatagram 会返回 -1错误信息是“Permission denied”之类的措辞看着像权限问题实际是套接字没开广播选项。其二255.255.255.255 只在本地子网内有效路由器不会转发跨网段必须用子网定向广播地址比如 192.168.1.255而且要提前算好掩码。接收侧就更简单了只要 bind 的是 AnyIPv4 的对应端口广播包会正常触发 readyReadsenderAddress 拿到的是发送方的实际 IP。真要用广播做设备发现节奏上建议“上电连发三次、间隔 300 毫秒”而不是一直刷。广播包会被同一网段所有机器收到频率高了纯属干扰别人。发现到对端之后立刻转成单播继续通信广播只用在“我还不知道你是谁”的阶段。4.2 组播joinMulticastGroup 与网卡选择组播比广播克制只有加入了同一个组地址的进程才收得到。组地址范围是 224.0.0.0 到 239.255.255.255其中 224.0.0.x 段被协议保留实际项目里习惯用 239.x.x.x// 接收端绑定任意地址后加入组播组 QUdpSocket *mc new QUdpSocket(this); mc-bind(QHostAddress::AnyIPv4, 45455, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); bool joined mc-joinMulticastGroup(QHostAddress(239.255.43.21)); if (!joined) { qWarning() join multicast failed: mc-errorString(); } // 发送端组播包的 TTL 一般设小一点避免被路由器转出本网段 QUdpSocket *ms new QUdpSocket(this); ms-setSocketOption(QAbstractSocket::MulticastTtlOption, 1); ms-writeDatagram(payload, QHostAddress(239.255.43.21), 45455);绑定地址写 AnyIPv4 是必须的写具体网卡地址在某些平台上会让 joinMulticastGroup 失败。多网卡机器上笔记本同时开着有线和无线很常见joinMulticastGroup 的第二个参数可以指定 QNetworkInterface否则包可能从错误的网卡出去。TTL 设 1 表示只在本网段传播这是局域网发现业务里最合理的值。判断网卡是否选对可以遍历 QNetworkInterface::allInterfaces() 打印每个接口的 flags看哪个带 IsUp 和 IsRunning。4.3 心跳与在线列表用 QTimer 维护对端状态UDP 没有连接状态对端什么时候下线你完全不知道所以在线列表只能靠心跳推出来// src/model/peerlist.cpp void PeerList::onHeartbeat(const QString ip, quint16 port) { const QString key QString(%1:%2).arg(ip).arg(port); PeerInfo info m_peers[key]; info.ip ip; info.port port; info.lastSeen QDateTime::currentMSecsSinceEpoch(); info.online true; } void PeerList::sweep() { const qint64 now QDateTime::currentMSecsSinceEpoch(); for (auto it m_peers.begin(); it ! m_peers.end(); ) { if (now - it-lastSeen kTimeoutMs) { // 三个心跳周期没更新就摘掉 emit peerOffline(it-ip, it-port); it m_peers.erase(it); } else { it; } } }心跳间隔我一般设 2 秒超时阈值设 3 个周期即 6 秒容忍偶发丢包。这里有个细节超时不要只靠“最后一次心跳时间”还要考虑本地时钟被系统校时改掉的情况用单调时钟 QElapsedTimer 更稳妥示例里用毫秒时间戳是为了好读。另外心跳包要单独一个 type不要和业务消息混用否则日志里满屏都是心跳排查问题时根本看不清。5. 避坑与排查UDP 在真实网络里的五条经验Demo 在本机上跑通不代表能上线局域网环境里的问题基本集中在端口、地址、编码和性能这四块。下面五条是我在类似项目里真真切切碰过的。5.1 现象第二个实例一启动就收不到包现象同一个程序开两个窗口做自测第二个窗口的日志一直为空bind 返回 falseerrorString 是“Address already in use”。原因QUdpSocket 的默认绑定的绑定模式在多数平台上对端口是独占的第一个实例没释放之前第二个拿不到。解决接收端的 bind 调用统一加上 QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint两个实例就能同时监听同一端口数据报会被复制给所有绑定者。需要注意这只适合调试正式场景里两个进程抢同一端口谁收到包是不确定的。5.2 现象本机自己发给自己一条都收不到现象发送端显示 writeDatagram 返回了正数接收端 readyRead 就是不触发换到局域网另一台机器就正常。原因通常有两个接收端 bind 的是某块具体网卡的地址而发送端发往 127.0.0.1地址对不上或者目标是 255.255.255.255 广播但接收端绑定到了具体地址广播包被网卡过滤掉了。解决接收端一律绑 QHostAddress::AnyIPv4让它监听所有网卡包括回环自测时优先往 127.0.0.1 单播确认链路通了再试广播。另外 Windows 上首次运行会弹防火墙授权框点“取消”之后再想收包就得去防火墙规则里手动放行这个坑很隐蔽。5.3 现象中文消息到对端变成乱码或者问号现象发“温度异常”四个字对方收到的是一串乱码或者长度对不上解析失败。原因QString 转 char* 时用了隐式转换走的是 Latin-1 编码中文直接被替换成问号另一种情况是两端 QDataStream 版本不一致长度字段按不同规则序列化。解决所有字符串出入都显式走 toUtf8 / fromUtf8协议头里的长度字段按字节数算而不是按字符数算QDataStream 在两端写死同一个版本号比如都写 QDataStream::Qt_5_15。上线前用一段纯中文加 emoji 的测试串跑一遍比看十遍代码管用。5.4 现象高频发送时界面卡成幻灯片现象压测时每秒发几百个包界面按钮点不动日志窗口滚动有延迟。原因有两个叠加一是每条数据报都 emit 一次信号、追加一次日志到 QTextEdit控件重绘成了瓶颈二是 readyRead 里没把队列读空事件循环反复被唤醒。解决接收槽里用 while 循环一次读光攒到一个 QVector 后再发一个批量信号界面侧用 QTimer 每 100 毫秒合并刷新一次而不是来一条刷一条再不行就把网络对象 moveToThread 到独立线程用队列连接把结果传回界面。这个改法通常能把 CPU 占用从单核跑满降到个位数。5.5 现象大包发出去了收到的却是半截现象发一条几十 KB 的 JSON接收端解析时报“数据不完整”pendingDatagramSize 拿到的长度比预期小。原因超过了链路 MTU 触发 IP 分片任何一个分片丢失整个数据报就作废更极端的情况下超过 65507 字节writeDatagram 直接返回 -1。解决协议层强制单包不超过 1400 字节超出就按序号切片接收端做重组并设超时丢弃不完整的组或者干脆把大块数据挪到 TCP 通道上UDP 只承载控制消息。这条属于设计阶段就要定下来的约束等上线再改代价很大。6. 二次开发把它抽成一个能复用的 UdpChannelDemo 里的 Sender 和 Receiver 分成两个类是便于讲解真到项目里我更愿意合成一个 UdpChannel收发同一个套接字接口窄一点用起来省心。6.1 封装边界与接口设计我的做法是把 bind、发送、批量接收、对端列表全收进去对外只暴露四个东西// src/net/udpchannel.h class UdpChannel : public QObject { Q_OBJECT public: explicit UdpChannel(quint16 localPort, QObject *parent nullptr); bool start(); // bind 连接信号 qint64 sendTo(const QByteArray payload, const QHostAddress to, quint16 port); void enableBroadcast(bool on); signals: void messagesReady(const QVectorPacket::Message batch); // 批量上抛 void errorOccurred(const QString reason); };关键改动有三个。其一接收侧不再逐条 emit攒够一批或者定时器到点再发界面侧订阅 messagesReady 就够了刷新频率和解包频率解耦。其二把在线列表也吃进来start() 里顺带起心跳定时器和过期清扫定时器调用方只需要关心“谁在线”这个信号。其三错误统一走 errorOccurred 字符串而不是把 QAbstractSocket 的枚举抛给上层界面层不该知道 SocketError 是什么。这套接口换到命令行程序或者后台服务里照样能用不依赖任何控件。6.2 验证方法别急着开界面封装完先别写界面用两个终端验证链路更高效。Linux 或 macOS 上可以用 nc 直接发一条 UDP 包到本地端口看程序日志有没有打印对端地址# 终端 1先跑起来你的接收端监听 45454 ./QTUdpDemo --listen 45454 # 终端 2发一条纯文本 UDP 包到本机 printf hello-udp | nc -u -w1 127.0.0.1 45454 # 终端 2 变体发广播到当前网段需要系统允许 printf probe | nc -u -w1 -b 255.255.255.255 45454看到日志里出现 127.0.0.1 和正确的负载长度说明 bind 和 readyRead 这条链通了再换到另一台机器上重试通不过就按第 5 章的顺序查网卡地址、防火墙、子网掩码。之后再测组播用 ip maddr 看网卡有没有真的加入组这一步能省掉很多“代码明明没错”的争论。压测用 for 循环发一万个包统计接收端实际收到的数量丢包率在局域网内应该很低如果掉得厉害先看接收端是不是在界面线程里同步处理。这套流程我后来固化成了习惯每加一个网络模块先写一个不依赖界面的最小可执行用命令行和 nc 打通再把界面接上去。从那以后我每次新建网络通信相关的类都强制先跑一遍自环和跨机两条验证再动 UI 代码。希望帮到你。本文还有配套的精品资源点击获取