简介:面向Qt初学者与网络编程入门者的UDP局域网通信示例工程,围绕QUdpSocket的收发封装展开,演示同一IP下通过绑定不同端口号建立客户端之间的通信连接,帮助读者把UDP协议从概念落到可运行的Qt代码上。压缩包共8个文件,含3个cpp源文件、2个h头文件、2个ui界面文件与1个pro工程文件,整体仅7KB;pro工程可直接用Qt Creator打开编译,ui文件对应收发两端界面,cpp与h文件承载套接字初始化、数据报收发和界面交互逻辑。资源已有2282人学习下载,作为入门练习的参考价值较为稳定。读者可获得一套完整可编译的工程骨架、UDP发送与接收的基本流程写法、多端口客户端的组织方式,以及信号槽与界面联动的入门范例,便于在此基础上改造成局域网聊天、调试小工具或课程作业。
1. 局域网里发一条消息,Qt5 的 UDP 比你想的更“薄”
会议室里两个人隔着一台没连外网的路由器,要互相发一段文本、一张截图、一段几 KB 的配置片段——Qt5 写的 UDP 局域网通信软件,恰好是这种场景里成本最低的解法:不用起服务端、不用配证书、不用管 NAT 端口映射,两个QUdpSocket绑到同一个端口就能对话。项目名里那个“简单”不是谦虚,它确实只做了收发和在线列表,没有消息持久化,也没有跨网段路由能力。但也正因为薄,它适合三类人:想搞清楚QUdpSocket到底怎么落地的人、打算拿它当自己工具通信底座的人、以及需要一份能直接改的局域网聊天 demo 的人。下文按我拆包复现的顺序走,从工程骨架到广播发现再到应用层分帧,最后落到几个我踩过的坑。
2. QUdpSocket 收发闭环:把 bind、readyRead、writeDatagram 三件事做对
这四个 API 名字谁都认识,但真正决定“跑不跑得起来”的,是它们的参数和调用时序。我见过太多 demo 倒在第一关:程序编译通过、界面正常、点发送毫无反应,日志里一行错误都没有。这一章把最小闭环拆开讲,每种失败模式对应一处具体写法。
2.1 .pro 里少写一行 network,编译期就给你脸色
Qt5 的网络类不在 core 里,.pro少一行network会直接报'QUdpSocket' was not declared in this scope。这个错误信息很有欺骗性,新手容易以为是头文件没包含,于是往.cpp里一通#include,结果还是报错——因为模块根本没链接进来。
# chat.pro —— 只列和网络相关的几行,其余是常规 widgets 工程配置 QT += core gui network CONFIG += c++11 TARGET = LanChat SOURCES += main.cpp mainwindow.cpp chatnode.cpp HEADERS += mainwindow.h chatnode.hQT += core gui network这一行做两件事:把头文件搜索路径加上,把-lQt5Network加到链接参数里。CONFIG += c++11是给后面用nullptr、范围 for 循环、auto铺路,Qt5 的默认标准在不同编译器上不一致,显式写死省事。
文件职责我拆成两层,界面和通信解耦,后面想换 UI 或者把通信层塞进别的工程都不用改逻辑:
| 文件 | 职责 | 关键点 |
|---|---|---|
chatnode.h/.cpp | 纯通信层,不引用任何 UI 头文件 | 收发、广播发现、心跳、去重都在这 |
mainwindow.h/.cpp | 界面,只调ChatNode的公开方法 | 用信号把收到的消息抛给界面 |
main.cpp | 启动入口 | 只做QApplication和主窗口 |
把通信层做成一个QObject子类、只用信号往外抛数据,是这类小工具最划算的结构。后面要做多线程、要做单元测试、要把它塞进命令行程序,都不用动界面代码。
2.2 bind 的四个参数,决定你是“收得到”还是“静悄悄”
QUdpSocket::bind()有两个重载,带BindMode的那个才是重点。默认参数是QUdpSocket::DefaultForPlatform,在 Windows 和 Linux 上行为不一致,这是跨平台翻车的源头之一。
// chatnode.cpp #include "chatnode.h" #include <QUdpSocket> #include <QHostAddress> bool ChatNode::start(quint16 listenPort) { m_socket = new QUdpSocket(this); // AnyIPv4:只监听 IPv4。用 Any 在部分系统会走 IPv6 双栈,和对方的 IPv4 广播地址对不上 // ShareAddress:允许同一台机器上的多个实例绑同一端口,本机双开调试必备 // ReuseAddressHint:等价于 SO_REUSEADDR,Windows 上多播同端口监听也依赖它 const bool ok = m_socket->bind(QHostAddress::AnyIPv4, listenPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); if (!ok) { qWarning() << "bind port" << listenPort << "failed:" << m_socket->errorString(); delete m_socket; m_socket = nullptr; return false; } connect(m_socket, &QUdpSocket::readyRead, this, &ChatNode::onReadyRead); m_port = listenPort; return true; }参数逐个说清楚。第一个参数传QHostAddress::AnyIPv4而不是Any,是为了把监听范围锁死在 IPv4——局域网里的广播地址都是 IPv4 概念,双栈反而添乱。第二个参数是端口,传0表示让系统分配临时端口,但局域网发现场景下必须固定端口,否则对方根本不知道往哪个端口发。
第三个参数ShareAddress的含义容易被误解:它不是“共享给别的进程读写”,而是允许同一端口被多个 socket 绑定。在 Linux 上它的底层就是SO_REUSEADDR加SO_REUSEPORT的组合效果,在 Windows 上对应SO_REUSEADDR。做本机双开测试时,没有这个标志,第二个实例必然bind失败,你会以为是代码问题,其实只是端口被占了。
bind失败时一定要打印errorString()。我见过一份 demo 直接把返回值丢了,结果防火墙拦截、端口被占、权限不足三种完全不同的原因,在界面上表现都是“收不到消息”,排查成本翻好几倍。
2.3 readyRead 里那个 while 循环,删掉就丢包
readyRead是电平触发语义:只要接收缓冲区里还有数据报没读完,信号就可能再次发出。但如果你在槽函数里只读一次,剩下的数据报会一直躺在缓冲区里,直到下一次有新包到达才被“顺带”处理——表现就是消息延迟、丢包、顺序错乱,而且极难复现。
void ChatNode::onReadyRead() { // 一次 readyRead 可能对应多个待处理数据报,必须循环读到空 while (m_socket->hasPendingDatagrams()) { const qint64 size = m_socket->pendingDatagramSize(); if (size <= 0) { // 空数据报也要取走,否则循环不退出 m_socket->readDatagram(nullptr, 0); continue; } QByteArray buf; buf.resize(int(size)); // 按真实长度分配,不要写死 1024 QHostAddress from; quint16 fromPort = 0; const qint64 n = m_socket->readDatagram(buf.data(), buf.size(), &from, &fromPort); if (n < 0) { qWarning() << "readDatagram failed:" << m_socket->errorString(); continue; } buf.resize(int(n)); // 实际读到的长度可能小于缓冲区 handleDatagram(buf, from, fromPort); } }三个细节值得单独拎出来。pendingDatagramSize()返回的是下一个待读数据报的完整长度,用它来resize缓冲区才能保证不截断;如果图省事写死char buf[1024],超过 1KB 的消息会被静默截断,接收端解析失败但不报错,这是最典型的“黑匣子”现场。readDatagram的返回值是实际写入的字节数,正常情况下等于size,但保险起见还是要resize。最后一个参数对from和fromPort是可选的,但局域网聊天室要靠它区分发送者,一定要取。
还有一点:QUdpSocket有线程归属,必须在创建它的那个线程里调用。跨线程用moveToThread之后通过信号槽排队调用,别在主线程直接writeDatagram。
2.4 writeDatagram 的返回值不是装饰
发送这一侧的坑比接收少,但返回值必须看。UDP 是面向数据报的,writeDatagram要么整包写进内核缓冲区,要么返回 -1,不存在“写了一半”的情况。
qint64 ChatNode::sendRaw(const QByteArray &datagram, const QHostAddress &to, quint16 port) { if (!m_socket || m_socket->state() != QAbstractSocket::BoundState) { qWarning() << "socket not ready"; return -1; } if (datagram.size() > kMaxDatagram) { // kMaxDatagram = 1400 qWarning() << "datagram too large:" << datagram.size(); return -1; } const qint64 n = m_socket->writeDatagram(datagram, to, port); if (n == -1) { const auto err = m_socket->error(); qWarning() << "send failed:" << m_socket->errorString() << "code" << int(err); if (err == QAbstractSocket::DatagramTooLargeError) { // 超过系统上限,通常是几十 KB,属于设计问题而不是网络问题 } } return n; }writeDatagram返回成功写入的字节数,正常等于datagram.size()。返回 -1 时用error()拿到具体错误码,DatagramTooLargeError和NetworkError的处理思路完全不同:前者是你的包设计有问题,后者多半是网卡没起来或者目标地址不可达。
发送成功不代表对方收到。UDP 没有确认机制,writeDatagram返回正数只能说明数据报交给了操作系统的发送缓冲区。这一点在做在线列表和消息可靠性时必须心里有数,后面的章节会专门处理。
我自己检查这套闭环的顺序是固定的:先看bind返回值,再看state()是不是BoundState,然后用netstat -ano | findstr 45454(Linux 下ss -lunp | grep 45454)确认端口真的在监听,最后才怀疑网络。顺序反了就是在瞎猜。
3. 在线列表怎么自动亮起来:广播发现、心跳与组播三选一
收发通了之后,下一个需求几乎必然是“我怎么知道局域网里还有谁”。手动填 IP 的 demo 没法交付,自动发现是这类工具真正的门槛。三种做法——全网广播、子网定向广播、组播——成本和适用场景完全不同,选错了会在多网卡机器上直接失效。
3.1 先把广播地址算对:QNetworkInterface 遍历与掩码兜底
新手最容易犯的错是直接往255.255.255.255发。这个地址在单网卡、单网段的机器上能工作,但一旦机器上装了虚拟机网卡、Docker 虚拟网卡、或者插了第二个网口,包会从错误的网卡出去,对方永远收不到。
正确做法是遍历所有网络接口,逐个算子网定向广播地址(比如192.168.1.255),对每个有效网卡都发一份。
QList<QHostAddress> ChatNode::broadcastAddresses() const { QList<QHostAddress> result; const auto ifaces = QNetworkInterface::allInterfaces(); for (const QNetworkInterface &iface : ifaces) { const auto flags = iface.flags(); // 只要“已启用 + 正在运行 + 非回环”的接口 if (!(flags & QNetworkInterface::IsUp)) continue; if (!(flags & QNetworkInterface::IsRunning)) continue; if (flags & QNetworkInterface::IsLoopBack) continue; for (const QNetworkAddressEntry &entry : iface.addressEntries()) { if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol) continue; QHostAddress b = entry.broadcast(); if (b.isNull()) { // 部分平台(尤其某些虚拟网卡)拿不到 broadcast,自己按掩码算 const quint32 ip = entry.ip().toIPv4Address(); const quint32 mask = entry.netmask().toIPv4Address(); if (mask == 0) continue; b = QHostAddress((ip & mask) | ~mask); } if (!result.contains(b)) result.append(b); } } return result; }QNetworkInterface::allInterfaces()返回的是系统当前所有接口,flags()里那三个标志必须同时判断:IsUp表示管理上启用了,IsRunning表示物理链路真的通(网线没拔、Wi-Fi 连着),IsLoopBack要把lo/127.0.0.1排除掉,否则你会往自己的回环里发广播。
entry.broadcast()不是所有平台都填得出来,虚拟网卡尤其常见返回空地址。兜底算法是标准的:网络号 =ip & mask,广播地址 = 网络号把主机位全置 1,也就是| ~mask。这里要用quint32而不是int,否则按位取反会带符号位。
Windows 上还有一道隐含门槛:发广播前必须打开SO_BROADCAST。Qt 在 Unix 系上内部会替你处理,Windows 上不会,结果就是writeDatagram返回 -1 但你完全不知道原因。
void ChatNode::enableBroadcast() { #ifdef Q_OS_WIN // Windows 上必须显式打开 SO_BROADCAST,否则发往广播地址会失败 const qintptr fd = m_socket->socketDescriptor(); if (fd == -1) { qWarning() << "socketDescriptor invalid, bind 之后再调用"; return; } int enable = 1; ::setsockopt(SOCKET(fd), SOL_SOCKET, SO_BROADCAST, reinterpret_cast<const char *>(&enable), sizeof(enable)); #endif }注意socketDescriptor()只有在bind成功之后才有效,在构造函数里调用会拿到 -1。这个顺序问题我在两个工程里都见过,写法上建议把它放在start()的最后一步,和bind挨着。
3.2 心跳包设计:3 秒一次,10 秒判死
广播发现的核心不是“发”,而是“什么时候认为对方已经下线”。UDP 不可靠,掉一两个包很正常,判死阈值太激进会导致在线列表疯狂闪。
我的做法是固定节奏:每 3 秒广播一次hello,每个节点维护一个lastSeen时间戳,超过 10 秒没更新就剔除。3 秒的间隔是权衡——再快会让无线网络上的广播流量明显上升,再慢下线感知就很迟钝;10 秒约等于容忍连丢三个包。
void ChatNode::onHeartbeatTick() { QJsonObject hello{ {"id", m_selfId}, // 每个实例一个 UUID,别用 IP 做唯一键 {"nick", m_nick}, {"port", int(m_port)}, {"ts", QDateTime::currentMSecsSinceEpoch()} }; const QByteArray datagram = encode(hello, MsgType::Hello, nextMsgId()); for (const QHostAddress &b : broadcastAddresses()) { sendRaw(datagram, b, m_port); // 发现包和聊天包共用端口,靠 type 区分 } // 剔除超时节点 const qint64 now = QDateTime::currentMSecsSinceEpoch(); bool changed = false; for (auto it = m_peers.begin(); it != m_peers.end(); ) { if (now - it->lastSeen > kPeerTimeoutMs) { // kPeerTimeoutMs = 10000 it = m_peers.erase(it); changed = true; } else { ++it; } } if (changed) emit peerListChanged(m_peers.keys()); }唯一键必须用 UUID 而不是 IP,原因很实际:同一台机器双开两个实例,IP 完全相同,用 IP 做键会互相覆盖;DHCP 环境下 IP 还可能变。QUuid::createUuid().toString(QUuid::WithoutBraces)一行就够,持久化到配置文件里,重启后身份不变。
hello包里带上自己的监听端口,是为了处理“发现端口和聊天端口不同”的扩展场景。简单版共用一个端口更省事,靠包头里的type字段区分心跳和文本。
收到hello的处理逻辑要注意:不要每收到一包就emit一次列表变化,界面会疯狂重绘。我的做法是只在m_peers的键集合真正发生变化时才发信号,时间戳更新走静默路径。
3.3 组播换广播:joinMulticastGroup 与 TTL 的一个数字
广播的问题是会打扰同一网段里的所有设备,交换机也要把广播帧泛洪到所有端口。局域网里设备一多,或者碰上对广播敏感的环境,就该换组播了。
组播的地址段是224.0.0.0/4,本地管理范围推荐用239.x.x.x。关键是 TTL 参数:设成 1 表示只在本地子网内传播,路由器不会转发,这正好符合“局域网通信”的定位。
bool ChatNode::startMulticast(const QHostAddress &group, quint16 port) { m_socket = new QUdpSocket(this); // 组播同样需要 ShareAddress,否则同一台机器只能起一个监听者 if (!m_socket->bind(QHostAddress::AnyIPv4, port, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint)) { qWarning() << "bind failed:" << m_socket->errorString(); return false; } if (!m_socket->joinMulticastGroup(group)) { qWarning() << "joinMulticastGroup failed:" << m_socket->errorString(); return false; } // TTL=1:限制在本地子网,跨路由器不转发 m_socket->setSocketOption(QAbstractSocket::MulticastTtlOption, 1); // 回环开关:本机多实例调试时设 1 能收到自己发的包,正式运行设 0 减少干扰 m_socket->setSocketOption(QAbstractSocket::MulticastLoopbackOption, 0); connect(m_socket, &QUdpSocket::readyRead, this, &ChatNode::onReadyRead); return true; }joinMulticastGroup的失败原因八成是地址不合法(比如传了192.168.x.x)或者网卡不支持。组播还有一个广播没有的坑:多网卡机器上,加入组播组时可以指定网卡,不指定的话系统会挑默认路由那张网卡,虚拟机网卡一多就可能挑错。
// 指定从哪张网卡加入组播组,避免虚拟网卡抢走默认路由 const QNetworkInterface iface = QNetworkInterface::interfaceFromName("eth0"); if (!m_socket->joinMulticastGroup(group, iface)) { qWarning() << "join on" << iface.name() << "failed:" << m_socket->errorString(); }广播和组播怎么选,我的判断标准很粗暴:设备少于 20 台、网络环境干净,广播足够,代码少一半;设备多、有虚拟化环境、或者对方明确要求减少广播流量,就上组播。两种方式在同一个工程里可以共存,用一个枚举切换即可。
4. 应用层协议:16 字节包头 + JSON 载荷,把“简单”做成可扩展
能收发文本之后,很快会加上“发送文件”“发送图片”“撤回消息”这些需求。如果一开始就是裸字符串,后面每加一个功能都要改解析逻辑,改到最后没人敢动。花半小时定一个包头,是这类 demo 唯一值得提前做的设计。
4.1 UDP 有边界,但边界不等于可信
很多人以为 UDP 保留了消息边界,所以不需要应用层分帧。这句话只对了一半:readDatagram确实一次返回一个完整数据报,不会像 TCP 那样粘包。但它不保证三件事——包一定到达、包内容完整、包来自你以为的那个人。
TCP/IP 协议栈在这一层只做校验和,UDP 的校验和还是可选的(IPv4 下可以关)。局域网环境里出错概率低,但你自己代码里的 bug 会制造同样的问题:缓冲区截断、编码错误、把心跳包当文本包渲染。包头的作用就是让接收端在做任何解析之前,有能力判断“这包我该不该处理”。
// 协议常量,全工程只在这一处定义 namespace Proto { constexpr quint16 kMagic = 0x5154; // 'Q' 'T',用来快速排除非本协议的包 constexpr quint8 kVersion = 1; constexpr int kHeaderSize = 16; constexpr int kMaxDatagram = 1400; // 单包上限,见 4.3 }4.2 包头字段为什么是这六个
协议设计不需要多复杂,但每个字段都得有理由。这套 16 字节头我用在好几个小工具里,改的都是载荷部分,头部一直没动过:
| 偏移 | 长度 | 字段 | 取值与作用 |
|---|---|---|---|
| 0 | 2 | magic | 固定0x5154,一字节判掉误收的杂包 |
| 2 | 1 | version | 当前为 1,将来改协议时老客户端能自己退出 |
| 3 | 1 | type | 1=文本 2=心跳 3=文件块 4=ACK |
| 4 | 8 | msgId | 单调递增,用于去重和 ACK 关联 |
| 12 | 4 | payloadLen | 载荷字节数,网络字节序,做长度自校验 |
| 16 | N | payload | UTF-8 编码的 JSON |
magic看着像仪式感,实际很有用:端口被别的程序复用、或者调试时误发了别的包进来,头两个字节就能挡掉。version是为了将来加字段时不至于让老版本崩掉——收到不认识的版本直接忽略,比解析到一半越界安全得多。
msgId是整套协议里最关键的一个字段。它同时承担两个职责:去重(同一个包被广播和多播各收一次)和 ACK 关联(后面做可靠传输时要用)。用单调递增的 64 位整数,配合节点 UUID 就能全局唯一。
序列化用QDataStream,一定要显式设置字节序和版本,否则不同机器上的默认值可能不一致:
QByteArray ChatNode::encode(const QJsonObject &obj, quint8 type, quint64 msgId) { const QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact); if (Proto::kHeaderSize + payload.size() > Proto::kMaxDatagram) { qWarning() << "payload too large:" << payload.size(); return {}; } QByteArray out; out.reserve(Proto::kHeaderSize + payload.size()); QDataStream ds(&out, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); // 网络字节序,跨平台必须固定 ds << Proto::kMagic; ds << Proto::kVersion; ds << type; ds << msgId; ds << quint32(payload.size()); // 长度最后写,方便先算出来 out.append(payload); return out; }解析侧要比编码侧严格得多,任何一项校验不过都直接丢弃,不做“尽力修复”:
bool ChatNode::decode(const QByteArray &datagram, quint8 &type, quint64 &msgId, QJsonObject &obj) { if (datagram.size() < Proto::kHeaderSize) return false; QDataStream ds(datagram); ds.setByteOrder(QDataStream::BigEndian); quint16 magic = 0; quint8 ver = 0; quint32 len = 0; ds >> magic >> ver >> type >> msgId >> len; if (magic != Proto::kMagic) return false; // 不是本协议的包 if (ver != Proto::kVersion) return false; // 版本不兼容 if (len != quint32(datagram.size() - Proto::kHeaderSize)) return false; // 长度自校验 const QByteArray payload = datagram.mid(Proto::kHeaderSize, int(len)); QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(payload, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) return false; obj = doc.object(); return true; }len != datagram.size() - kHeaderSize这一行是整个解析里性价比最高的检查。它同时挡住了缓冲区截断(实际收到的比声明的短)和协议错位(多读了几个字节)。没有这一行,mid()会返回一个残缺的 JSON,fromJson报错,但你不知道是网络问题还是代码问题。
4.3 去重窗口与长度上限:两个必须写死的常量
长度上限。以太网 MTU 是 1500 字节,减去 IP 头 20 字节和 UDP 头 8 字节,一个数据报最多 1472 字节不分片。超过这个数,IP 层会切片,任意一片丢失整个数据报就废了。而局域网里无线环境丢片是常态,所以我把上限压到 1400,留点余量。
超过 1400 的消息怎么办?分片,在应用层做。给文件块类型的载荷加seq和total字段,接收端按msgId聚合。这件事要提前想好,等到用户发一张 3MB 的截图再回头改协议,成本高得多。
去重窗口。广播和组播共存、或者网络里存在环路时,同一个包收到两次很正常。用一个定长队列维护最近处理过的msgId:
bool ChatNode::isDuplicate(quint64 msgId) { if (m_seen.contains(msgId)) return true; // QSet<quint64> 查得快 m_seen.insert(msgId); m_seenOrder.enqueue(msgId); // QQueue 记插入顺序 // 窗口只保留最近 512 条,超出的从两端一起淘汰 while (m_seenOrder.size() > kDedupWindow) { m_seen.remove(m_seenOrder.dequeue()); } return false; }窗口大小 512 是经验值:局域网里每秒几十个包的场景,512 条能覆盖十几秒的重复窗口,内存占用可以忽略。窗口太小会漏判,太大则纯属浪费。
还有一条容易忘的:文本统一走 UTF-8。发送端QString::toUtf8(),接收端QString::fromUtf8(),中间不要经过fromLatin1。用QLocale::system()或者本地 8 位编码在两台机器语言设置不同时必乱码,而且乱码位置不固定,非常难查。
5. 避坑与排查:五个让我加班到深夜的现场
这一章全是真实踩过的坑,现象都很有迷惑性。每条按“现象 → 原因 → 解决”写,遇到类似症状可以直接对号入座。
5.1 收发链路上的三处翻车
现象:本机双开两个实例,各自都能收到自己发的消息,但完全收不到对方发的。
原因有两个层面。一是bind时没带ShareAddress,第二个实例起不来,你以为开了两个其实只有一个在跑;二是即便两个都绑上了同一端口,发往127.0.0.1的包只有本进程能收,发往广播地址的包在同一台机器上会不会回环给自己,取决于操作系统和各 socket 的绑定模式,行为很不确定。跨机通信没问题、本机联调就是不行,是最典型的组合症状。
解决方式是三件事一起做:bind加ShareAddress | ReuseAddressHint;调试时把组播回环开关设为 1;最稳妥的是本机调试走两个不同端口,互相指定对方端口发单播。别在本机双开上用广播做验证,那不是可靠测试环境。
现象:接收端收到的中文偶尔变成乱码,或者 JSON 解析在某个字段上失败。
原因几乎总是缓冲区长度。写死char buf[1024]的代码,超过 1KB 的消息被readDatagram截断,QJsonDocument::fromJson在一个不完整的字符串上必然失败,但错误信息指向的是 JSON 语法,看着像编码问题。另一条路径是编码不一致:发送端用toLocal8Bit,接收端用fromUtf8,英文正常中文乱码。
解决办法是把pendingDatagramSize()的返回值当成缓冲区长度,读完之后再按readDatagram的返回值resize一次。编码统一 UTF-8,代码里搜一遍fromLatin1、toLocal8Bit全部清掉。
现象:消息发出去了writeDatagram返回正数,接收端日志干净,就是一个字都没收到。
先别改代码,先看防火墙。Windows 上第一次运行程序会弹网络访问提示,如果当时随手点了“取消”,或者默认为“公用网络”而实际连的是“专用网络”,包会被静默丢弃,send侧完全无感。Linux 上ufw或iptables同样会挡。
排查顺序是固定的:先在发送端ping目标 IP 确认三层通,再用telnet 目标IP 端口或者nc -u确认端口可达,最后才怀疑代码。Windows 上如果拿不准,临时在“Windows Defender 防火墙 → 允许应用通过防火墙”里给程序加个例外,看现象是否消失——这一步只是诊断,不是长期方案。
5.2 网络环境与部署里的两处翻车
现象:开发机上一切正常,拷到另一台机器,在线列表永远是空的,但手动填 IP 单播又能通。
根因基本锁定在广播地址。开发机可能只有一张网卡,255.255.255.255能工作;目标机器装了 VMware、VirtualBox、Docker 的虚拟网卡,系统的默认出口挑了虚拟网卡,广播包发进了一个没有任何真实设备的虚拟网段。
解决就是 3.1 节那套:遍历全部接口,过滤IsUp && IsRunning && !IsLoopBack,按掩码算出每个子网的定向广播地址,逐个发送。为了确认发到了哪些地址,我会在发送前打一行日志把地址列表打出来,一眼就能看出是不是发错了地方。
现象:bind失败,但错误信息只是Address already in use,重启程序也不行,得等一会儿才好。
这是端口没释放干净。设置了SO_REUSEADDR之后,TIME_WAIT 状态的端口通常能复用,但如果有另一个进程(比如上一次没退干净的实例、或者别的软件占了这个端口)真的在监听,bind必然失败。
定位方式:Windows 上netstat -ano | findstr :45454拿到 PID,再去任务管理器对;Linux 上ss -lunp | grep 45454直接能看到进程名。代码里的处理是启动时bind失败就把errorString()和端口号一起弹到界面上,而不是qDebug里一闪而过——这类问题用户自己能解决,前提是你告诉他端口被占了。
还有一种隐蔽情况:多播监听下,只有第一个 socket 设置了SO_REUSEADDR,后续加入的 socket 没设,bind会失败。所以bind的第三个参数每次都要带上标志位,不要指望“上次设过了”。
6. 从 demo 到能交付:ACK 重传、抓包验证与丢包率日志
这套代码跑通之后,最容易被问的问题是“消息会不会丢”。答案是会。UDP 没有重传,局域网里网络抖动、无线漫游、接收端进程卡顿都会导致丢包。想把它变成能交付的东西,加一层轻量的应用层 ACK 就够了。
做法不复杂:发送方把消息写入一个待确认表(msgId -> 发送时间 + 重试次数),接收方收到后回一个type=ACK的小包,发送方收到 ACK 就从表里删掉,超时 500ms 重发,最多三次,三次还不行就在界面上标注“未送达”。这套机制只需要在handleDatagram里加两个分支,代码量不到一百行,但可靠性提升非常明显。注意 ACK 包本身不要再去确认,否则会无限递归。
验证环节我强烈建议上抓包工具,别靠日志猜。过滤器写udp.port == 45454,能看到每一包的源地址、目标地址、长度和实际内容。三种验证手段的适用场景差别很大:
| 手段 | 看什么 | 适用场景 |
|---|---|---|
| Wireshark 抓包 | 包有没有发出去、发的目标地址对不对 | 广播/组播发错网卡、地址算错 |
| 应用内日志 | 协议解析在哪个字段失败 | 包头校验不过、JSON 字段缺失 |
| 丢包率统计 | 发送数 vs ACK 数 | 无线环境、大包分片 |
丢包率统计可以直接做进工具里:每发送 100 条消息,把发送计数和收到 ACK 的计数打一行日志,比值就是丢包率。有一次我在一个无线环境里看到 15% 的丢包,查了半天代码,最后发现是某台机器的无线信号只有两格——这种问题日志里看不出来,统计数字一摆就清楚了。
还有一个习惯值得养成:把msgId和发送时间一起打进日志。排查“消息顺序错乱”时,接收端按msgId排序就能还原真实顺序,比对着界面时间戳猜高效得多。
从那以后我每次接这类局域网通信的小项目,第一件事不是写界面,而是先把 Wireshark 挂上、把端口和广播地址打印出来,确认第一包真的发出去了、真的到了对端,再往上堆功能。这个习惯让我少走了很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取