简介:围绕 Qt 网络编程中的 UDP 广播与组播,这份实例面向初学者及需要快速验证局域网通信方案的开发者,分别给出可独立编译运行的广播、组播工程。广播部分演示仅指定端口即可向默认广播地址发送,绑定端口后借助 readyRead 信号槽接收;组播部分涉及 TTL 设置、加入指定组地址以及收发组地址保持一致等关键点,便于对照理解两种通信方式的差异与适用场景。压缩包共 10 个文件,以 4 个 cpp 源文件、2 个头文件承载核心逻辑,搭配 2 个 ui 界面文件与 2 个 pro 工程文件,整体约 8KB,结构轻量,适合导入 Qt Creator 调试。已有 533 人学习,可作为课堂示例、课程设计或局域网联调的参考素材,帮助读者搭建发送端与接收端、观察数据收发过程,并延伸思考多播组管理与异常排查。
1. 同一网段里找设备,单播为什么先崩掉
上个月帮一个做实训设备的团队看现场问题:三台工控机、两台采集终端插在同一台交换机上,程序用 TCP 单播互相连,IP 全部写死在配置文件里。换一台机器、改一次网段,整套配置就得重新改一遍,改漏一处就是半小时排查。他们想要的是“插上网线就能互相看见”,这个需求落到 Qt 里,基本就绕不开 QUdpSocket 的广播和组播两条路:广播用来做设备发现,向本网段内的所有设备发一句“我在”,组播用来做小范围的数据分发,只让加入组播组的终端收到。这份基于 Qt 写的 UDP 广播与组播实例,价值就在这两条路都给出了可直接编译的收发代码,而不是停在 QUdpSocket 的 API 列表上。适合已经会写 Qt 界面、但网络这块总是卡在“发出去收不到”的开发者。
2. 工程骨架:QUdpSocket 三种收发路径怎么摆
拿到一份 UDP 例程,最容易出现的误判是“打开 main.cpp 就是全部”。实际上广播和组播在 Qt 里共用同一个类 QUdpSocket,差异全在 bind 的 flag、目标地址的构造、以及几个 socket option 上。骨架设计得对不对,决定了后面调不通时你能不能五分钟定位问题。
2.1 目录结构与类职责划分
这份实例的组织方式是典型的“一个通信类 + 一个演示窗口”,通信逻辑全部收在一个 UdpBroadcaster 里,界面只负责触发和显示。落到文件上大致是这样:
| 文件 | 职责 | 关键点 |
|---|---|---|
| udpbroadcaster.h | 通信类声明 | 只暴露 start / joinGroup / leaveGroup 三个入口 |
| udpbroadcaster.cpp | 收发实现 | bind、广播地址枚举、组播加入、readyRead 循环 |
| mainwindow.cpp | 演示界面 | 按钮触发、日志列表、网卡下拉框 |
| main.cpp | 入口 | 只需要 QApplication 和窗口 |
| udpdemo.pro | 构建脚本 | QT += core gui network |
QT += network这一行是硬性前提,漏了它编译期就会报 QUdpSocket 未声明,很多人第一脚就踩在这。我一般会把通信类和界面彻底切开,原因是广播/组播的调试往往要在没有界面的情况下跑(比如塞进一个无 GUI 的测试程序里,用两台机器对着发),耦合进 MainWindow 之后,想单独复用就得复制粘贴。
头文件长这样,重点是成员变量里只有一个 socket:
#ifndef UDPBROADCASTER_H #define UDPBROADCASTER_H #include <QObject> #include <QUdpSocket> #include <QHostAddress> #include <QNetworkInterface> #include <QTimer> class UdpBroadcaster : public QObject { Q_OBJECT public: explicit UdpBroadcaster(QObject *parent = nullptr); // 绑定本地端口,广播和组播共用一个接收 socket bool start(quint16 localPort); // 周期性向本网段所有子网广播地址发心跳 void setBroadcastInterval(int ms); // 加入组播组,ifaceName 为空时由系统默认路由决定 bool joinGroup(const QString &groupAddr, const QString &ifaceName = QString()); void leaveGroup(const QString &groupAddr); signals: void datagramReceived(const QHostAddress &from, quint16 port, const QByteArray &payload); private slots: void onReadyRead(); void onBroadcastTimeout(); private: QList<QHostAddress> collectBroadcastAddresses() const; QUdpSocket *m_socket = nullptr; QTimer *m_broadcastTimer = nullptr; quint16 m_port = 0; bool m_broadcasting = false; }; #endif // UDPBROADCASTER_Hstart只做三件事:new socket、bind、接 readyRead。这里没有把广播和组播拆成两个 socket,是因为两者监听的是同一个本地端口,用一个 socket 接收能让接收端代码只有一份。拆两个 socket 也能跑,但两边都要 bind 同一个端口,就得处理端口复用,反而多一层麻烦。参数上localPort是收发共用的端口号,广播发送的目标端口和组播发送的目标端口都取这个值,约定好之后现场部署不用再填第二遍。
2.2 广播、组播、单播三条发送路径的差异
同样是writeDatagram,三个模式在参数上差得很远,把差异摆到一起看,改代码时不容易串:
| 维度 | 单播 | 广播 | 组播 |
|---|---|---|---|
| 目标地址 | 对端具体 IP | 子网广播地址或 255.255.255.255 | 224.0.0.0 ~ 239.255.255.255 |
| 是否需加入 | 否 | 否 | 是,joinMulticastGroup |
| 接收端 bind | 具体 IP 或 AnyIPv4 | 必须 AnyIPv4 | 必须 AnyIPv4 |
| 跨网段 | 可路由 | 默认不跨网段 | 需 TTL > 1 且路由器支持 |
| 典型用途 | 点对点指令 | 设备发现、心跳 | 一对多数据分发 |
表里“接收端 bind 必须 AnyIPv4”这一行是很多人翻车的地方。广播包的目标地址不是本机网卡上任何一个具体 IP,如果你 bind 成192.168.1.10这种具体地址,内核在匹配时会把广播包丢掉,现象就是 sendto 返回成功、接收端 readyRead 一次都不触发。组播同理。这个坑后面第 5 章还会展开。
2.3 初始化顺序:bind、join、定时器谁在前
顺序错了不会立刻报错,但会出现“偶尔能收到、重启一次就没了”的玄学现象。我一般按这个顺序写:
UdpBroadcaster::UdpBroadcaster(QObject *parent) : QObject(parent) { m_socket = new QUdpSocket(this); m_broadcastTimer = new QTimer(this); m_broadcastTimer->setInterval(1000); connect(m_broadcastTimer, &QTimer::timeout, this, &UdpBroadcaster::onBroadcastTimeout); connect(m_socket, &QUdpSocket::readyRead, this, &UdpBroadcaster::onReadyRead); } bool UdpBroadcaster::start(quint16 localPort) { m_port = localPort; // ShareAddress:同机多进程可同时监听该端口 // ReuseAddressHint:让 Windows 下 SO_REUSEADDR 真正生效 const auto flags = QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint; if (!m_socket->bind(QHostAddress::AnyIPv4, m_port, flags)) { qWarning() << "bind failed:" << m_socket->errorString(); return false; } return true; } void UdpBroadcaster::setBroadcastInterval(int ms) { m_broadcastTimer->setInterval(ms); m_broadcasting = true; m_broadcastTimer->start(); }第一段构造里只做对象创建和信号连接,不 bind。第二段start单独 bind,第三段才启动定时器。为什么把 bind 从构造函数里拎出来?因为构造阶段往往还没有拿到用户选择的网卡和端口,绑早了就得先 close 再重绑,close 之后 socket 状态未必干净。参数flags里两个枚举的组合值得记一下:ShareAddress决定同机多进程能不能共享端口,ReuseAddressHint在 Windows 平台上控制 SO_REUSEADDR,两个都写上是跨平台最稳的写法。少写后者,Windows 上第二次启动程序会直接报AddressAlreadyInUseError,而 Linux 上却能跑,这种平台差异最耗时间。
bind的返回值一定要检查并打印errorString()。我见过不止一次代码里 bind 失败但没判断,后面所有发送都“成功”(UDP 无连接,writeDatagram 只要地址合法就返回字节数),接收端永远安静,排查方向直接被带偏。
3. 广播实例拆解:从子网掩码算地址到 writeDatagram 发出
广播这条路上真正需要动脑的只有一件事:往哪个地址发。255.255.255.255是受限广播,很多交换机和主机防火墙默认不放行,跨网段更是必丢。工程上更靠谱的做法是按本机每块网卡的 IP 和掩码算出各自的子网广播地址,逐个发一遍。
3.1 用 QNetworkAddressEntry 拿广播地址,以及拿不到时的兜底
Qt 已经把这步封装好了,QNetworkAddressEntry::broadcast()会返回该条目对应的广播地址。把枚举逻辑单独抽成一个函数,好处是发送前能打日志,出问题时一眼看出程序认为该往哪发:
QList<QHostAddress> UdpBroadcaster::collectBroadcastAddresses() 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; // 只处理 IPv4 QHostAddress bcast = entry.broadcast(); if (bcast.isNull()) { // 少数平台上 broadcast() 为空,这里按 ip | ~mask 自己算 quint32 ip = entry.ip().toIPv4Address(); quint32 mask = entry.netmask().toIPv4Address(); bcast = QHostAddress(ip | ~mask); } if (!result.contains(bcast)) result.append(bcast); } } return result; }三段过滤按顺序是:网卡必须是 Up 且 Running,回环网卡跳过,只取 IPv4 地址条目。IsRunning这一条经常被忽略,拔了网线的网卡IsUp仍为真,但发出去的包没人接,日志里一堆发送成功反而干扰判断。兜底那几行是血泪经验:某些虚拟网卡驱动返回的broadcast()是空地址,直接writeDatagram到空地址会静默失败,用ip | ~mask手工算一遍就能绕开。
一个细节:IPv4 掩码取反之后是 32 位全 1 的高位,QHostAddress(quint32)构造出来的就是标准点分十进制,不需要再做字节序转换。如果发现算出来的地址是192.168.255.255但实际网段是192.168.1.0/24,那基本是掩码读错了,把entry.netmask().toString()打出来核对。
3.2 发送端:一次 writeDatagram 打多个目标
定时器到点就发,每个广播地址发一份,最后补一个受限广播兜底:
void UdpBroadcaster::onBroadcastTimeout() { if (!m_socket || !m_broadcasting) return; // 载荷尽量短,广播包会被本网段所有设备收到,别塞大对象 const QByteArray payload = QByteArrayLiteral("DISCOVER|v1|"); const auto targets = collectBroadcastAddresses(); for (const QHostAddress &addr : targets) { const qint64 n = m_socket->writeDatagram(payload, addr, m_port); if (n < 0) { qWarning() << "send to" << addr.toString() << "failed:" << m_socket->errorString(); } } // 兜底:受限广播,部分环境下定向广播会被交换机丢弃 m_socket->writeDatagram(payload, QHostAddress::Broadcast, m_port); }writeDatagram的三个参数是载荷、目标地址、目标端口。返回值小于 0 才算失败,等于载荷长度就是发出去了。注意它返回成功只代表交给了内核协议栈,不代表对端收到,UDP 没有确认机制,这一点决定了广播发现必须配重试,靠周期性的定时器天然实现了重试。
载荷设计上,DISCOVER|v1|这种带版本号和长度的文本协议在调试期比二进制结构体好用得多,抓包能直接看懂。等协议稳定了再换紧凑格式。发送频率建议 1 秒一次,太密会在设备多的现场把交换机控制面打满,太稀又会让发现过程显得很慢。
还要注意一点:如果这台机器有多块网卡(有线 + 无线 + 虚拟机网卡),上面这段会往每块网卡的广播地址都发一份,接收端因此可能收到重复包。去重靠载荷里的设备 ID,不靠网络层。
3.3 接收端:bind 到 AnyIPv4,readyRead 里循环读到空
接收端的核心是“一次 readyRead 可能对应多个数据报”,必须循环读,只读一次会积压:
void UdpBroadcaster::onReadyRead() { while (m_socket->hasPendingDatagrams()) { QByteArray buf; buf.resize(int(m_socket->pendingDatagramSize())); QHostAddress from; quint16 fromPort = 0; const qint64 n = m_socket->readDatagram(buf.data(), buf.size(), &from, &fromPort); if (n < 0) { qWarning() << "readDatagram:" << m_socket->errorString(); continue; } buf.resize(int(n)); // 实际收到的长度可能小于预分配 emit datagramReceived(from, fromPort, buf); } }pendingDatagramSize()返回队首数据报的长度,用它预分配缓冲区最省事。readDatagram的第三、四个参数是出参,带回来源地址和源端口,做设备发现时正好用来记录对端信息,不需要额外解析。buf.resize(int(n))这一步不能省,如果发送端发的是短包而缓冲区按长包分配,不裁剪的话后面解析会读到一堆\0。
from参数在广播场景下是对端的真实单播 IP,这一点很有用:收到广播的设备可以直接用这个地址回单播,不必再走一次广播。这也是“广播发现 + 单播传输”这个经典组合的实现基础。
4. 组播实例拆解:joinMulticastGroup 与三个 socket 选项
广播的短板很明显:本网段所有设备都得处理这个包,设备一多就是噪音。组播把接收方收窄到“主动加入组”的终端,交换机也支持只往有订阅者的端口转发。代价是配置项变多,加入组、指定网卡、设 TTL,每一步都能单独出错。
4.1 组播地址与端口的约定,收发共用还是分开
组播地址段是224.0.0.0到239.255.255.255,其中224.0.0.1这类是协议保留的,业务代码别碰。实例里一般选239.x.x.x段,这段是管理范围地址,不会和标准协议冲突。工程上我习惯把地址和端口写进一个配置文件,因为不同现场可能同时跑多套系统,地址撞了就会出现“收到了别的系统的包”这种诡异现象。
// 组播组地址与端口建议集中配置,不要散落在代码里 static const char *kGroupAddr = "239.255.1.100"; static const quint16 kGroupPort = 45454;收发是否共用一个 socket?在这个实例里是共用的:启动时 bind 到kGroupPort,然后 join 组,发送时用同一个 socket 往组地址writeDatagram。共用省事,但有个副作用,自己发的组播包自己也会收到(回环默认开启),如果处理逻辑不是幂等的,就会出现“我发了一条指令,结果执行了两次”。要不要关掉回环,取决于业务:做发现协议时通常希望收到自己的包来确认发送链路通,做指令分发时则希望关掉。
4.2 joinMulticastGroup 的网卡参数,多网卡机器必须显式指定
这是组播最容易出错的地方。不带网卡参数的重载会交给内核按默认路由选网卡,机器只有一块网卡时没问题,一旦有虚拟网卡、双网卡、容器网桥,内核很可能选到一块业务流量根本不走的网卡上,现象就是“加入组成功,但一个包都收不到”。
bool UdpBroadcaster::joinGroup(const QString &groupAddr, const QString &ifaceName) { const QHostAddress group(groupAddr); if (group.isNull() || !group.isMulticast()) { // 校验是否落在 224.0.0.0/4 qWarning() << "not a multicast address:" << groupAddr; return false; } QNetworkInterface iface; if (!ifaceName.isEmpty()) { iface = QNetworkInterface::interfaceFromName(ifaceName); if (!iface.isValid()) { qWarning() << "interface not found:" << ifaceName; return false; } } const bool ok = iface.isValid() ? m_socket->joinMulticastGroup(group, iface) : m_socket->joinMulticastGroup(group); if (!ok) { qWarning() << "joinMulticastGroup failed:" << m_socket->errorString(); return false; } // 出站组播也走同一块网卡,收发对称 if (iface.isValid()) m_socket->setMulticastInterface(iface); return true; }三个检查点:地址必须是组播地址(isMulticast()内部判断 224.0.0.0/4)、网卡名必须能查到、join 的返回值必须判。第三点特别重要,joinMulticastGroup在网卡不存在或者权限不足时返回 false,不判就会带着一个没加入组的 socket 继续跑,接收端全程安静。
setMulticastInterface管的是出站方向。只设 join 不设它,进站走指定网卡,出站还是走默认路由,双网卡机器上就会变成“能收到别人的,别人收不到我的”。收发两个方向都锁到同一块网卡,才叫真正指定了接口。界面上我一般放一个 QComboBox 列出QNetworkInterface::allInterfaces(),让现场人员手动选,比猜默认路由可靠。
4.3 TTL、回环、出站接口三个选项的取值与含义
加入组之后还有三个 socket 选项要设,它们是组播行为的开关:
| 选项 | 枚举名 | 建议值 | 作用 |
|---|---|---|---|
| TTL | MulticastTtlOption | 1 或 4 | 1 表示不出本网段;4 可过几跳路由 |
| 回环 | MulticastLoopbackOption | 0 或 1 | 是否接收自己发出的组播包 |
| 出站接口 | setMulticastInterface | 指定网卡 | 决定从哪块网卡发出 |
// TTL=1:限制在本网段,避免包跑到不该去的地方 m_socket->setSocketOption(QAbstractSocket::MulticastTtlOption, 1); // 回环关闭:指令类业务不希望自己发的包回到自己 m_socket->setSocketOption(QAbstractSocket::MulticastLoopbackOption, 0);TTL 的默认值是 1,也就是不出本网段。想跨网段必须调大,同时路由器要开组播路由,两者缺一不可。只改代码不改路由器,包会在第一跳被丢弃,这种“代码看着没问题但就是不通”的场景,抓包一看 TTL 和组播组都对,问题在网络设备上。
回环选项在调试期建议开成 1,本机就能验证收发链路,不用凑两台机器。上线前按业务决定。我第一次接触这个概念时被坑过:调试时一直开着回环,本机测试全通过,部署到两台机器上才发现组播根本没发出去,因为本机收的一直是自己的包。
还有一个容易忽略的点:joinMulticastGroup对同一个组重复调用是幂等的,但重复调用不同组会累积,退出程序前记得leaveGroup,否则某些系统上组播组成员关系会残留一小段时间,重启程序时可能表现异常。
5. 避坑排查:收不到包时按这个顺序查
UDP 广播和组播的麻烦在于没有连接状态,出问题时既没有报错也没有日志,整个链路像个黑匣子。下面五条是这类项目里出现频率最高的,按“现象 → 原因 → 解决”记下来,现场排查能省不少时间。
5.1 现象:发送端返回成功,接收端 readyRead 一次都没触发
原因几乎都是 bind 的地址不对。接收端 bind 到了具体网卡 IP(比如192.168.1.10),而广播包和组播包的目标地址不是这个 IP,内核匹配时直接丢弃。另外还有人用QHostAddress::LocalHostbind,那就只能收到本机回环流量。
解决:接收端统一bind(QHostAddress::AnyIPv4, port, flags)。IPv6 环境另说,但这份实例是 IPv4 路由的,AnyIPv4是正确选择。改完还是收不到,就在 bind 之后立刻打印m_socket->localAddress()和localPort()确认实际绑定结果。
5.2 现象:单网卡机器一切正常,换到双网卡机器后组播只出不进或只进不出
原因是 join 时没指定网卡,内核按默认路由选了另一块网卡。这台机器上网走的是无线网卡,业务网段在有线网卡上,默认路由指向无线,组播就跑到无线那边去了。
解决:join 和setMulticastInterface都显式传QNetworkInterface。界面上给出网卡下拉框,让使用者选,不要赌默认路由。命令行环境可以用ip route(Linux)或route print(Windows)确认默认路由落在哪块网卡上,再对照程序里选的网卡是否一致。
5.3 现象:Linux 上收发正常,Windows 上接收端一个包都收不到
原因多半是防火墙。Windows 防火墙默认对入站 UDP 有较为严格的策略,尤其是程序第一次运行时会弹窗询问,点了“取消”之后规则就被记住了,后面永远静默丢包,连日志都没有。
解决:第一次运行留意弹窗并允许;已经点错的,去防火墙入站规则里删掉对应程序的旧规则重新运行。开发阶段可以临时关掉专用网络的防火墙验证,确认是这个问题之后再补规则,不要长期关着。另外注意很多企业终端装了额外的安全软件,规则是双份的。
5.4 现象:自己发的包自己收不到,或者同一条消息被处理了两次
这两个现象是一对。收不到是回环被关掉了;处理两次是回环开着,加上多网卡导致同一个包从两块网卡各进来一份,或者发送端向多个广播地址各发了一遍。
解决:先明确业务要不要回环。调试期开回环、单机验证;部署时指令类业务关回环。去重不能靠网络层,在应用层载荷里放一个消息 ID(比如时间戳 + 设备序列号),收到后维护一个短期已处理集合。多网卡重复包的问题用同样的办法解决,比按源 IP 去重可靠,因为同一台设备的两个包源 IP 是一样的。
5.5 现象:程序第二次启动报 AddressAlreadyInUseError,第一次却能跑
原因是 bind 的 flags 少写了ReuseAddressHint,Windows 下 SO_REUSEADDR 没生效;或者上一个进程没有正常退出,端口还处在占用状态。还有一种情况是之前用管理员权限跑过一次,规则残留。
解决:flags 固定写QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint。仍然冲突的话,netstat -ano | findstr 45454(Windows)或ss -lunp | grep 45454(Linux)看是谁占着,必要时换端口。这个坑我踩过一次,程序崩了之后重启一直起不来,查了半小时才发现是端口没释放,从那以后 bind 失败一律先打errorString()再看端口占用。
6. 二次开发:加一层协议头,把 Demo 变成能用的发现服务
实例里收发的是DISCOVER|v1|这种裸字符串,直接拿去上线会遇到三个问题:没法区分消息类型、没法去重、没法做版本兼容。二次开发的第一步我一般不是加功能,而是先定一层薄协议头。用固定 8 字节头:2 字节魔数、1 字节版本、1 字节消息类型、2 字节载荷长度、2 字节保留,后面跟 JSON 或 CBOR 载荷。魔数用来快速丢弃非本系统的广播包(同网段经常有别的软件在广播),长度字段用来做粘包边界判断,虽然 UDP 本身有边界,但留着做校验没坏处。
#pragma pack(push, 1) struct UdpHeader { quint16 magic; // 0x51A7,过滤非本系统的包 quint8 version; // 协议版本,便于灰度升级 quint8 type; // 1=发现 2=心跳 3=指令 quint16 length; // 载荷字节数 quint16 reserved; // 对齐用,填 0 }; #pragma pack(pop) QByteArray buildPacket(quint8 type, const QByteArray &body) { UdpHeader h; h.magic = 0x51A7; h.version = 1; h.type = type; h.length = quint16(body.size()); h.reserved = 0; QByteArray pkt(reinterpret_cast<const char *>(&h), sizeof(h)); pkt.append(body); return pkt; }#pragma pack(1)保证结构体没有填充字节,收发两端结构一致才解析得对。跨编译器时更稳的写法是手工按字节拼,用QDataStream设setByteOrder(QDataStream::BigEndian),避免结构体对齐在不同平台上出偏差。载荷建议用 JSON,调试期可读,字段加不加都不影响旧版本解析(旧版本忽略未知字段),这一点在设备分批升级的现场很关键。
第二个改造点是发现流程。广播负责“有人在吗”,收到应答的设备用源 IP 单播回一个带自身信息的包(设备 ID、固件版本、当前负载),发起方维护一张在线表,超过三个心跳周期没更新就标记离线。这套流程比纯广播稳,因为应答走单播不受广播域限制。验证方法很直接:两台机器分别跑,其中一台拔网线,三秒内在线表里应该变灰;插回网线,一个心跳周期内恢复。这个开关测试能同时验证广播发现、超时判定和状态恢复三条链路。
第三个改造点是收包线程。默认情况下 QUdpSocket 的 readyRead 在主线程触发,包一多界面会卡。量不大(每秒几十个包)不用管,到了每秒几百个就该把 socket 移到工作线程,用moveToThread加QThread,信号槽走队列连接。注意 socket 必须在创建它的线程里使用,在主线程 new 出来再 move 过去之后,所有调用都得在工作线程里发起,主线程直接调writeDatagram会触发警告。
最后提醒一句验证习惯:改完协议头,别急着上两台机器,先在单机开回环跑通,确认解析没问题再关回环。我有一次图省事跳过单机验证,直接上现场,结果两台设备的协议版本不一致,一方发的包另一方全部丢弃,日志里连一行错误都没有,硬是查了一个下午。从那以后每次动协议头,我都强制先在单机回环下把发现流程走一遍,再上真机。希望帮到你。
本文还有配套的精品资源,点击获取