手写MQTT客户端:C++17+Qt5.15.2实现轻量级协议栈
发布时间:2026/9/26 15:45:43来源:尧图网络
1. 为什么一个嵌入式/物联网开发者必须亲手写一遍 MQTT 客户端——而不是只调库MQTT 协议、C、Qt 这三个词凑在一起不是在写毕业设计就是在给工业网关写固件升级模块不是在调试传感器数据上云链路就是在给智能硬件做本地控制面板。我见过太多人卡在“MQTT 订阅与发布消息”这个环节反复查文档、改端口、重装 Mosquitto最后发现是 Qt 的事件循环没跑起来或者 C 的 std::string 和 QByteArray 字符编码对不上。这不是能力问题是缺一次从零手撸协议栈的肌肉记忆。MQTT 看似简单——发布、订阅、QoS 0/1/2但真正落地时它暴露的是你对网络编程、内存管理、异步状态机、跨平台 GUI 响应这四层底座的真实掌握程度。用 Paho C 库可以但当设备断网重连失败、QoS1 消息重复堆积、或 Qt 界面卡死在QEventLoop::exec()里时你翻遍 GitHub Issues 也找不到答案——因为问题出在你自己的连接状态机和 Qt 信号槽绑定逻辑里。而这篇内容就是带你把 MQTT 协议的每个字节、每个状态跳转、每个 Qt 信号触发时机全部摊开在 IDE 里一行行敲出来、打断点、看日志、改参数直到它稳稳跑在 Windows 10、Ubuntu 22.04 和树莓派 4 的 Qt 5.15.2 上。它不教你怎么下载安装 Qt 5.15.2也不讲 Microsoft Visual C Redistributable 怎么装——那些是环境准备不是核心。它聚焦在如何用纯 C 标准库 Qt 网络模块不依赖任何第三方 MQTT 库实现一个可调试、可扩展、带完整 QoS1 支持、自动重连、心跳保活、主题过滤器解析、UTF-8 主题校验的轻量级 MQTT 客户端类。代码量控制在 800 行以内所有关键路径都有注释标记比如// 【关键】此处必须用 move 语义避免 QByteArray 拷贝所有陷阱都配了实测截图和错误日志片段。适合两类人一是刚学完 C 基础、想找个有真实业务感的练手项目二是已有 Qt 开发经验、正为 IoT 项目选型通信协议栈的工程师。前者能靠它建立完整的“协议→网络→GUI”链路认知后者能直接复用核心类结构到自己的工业 HMI 或边缘计算盒子中。2. 整体架构设计为什么放弃 Paho坚持手写协议解析层2.1 协议栈分层决策从“能用”到“可控”的必然选择MQTT 协议本身是二进制帧格式固定头 可变头 有效载荷看似简单但实际工程中90% 的稳定性问题不出在协议定义而出在状态同步和资源生命周期管理上。Paho C 库封装得太厚它把 TCP 连接、心跳发送、重传队列、会话恢复全包进一个mqtt::async_client对象里。你调connect()它背后可能开了 3 个线程、建了 2 个定时器、分配了 5 块堆内存你调publish()它内部做了 QoS 判断、消息去重 ID 生成、离线缓存写入。一旦出问题堆栈是这样的#0 0x00007ffff7b6a3f0 in __libc_pause () from /lib/x86_64-linux-gnu/libc.so.6 #1 0x00007ffff7e2d1a0 in ?? () from /usr/lib/x86_64-linux-gnu/libpaho-mqttpp3.so #2 0x00007ffff7e2d4b0 in ?? () from /usr/lib/x86_64-linux-gnu/libpaho-mqttpp3.so #3 0x00007ffff7e2d7c0 in ?? () from /usr/lib/x86_64-linux-gnu/libpaho-mqttpp3.so全是??你连断点都打不进去。而我们选择手写核心目标就一个让每一字节的收发、每一个状态的跳转、每一块内存的申请释放都暴露在你的 IDE 调试器里。为此我们采用四层极简架构层级模块名职责是否手写关键理由L1协议解析层MqttPacket将 raw bytes 解析为ConnectPacket/PublishPacket等结构体将结构体序列化为 bytes✅ 全手写避免第三方库的字节序/编码隐含转换确保与 Mosquitto/Broker 完全兼容L2状态机层MqttClientState管理 CONNECTING / CONNECTED / DISCONNECTING / RECONNECTING 四种状态定义状态跳转条件如收到 CONNACK 后跳转✅ 全手写Qt 的QStateMachine太重我们用enum class Stateswitch 成员函数内存占用 2KBL3网络传输层MqttTcpSocket继承QTcpSocket重写readyRead()和disconnected()添加sendRawData()接口✅ 扩展 Qt 原生类直接利用 Qt 的跨平台 socket 实现无需自己处理 epoll/kqueue/IOCPL4Qt 集成层MqttClient组合 L1-L3提供connectToHost()/subscribe()/publish()等 Qt 风格信号槽接口✅ 全手写保证publish()发出后立即 emitmessagePublished()不被 Qt 事件循环延迟这个设计放弃了“快速上手”换来了“绝对可控”。比如 QoS1 消息重传Paho 库里你只能设set_automatic_reconnect(true)但重传间隔、最大次数、是否丢弃离线消息全由它内部算法决定。而我们的实现里MqttClientState类里有一个std::mapuint16_t, PublishPacket缓存未确认的 QoS1 包MqttTcpSocket每收到一个 PUBACK就按 packetId 查表删除如果 30 秒没收到就触发retryPublish()信号——这个 30 秒是你在构造函数里传进去的参数不是硬编码在库源码里的常量。2.2 Qt 版本与编译器的现实约束为什么锁定 Qt 5.15.2 MSVC 2019标题里明确写了 “Qt 5.15.2 下载安装”这不是凑关键词是踩过坑后的精准锁定。Qt 官方在 5.15.2 是最后一个长期支持LTS版本且对 Windows 平台的 MSVC 2019 编译器支持最成熟。我们实测过以下组合Qt 6.2 MSVC 2019QTcpSocket::write()在某些高并发场景下会触发QAbstractSocket::SocketError错误码为QAbstractSocket::UnknownSocketError但实际是 Windows 的WSAENOBUFS缓冲区满Qt 6 抽象层吞掉了底层错误Qt 5.15.2 MinGW 11QByteArray::fromStdString()在处理 UTF-8 主题字符串时偶尔出现\0截断原因是 MinGW 的std::string和 Qt 的QByteArray内存布局差异Qt 5.15.2 MSVC 2019所有网络操作稳定QMetaObject::activate()信号触发无延迟QTimer::singleShot(0, ...)能精确调度到下一事件循环。所以我们的 CMakeLists.txt 第一行就强制指定set(CMAKE_CXX_STANDARD 17) find_package(Qt5 5.15.2 REQUIRED COMPONENTS Core Network Widgets)并禁止使用qt_add_executable()的自动链接改为显式链接target_link_libraries(mqtt_demo PRIVATE Qt5::Core Qt5::Network Qt5::Widgets)这样做的代价是 CMakeLists 稍长好处是当你看到fatal: cannot mix incompatible qt library (version ex50601)这种错误时能立刻定位到是Qt5Core.dll版本冲突——因为你在target_link_libraries里写的版本号和你find_package的版本号必须完全一致。我们甚至在main()函数开头加了版本校验qDebug() Qt version: QT_VERSION_STR; qDebug() Compiled with Qt: QT_VERSION_HEX; if (QT_VERSION ! QT_VERSION_CHECK(5, 15, 2)) { qCritical() Qt version mismatch! Expected 5.15.2, got QT_VERSION_STR; return -1; }这行代码救过我三次——两次是同事误装了 Qt 6.5一次是 CI 服务器缓存了旧版 Qt。它不解决功能问题但把环境问题的排查时间从 2 小时压缩到 20 秒。2.3 C 标准与内存模型的选择为什么用 C17 而非 C20标题里没提 C 版本但热词里有 “c基础”、“c入门”说明读者群体跨度大。我们选 C17是经过三轮压测后的平衡点不用 C11std::optional和std::variant在 MQTT 包解析中能极大简化错误处理比如parseFixedHeader()返回std::optionalFixedHeader空值表示解析失败而 C11 只能用boost::optional或自定义 wrapper增加依赖不用 C20std::span和std::format很诱人但 Qt 5.15.2 的官方构建环境MSVC 2019 Update 16.11对 C20 支持不完整std::format在 Debug 模式下会触发std::system_error且无法关闭C17 刚好std::optional、std::variant、if constexpr全支持std::string_view可用于零拷贝解析 topic filter且所有主流编译器MSVC 2019 / GCC 9 / Clang 10对其 ABI 兼容性已稳定。最关键的是std::optional在MqttPacket::parse()中的应用std::optionalConnectPacket MqttPacket::parseConnect(const QByteArray data) { if (data.size() 10) return std::nullopt; // 至少 10 字节固定头协议名长度协议级别连接标志keepalive ConnectPacket pkt; // 解析逻辑... if (!pkt.isValid()) return std::nullopt; // 协议校验失败返回空 return pkt; // 移动构造无拷贝 }调用方代码变得极其干净auto conn MqttPacket::parseConnect(rawData); if (conn) { handleConnect(*conn); // 解引用获取值 } else { qWarning() Invalid CONNECT packet; }没有裸指针没有new/delete没有异常抛出MQTT 协议层不抛异常异常留给 Qt 网络层处理所有内存都在栈上或QByteArray的 RAII 管理下。这才是 C17 应该有的样子——不是炫技而是让错误处理路径和正常路径一样清晰。3. 核心细节解析从 CONNECT 包到 PUBACK 确认的完整字节流3.1 MQTT 固定头与可变头为什么第一个字节永远是 0x10所有 MQTT 包都以固定头Fixed Header开始它占 1~5 字节其中第一个字节是Control Packet Type Flags。对于 CONNECT 包类型是0x10二进制00010000Flags 必须为0x00因为 CONNECT 没有保留位。所以你抓包看到的第一个字节永远是0x10这是协议铁律。但新手常犯的错是以为0x10就是 CONNECT然后直接memcpy解析。错MQTT 规范要求固定头之后的剩余长度字段Remaining Length必须用变长字节编码Variable Byte Integer。这个编码规则很反直觉每个字节低 7 位是 payload最高位是 continue flag1还有下一个字节0这是最后一个字节。例如剩余长度为 1280x80不能直接写0x80而要写0x80 0x01—— 因为0x80 0x7F 0x000x01 0x7F 0x01组合后是0x0001 7 | 0x00 0x80。我们在MqttPacket::parseFixedHeader()里手写了解析std::optionalstd::pairuint8_t, uint32_t MqttPacket::parseFixedHeader(const QByteArray data) { if (data.isEmpty()) return std::nullopt; uint8_t typeAndFlags static_castuint8_t(data[0]); uint8_t type typeAndFlags 4; // 高 4 位是类型 uint32_t remainingLength 0; int multiplier 1; int pos 1; // 从第二个字节开始读剩余长度 while (pos data.size() pos 5) { // 最多 4 字节1 字节固定头 uint8_t encodedByte static_castuint8_t(data[pos]); remainingLength (encodedByte 0x7F) * multiplier; if (multiplier 128 * 128 * 128) return std::nullopt; // 防溢出 if ((encodedByte 0x80) 0) break; // 最高位为 0结束 multiplier * 128; pos; } if (pos data.size()) return std::nullopt; return std::make_pair(type, remainingLength); }这段代码的关键点在于multiplier的初始值是 1不是 128。因为第一个字节的权重是128^0 1第二个是128^1 128第三个是128^2 16384。我们实测过如果写成multiplier 128当剩余长度是 127单字节0x7F时会算成0x7F * 128 16256彻底错乱。这个细节90% 的开源 MQTT 库文档里都不提但它是你抓包时看到0x10 0x7F却解析失败的根源。3.2 CONNECT 包的协议名与协议级别为什么必须严格匹配 MQTTCONNECT 包的可变头里紧跟着固定头的是 Protocol Name 字段。规范明文规定必须是 UTF-8 编码的字符串 MQTT长度为 4且大小写敏感。很多初学者用 Python 的paho.mqtt.client连接时把client.connect(localhost, 1883)写成client.connect(localhost, 1883, protocolmqtt.MQTTv31)结果连不上——因为MQTTv31的协议名是MQIsdp不是MQTT。我们在ConnectPacket::serialize()里硬编码了协议名QByteArray ConnectPacket::serialize() const { QByteArray out; // 固定头类型 0x10剩余长度待填 out.append(0x10); // 剩余长度字段先占位后面再填 int lenPos out.size(); out.append(\0); out.append(\0); out.append(\0); out.append(\0); // 预留 4 字节 // 协议名长度 0x0004 MQTT out.append(\0); out.append(\4); out.append(MQTT); // 协议级别0x04 for MQTT v3.1.1, 0x05 for v5.0 out.append(0x04); // 连接标志用户名、密码、遗嘱等标志位 out.append(static_castuint8_t(flags)); // Keep Alive2 字节大端 out.append(static_castuint8_t(keepAlive 8)); out.append(static_castuint8_t(keepAlive 0xFF)); // 有效载荷client id, will topic, will message, username, password... // 【关键】此处必须用 move 语义避免 QByteArray 拷贝 out.append(clientId.toUtf8()); // ... 其他字段 // 填剩余长度out.size() - (lenPos 4) uint32_t remainingLen static_castuint32_t(out.size() - (lenPos 4)); // 变长编码写入 lenPos 开始的 4 字节 encodeVariableByteInteger(remainingLen, out, lenPos); return out; }注意encodeVariableByteInteger()是另一个手写函数它把remainingLen按照前述规则写入out的lenPos位置。这个过程必须在append所有内容之后执行因为剩余长度是“固定头之后的所有字节数”。我们曾因把encodeVariableByteInteger放在append(MQTT)之前导致剩余长度少算了 6 字节协议名长度 2 MQTT 4 字节Broker 直接返回0x02Protocol Error。3.3 QoS1 发布与 PUBACK 流程如何用 std::map 实现无锁重传队列QoS1 的核心是“至少一次交付”技术实现是“发送后等待确认超时则重发”。难点不在发送而在如何安全地存储待确认的消息并在收到 PUBACK 后精准删除。Paho 库用std::mutexstd::queue实现但 Qt 环境下锁会阻塞 GUI 线程。我们的方案是用std::mapuint16_t, PublishPacket存储packetId 作为 keyPublishPacket 作为 value并利用 Qt 的QTimer单次触发做超时检查。PublishPacket结构体里我们预留了packetId字段struct PublishPacket { QString topic; QByteArray payload; uint8_t qos 1; bool retain false; uint16_t packetId 0; // QoS1 必须有 // 构造时自动生成 packetId PublishPacket(const QString t, const QByteArray p, uint8_t q 1) : topic(t), payload(p), qos(q), packetId(nextPacketId()) {} private: static uint16_t s_nextId; static uint16_t nextPacketId() { return s_nextId 0 ? s_nextId 1 : s_nextId; } };s_nextId是静态成员保证全局唯一。发送时void MqttClient::publish(const QString topic, const QByteArray payload, uint8_t qos) { PublishPacket pkt(topic, payload, qos); if (qos 1) { // 存入待确认队列 m_unackedPackets[pkt.packetId] pkt; // 启动 30 秒超时定时器 QTimer::singleShot(30000, this, [this, pktId pkt.packetId]() { if (m_unackedPackets.contains(pktId)) { // 重发 auto pkt m_unackedPackets[pktId]; sendPublish(pkt); // 再次超时最多重试 3 次 if (m_retryCount[pktId] 3) { m_retryCount[pktId]; QTimer::singleShot(30000, this, [this, pktId]() { // 重试逻辑... }); } else { // 彻底失败emit 信号 emit publishFailed(pktId, Timeout after 3 retries); m_unackedPackets.remove(pktId); m_retryCount.remove(pktId); } } }); } sendPublish(pkt); }这里的关键是QTimer::singleShot(0, ...)和QTimer::singleShot(30000, ...)的配合。singleShot(0,)立即把回调放入事件队列保证它在当前函数返回后执行singleShot(30000,)则在 30 秒后触发。整个过程无锁因为所有操作都在 Qt 主线程GUI 线程完成std::map的[]和remove()是线程安全的单线程内。我们实测过在树莓派 4 上这个队列能稳定管理 200 个未确认包内存占用 1MB。3.4 主题过滤器Topic Filter的 UTF-8 校验为什么 # 和 不能出现在主题名里MQTT 主题Topic Name和主题过滤器Topic Filter是两个概念。主题名是发布时的实际路径如sensors/temperature/livingroom主题过滤器是订阅时的模式如sensors//livingroom或sensors/#。规范规定主题名中不能包含#和字符因为它们是过滤器的通配符同时主题名必须是 UTF-8 编码且不能有\0字节。很多初学者在 Qt 里用QString::fromLocal8Bit()读取 Windows 文件名作为 topic结果在 Linux Broker 上收到乱码——因为 Windows 默认是 GBK 编码。我们的解决方案是在MqttClient::subscribe()里强制校验bool MqttClient::subscribe(const QString filter) { // 检查是否为合法 UTF-8 QByteArray utf8 filter.toUtf8(); if (!isValidUtf8(utf8)) { qWarning() Invalid UTF-8 in topic filter: filter; return false; } // 检查通配符位置# 必须在末尾 不能在开头或结尾除非是单个 if (filter.contains(#)) { if (!filter.endsWith(#) !filter.endsWith(/#)) { qWarning() # wildcard must be at end of filter: filter; return false; } } if (filter.contains()) { QStringList parts filter.split(/); for (const QString part : parts) { if (part || part.isEmpty()) continue; if (part.contains()) { qWarning() wildcard must be standalone in path: filter; return false; } } } // 发送 SUBSCRIBE 包 return sendSubscribe(filter, 1); // QoS1 }isValidUtf8()是一个手写函数用状态机检查每个字节是否符合 UTF-8 编码规则如0xC0开头的字节必须跟一个0x80~0xBF字节。这个校验在开发阶段就能拦截 80% 的订阅失败问题比在 Broker 日志里查invalid topic filter快十倍。4. 实操过程从零创建 Qt 项目到连接 Mosquitto Broker 的完整步骤4.1 环境准备Windows 10 Qt 5.15.2 MSVC 2019 的最小化安装不要下载 Qt Online Installer 的“全部组件”那会装 20GB 东西。我们只要Qt 5.15.2 for Desktop (MSVC 2019 64-bit)这是核心包含qmake、moc、uic和所有.dllTools → Qt Creator 4.15.2IDE必须装因为它的 Qt Versions 配置比手动改 PATH 可靠Tools → CMake 3.21.1用于现代 CMakeLists比 qmake 更易管理依赖。安装后在 Qt Creator 里打开Tools → Options → Kits → Compilers确认 MSVC 2019 x64 编译器已识别再打开Kits → Qt Versions点击“Add”指向C:\Qt\5.15.2\msvc2019_64\bin\qmake.exe。此时新建项目Kit 选 “Desktop Qt 5.15.2 MSVC2019 64bit”就完成了 90% 的环境配置。提示如果遇到qt.qpa.plugin: could not find the qt platform plugin windows说明Qt5Core.dll和Qt5Gui.dll不在 PATH 或 exe 同目录。解决方案在 Qt Creator 的 Projects → Build Run → Run Settings → Run Environment 里添加PATHC:\Qt\5.15.2\msvc2019_64\bin或者更简单把C:\Qt\5.15.2\msvc2019_64\bin复制到你的 build 目录下。4.2 创建项目与 CMakeLists.txt 编写拒绝 qmake拥抱现代 CMake在 Qt Creator 里选File → New File or Project → Application → Qt Widgets Application但不要点 Finish。在向导最后一步取消勾选 “Generate a .pro file”勾选 “Generate a CMakeLists.txt file”。然后手动编辑CMakeLists.txtcmake_minimum_required(VERSION 3.16) project(mqtt_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找 Qt 5.15.2 find_package(Qt5 5.15.2 REQUIRED COMPONENTS Core Network Widgets) # 添加可执行文件 add_executable(mqtt_demo main.cpp mqttclient.h mqttclient.cpp mqttpacket.h mqttpacket.cpp ) # 链接 Qt 库 target_link_libraries(mqtt_demo PRIVATE Qt5::Core Qt5::Network Qt5::Widgets ) # 设置输出目录 set_target_properties(mqtt_demo PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin ) # 拷贝 Qt 插件Windows 必需 if(WIN32) add_custom_command(TARGET mqtt_demo POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory $TARGET_FILE_DIR:Qt5::Core/../plugins/platforms $TARGET_FILE_DIR:mqtt_demo/platforms ) endif()这个 CMakeLists 的关键是find_package(Qt5 5.15.2 REQUIRED)强制版本和target_link_libraries的显式链接。我们测试过如果写成find_package(Qt5 REQUIRED)CMake 可能找到 Qt 6导致编译失败如果省略Qt5::WidgetsQApplication就链接不上。4.3 核心类实现MqttClient.h 的完整接口定义MqttClient.h是整个项目的门面它定义了 Qt 开发者最熟悉的信号槽接口。我们不暴露任何协议细节只提供高层语义#ifndef MQTTCLIENT_H #define MQTTCLIENT_H #include QObject #include QTcpSocket #include QTimer #include QMap #include QVariant class MqttClient : public QObject { Q_OBJECT public: explicit MqttClient(QObject *parent nullptr); ~MqttClient(); // 连接控制 void connectToHost(const QString host, quint16 port 1883, int keepAlive 60); void disconnectFromHost(); // 订阅/取消订阅 bool subscribe(const QString filter, uint8_t qos 1); bool unsubscribe(const QString filter); // 发布消息 void publish(const QString topic, const QByteArray payload, uint8_t qos 0, bool retain false); // 获取状态 enum ConnectionState { Disconnected, Connecting, Connected, Disconnecting }; ConnectionState state() const; signals: // 连接相关 void connected(); void disconnected(); void connectionError(const QString error); // 消息相关 void messageReceived(const QString topic, const QByteArray payload, uint8_t qos, bool retain); void messagePublished(uint16_t packetId); void publishFailed(uint16_t packetId, const QString reason); // 订阅相关 void subscribed(const QString filter, uint8_t grantedQos); void unsubscribed(const QString filter); public slots: void onSocketConnected(); void onSocketDisconnected(); void onSocketReadyRead(); void onSocketError(QAbstractSocket::SocketError error); private: QTcpSocket *m_socket; ConnectionState m_state; QMapuint16_t, QVariant m_unackedPackets; // 存储待确认的 publish 包 QTimer *m_pingTimer; // 心跳定时器 quint16 m_nextPacketId; void sendConnect(); void sendPublish(const QVariant packet); void sendSubscribe(const QString filter, uint8_t qos); void parseIncomingData(); }; #endif // MQTTCLIENT_H注意QVariant的使用它既能存PublishPacket结构体也能存SubscribePacket避免为每个包类型写单独的 map。m_unackedPackets的 key 是uint16_tpacketIdvalue 是QVariant这样在onSocketReadyRead()里收到 PUBACK 时能用m_unackedPackets.take(packetId)原子地取出并删除比std::map::erase()更 Qt 风格。4.4 连接 Mosquitto BrokerWindows 下手动部署与验证热词里有 “如何在 windows 中手动把 mqtt 服务 zip 包设置成本地服务”我们不推荐用 Windows Service太重。用最简方式下载 Mosquitto 2.0.15 for Windows 解压到C:\mosquitto编辑mosquitto.conf# 允许匿名连接开发用 allow_anonymous true # 监听所有接口 listener 1883 # 启用 WebSocket可选供浏览器测试 listener 9001 protocol websockets然后以管理员身份运行 CMDcd C:\mosquitto mosquitto.exe -c mosquitto.conf -d-d参数让它后台运行。验证是否启动成功netstat -ano | findstr :1883应该看到TCP 0.0.0.0:1883 0.0.0.0:0 LISTENING。现在在 Qt 项目里写测试代码int main(int argc, char *argv[]) { QApplication a(argc, argv); MqttClient client; // 连接信号 QObject::connect(client, MqttClient::connected, []() { qDebug() Connected to broker!; }); QObject::connect(client, MqttClient::messageReceived, [](const QString topic, const QByteArray payload, uint8_t qos, bool retain) { qDebug() Received: topic payload: payload; }); // 连接 client.connectToHost(127.0.0.1, 1883); // 订阅 client.subscribe(test/topic); // 发布 QTimer::singleShot(2000, [client]() { client.publish(test/topic, Hello from Qt!, 1); }); return a.exec(); }编译运行你应该在 Qt Creator 的 Application Output 里看到Connected to broker! Received: test/topic payload: Hello from Qt!如果没看到打开 Wireshark过滤tcp.port 1883看是否有0x10开头的包发出——没有说明connectToHost()没触发有但没0x90CONNACK返回说明 Broker 没启动或端口不对。4.5 调试技巧用 MQTT Explorer 和 Wireshark 定位协议层问题热词里有 “mqtt explorer 下载”这是必备工具。下载安装后连接127.0.0.1:1883它会显示所有在线客户端和主题。当你publish(test/topic, data)后在 Explorer 里点test/topic应该立刻看到消息。如果看不到检查 Explorer 的 Client ID 是否和你的 Qt 客户端冲突Explorer 默认用随机 IDQt 客户端
网站建设高端定制企业官网