ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OneNET平台MQTT订阅报文全解析:SUBSCRIBE与SUBACK抓包实战

OneNET平台MQTT订阅报文全解析:SUBSCRIBE与SUBACK抓包实战

设备端明明显示订阅成功了,平台侧消息也推送了,可终端就是收不到数据。这类问题在基于OneNET平台的MQTT接入项目里非常常见,最后把Wireshark抓到的那几帧SUBSCRIBE和SUBACK报文翻出来逐字节核对比对,才找到症结——主题的写法、QoS请求,甚至包标识符都可能是坑。

这篇文章就围绕OneNET平台上的MQTT订阅报文做一次完整抓包解析,重点拆解SUBSCRIBE(订阅请求)和SUBACK(订阅确认)这两类控制报文。内容适合正在做物联网设备接入、平时用MQTT协议但没系统看过报文格式的开发者,也适合那些遇到"订阅成功但收不到消息"这类问题想自己定位的人。看完你不仅知道每帧报文长什么样,还能照着操作步骤自己抓一遍,快速判断平台的响应到底符不符合预期。

1. 订阅报文在整个MQTT链路里的位置,以及为什么非要抓它

MQTT的交互看起来简单,但订阅环节是整个链路里最容易出"假成功"的地方。很多设备端日志只告诉你"连接成功""订阅成功",却没有告诉你平台视角下看到的是什么。

1.1 从连接建立到消息投递,SUBSCRIBE/SUBACK卡在哪一步

一台设备接入OneNET平台的完整MQTT流程大概是:TCP连接建立 → MQTT连接报文CONNECT → 连接确认CONNACK → 设备发送SUBSCRIBE订阅主题 → 平台返回SUBACK → 之后平台把匹配主题的消息通过PUBLISH推送下来。

从排错角度看,订阅报文卡在连接之后、收包之前。如果CONNACK正常但SUBACK迟迟不来,或者SUBACK返回失败码,后续消息推送自然无从谈起。我们之前遇到过一个诡异现象:设备端日志显示"Subscribed",但平台后台显示该设备在线却没有任何订阅关系。最后抓包发现,设备发的SUBSCRIBE里主题是一串错误的字节序,平台把这个主题当作另一个完全不同的主题过滤掉了,而设备端库函数只返回了"发送成功"。

所以抓订阅报文,本质上是在验证三件事:

  • 设备到底把哪个主题发送给了平台
  • 设备请求的QoS等级是多少
  • 平台是否受理了订阅,并以什么QoS受理

这三件事靠看日志都容易失真,只有抓包出的原始字节最可信。

1.2 抓包能验证的三件事:主题、QoS、平台受理结果

先说主题。MQTT主题是以UTF-8字符串放在报文载荷里的,前面有两个字节的字符串长度。一旦中文主题或者特殊字符处理不当,长度字段和实际字节数不匹配,平台解析出来的主题就和你心里想的不一样。抓包后你能直接看到载荷里的ASCII码,是不是多了一个字节、少了一个斜杠,一目了然。

再说QoS。设备端订阅时往往直接写死QoS 1或QoS 2,但OneNET平台对消息质量的支持可能和你的预期不一致。抓SUBACK报文能清楚看到平台实际granted的QoS等级,这比任何日志都有说服力。

最后是平台受理结果。SUBACK里的返回码就是平台的态度:0表示同意QoS 0,1表示同意QoS 1,2表示同意QoS 2,128表示完全拒绝。设备端库偶尔会忽略这个返回码,导致你误以为订阅成功。抓包后,对错一目了然。

2. 抓包前置准备:OneNET接入参数与本地抓包链路

工欲善其事,必先利其器。抓订阅报文不需要多昂贵的设备,但前置参数和抓包位置得选对,不然抓到一堆乱流也没用。

2.1 平台侧先把设备信息准备齐全

在OneNET平台创建产品后,你会拿到一组设备接入凭证。不同协议接入方式的参数名略有差异,但核心信息基本是这几类:产品ID、设备名称、设备鉴权信息,以及接入服务器的地址和端口。以经典的MQTT裸协议接入方式为例,连接时这样填:

  • clientId(客户端标识)填设备名称
  • username(用户名)填产品ID
  • password(密码)填设备鉴权信息

这些参数在抓包前就要准备好。我习惯先把它们写进一个临时配置脚本,避免在抓包现场手忙脚乱地翻控制台。还有一点容易忽略:先确认平台有没有把设备激活。未激活的设备虽然在平台上能看到记录,但连接报文很可能会被拒绝,这会干扰后面订阅报文的抓取。

接入地址和端口以OneNET官方文档提供的为准,不同区域、不同产品类型可能不一样。抓包的时候建议直接用IP地址做过滤器,会少很多噪声。

2.2 本机抓包和旁路抓包怎么选

抓包位置决定了你看到的是"设备视角"还是"平台视角"。最简单的方式是让MQTT客户端跑在PC上,用Wireshark抓本机流量。如果客户端和服务器都指向外部平台,那么流量会经过物理网卡,直接选择对应的以太网或Wi-Fi接口即可。但如果客户端和Wireshark在同一台机器上,且平台地址是本机回环地址,那就得选Loopback回环接口,否则什么都抓不到。

嵌入式设备的场景更麻烦一点。比如用ESP32连接OneNET,你不可能直接在芯片上跑Wireshark。我常用的替代方案有两种:一是让设备连电脑开的手机热点,再用Wireshark抓热点网卡的流量;二是在电脑上先跑一个和嵌入式设备等同参数的Mosquitto客户端订阅同一主题,抓包结果基本等价。很多嵌入式设备的MQTT协议栈都是标准实现,电脑客户端抓到的报文格式和设备发出去的一致,排错完全够用。

提示:抓包时要确保Wireshark的抓包权限足够,Linux下一般需要用sudo运行,某些系统还要求安装Npcap/WinPcap并选择正确的接口。

2.3 Wireshark里把MQTT报文找出来的过滤语法

流量如果不多,Wireshark默认就能帮你识别MQTT协议,显示过滤器里直接敲mqtt就能看到所有MQTT报文。但实际抓包时TCP重传、KeepAlive心跳之类的杂包很多,最好用更精准的过滤表达式。

只显示订阅请求和订阅确认:

mqtt.msgtype == 0x08 || mqtt.msgtype == 0x09

其中0x08对应SUBSCRIBE,0x09对应SUBACK。只看特定主题的订阅请求:

mqtt.topic contains "sensor"

按服务器地址过滤TCP流量:

ip.addr == 平台接入地址 && tcp.port == 接入端口

我习惯把第二个和第三个组合用:先按IP和端口过滤到MQTT连接,再按报类型锁定订阅相关报文。这样即使在嘈杂的网络环境里也能迅速定位目标。

3. SUBSCRIBE报文逐字节拆解:从0x82开始读懂每次订阅

SUBSCRIBE报文是客户端主动发向服务器的。它的第一个字节固定以0x82开头,这是入门时最容易产生疑问的地方,很多人总觉得0x10才是消息开头,但0x82才是订阅的关键标识。下面把每个字节拆开讲。

3.1 固定头:0x82的来历与标志位的纪律

MQTT所有的控制报文第一字节都分成两部分:高4位表示报文类型,低4位是标志位。SUBSCRIBE的类型值对应二进制0111,即十进制的7,换算成十六进制高4位就是0x70?这里要区分清楚:Wireshark里显示的是字节整体值,而协议规定的SUBSCRIBE标志位只能是0010,二者组合得到0x82。

写得更直观一些:

位数值含义
bit 7-40111报文类型=SUBSCRIBE(7)
bit 30保留位,必须为0
bit 20保留位,必须为0
bit 11标志位固定1
bit 00标志位固定0

之所以把SUBSCRIBE的标志位锁死为0010,是为了防止消息类型被误用于其他用途。你不需要死记这个二进制过程,只记住"订阅报文的固定头第一字节就是0x82"就够了。同理,后面要讲的SUBACK固定头是0x90。

3.2 剩余长度:按字节计算,别被小报文带偏

固定头第二字节非常关键,它表示的是"从可变头开始到报文结束的剩余字节数"。以订阅一个主题为例:

  • 可变头固定2字节(包标识符)
  • 载荷部分至少3字节起(2字节主题长度 + 主题字符串 + 1字节QoS)

所以剩余长度至少是5。计算公式为:

剩余长度 = 可变头长度(2) + 主题长度字段(2) + 主题字节数 + QoS字节数(1)

比如主题字符串是"sensor/temperature",共18个字符,剩余长度就是2 + 2 + 18 + 1 = 23,十六进制记作0x17。

需要提醒的是,剩余长度本身也是变长编码的。小于128时1个字节就够了;大于127时第一个字节的最高位置1,表示后面还有后续字节。绝大多数订阅报文都很短,很少会触发多字节编码,但遇到主题特别长或者一次订阅几十个主题的极端情况,就会看到第二字节高位为1。

3.3 可变头里的包标识符:订阅也需要对账

SUBSCRIBE的可变头里只有一个字段:包标识符(Packet Identifier),占2个字节。它的作用是让客户端能把订阅请求和对应的SUBACK确认对上号。

包标识符由客户端自己管理。简单递增、循环使用都可以,但同一时刻不能有多个未确认的SUBSCRIBE使用同一个标识符。比如某客户端先发了一包ID为1的订阅,还没收到SUBACK,又用ID=1发了一包订阅,此时服务器就把两包当成重传来对待。抓包看到这种情况时,要考虑客户端库的包ID管理是否有问题。

在实际业务里,如果设备重启后沿用旧的包ID,一般也没问题,因为连接已经重建,上下文已经清空。但在同一条连接里频繁订阅/退订,包ID的分配就需要小心。

3.4 载荷结构:Topic Filter与请求QoS

SUBSCRIBE的载荷是"一个或多个主题过滤器"的集合。每个主题过滤器的格式是:

[2字节字符串长度] [主题UTF-8字节] [1字节请求QoS]

注意主题过滤器比普通主题更宽松,它允许使用MQTT通配符,比如:

  • sensor/+/temperature是单层通配符,匹配sensor/a/temperature
  • sensor/#是多层通配符,匹配sensor/下的所有层级

多个主题过滤器可以打包在同一条SUBSCRIBE报文里。此时报文长度会相应增加,载荷依次排列即可。平台收到后会逐条解析,逐条在SUBACK里给出返回码。

请求QoS是单独1个字节,取值只有0、1、2。不少设备端代码会把QoS参数以整数形式塞进字符串,导致长度错位,抓包一眼就能看出来。

3.5 一帧完整报文对照:十六进制与Wireshark同屏

假设我们要订阅主题sensor/temperature,请求QoS为1,那么一帧完整的SUBSCRIBE报文如下:

82 17 00 01 00 12 73 65 6E 73 6F 72 2F 74 65 6D 70 65 72 61 74 75 72 65 01

逐个字段说明:

字节范围值说明
082固定头:SUBSCRIBE报文
117剩余长度=23字节
2-300 01包标识符=1
4-500 12主题字符串长度=18字节
6-2373 65 ... 65ASCII字符"sensor/temperature"
2401请求QoS=1

在Wireshark的MQTT解析视图里,你会看到Message Type: Subscribe、Packet Identifier: 1、Topic: sensor/temperature、Requested QoS: 1。对应的十六进制内容和上面完全一致。如果某个库生成的报文字节和这帧对不上,基本就是序列化哪里出了问题。

4. SUBACK报文逐字节拆解:平台到底答应了你什么

客户端把SUBSCRIBE发出去,服务器处理完会回复SUBACK。这个报文很短,但含义极密。读懂它,才能真正确认订阅关系的建立。

4.1 固定头0x90与报文长度

SUBACK的固定头第一字节是0x90。它的报文类型值是9,标志位固定为0000。和SUBSCRIBE不同,SUBACK没有需要重传或保留的标志位,所以标志位低四位全是0,组合成0x90。

SUBACK的剩余长度计算也很简单:可变头2字节 + 返回码N字节(N等于SUBSCRIBE里主题过滤器的个数)。如果只订阅了一个主题,SUBACK固定头后紧跟的剩余长度就是3。整帧报文为:

90 03 00 01 01

这里90是报文类型,03是剩余长度,00 01是包标识符,01是返回码。

4.2 包标识符回显的意义

SUBACK必须原样回显SUBSCRIBE里的包标识符。这个回显行为有两个作用:

  • 客户端用来匹配是哪一包订阅得到响应
  • 服务器用相同包ID告知具体结果

抓到SUBACK如果发现包标识符和刚才发出去的不一致,说明服务器响应的不是当前这包订阅,应该立即怀疑TCP层是否发生了数据粘包或解析错位。不过MQTT的剩余长度字段会帮解析器自动做好边界,正常情况下不会错位。真出现这种情况,优先查是不是服务器端网关做的中间层转发出了问题。

4.3 返回码表:0 / 1 / 2 / 128

SUBACK载荷里的每一个字节都代表对应订阅请求的处理结果。MQTT协议定义了四个值:

返回码含义
0x00订阅成功,最大QoS为0
0x01订阅成功,最大QoS为1
0x02订阅成功,最大QoS为2
0x80订阅失败

特别要解释一下"最大QoS"这个概念。如果设备请求QoS为2,而平台侧只允许最高QoS为1的投递,那么SUBACK返回码不会返回2,而是返回0x01,表示"我接受了你的订阅,但只能按QoS 1给你投递消息"。很多设备端代码不看返回码,只看到"非0x80就当作成功",所以不会发现问题。实际上,请求QoS和granted QoS不一致时,客户端应当按granted QoS来接收消息,否则后续报文确认逻辑会乱。

返回0x80时平台完全拒绝了该主题,常见原因包括:主题格式非法、无权限订阅该主题、主题包含被平台禁用的通配符。

4.4 一次订阅多个主题时SUBACK怎么排布

如果一条SUBSCRIBE里写了三个主题过滤器,那么SUBACK载荷里就会有3个返回码字节,顺序和请求顺序一一对应。比如请求的顺序是A、B、C,返回码排布可能是:

90 05 00 01 01 00 80

对应含义为:A主题granted QoS1,B主题granted QoS0,C主题订阅失败。不要小看这个排布,很多人看到第一字节是01就以为所有主题都订阅成功了,实际第三个已经失败。

Wireshark对SUBACK的解析同样直观:它会列出每个Return code,并在对应位置标注Success / Failure。抓着多主题订阅报文逐条对照,比自己数字节高效得多。

5. 实战演示:用Mosquitto客户端把订阅全流程抓出来

讲完理论,直接做一次完整的抓包实操。这里以PC机上的Mosquitto客户端连接OneNET为例,步骤可以完整复现。

5.1 从连接到订阅的命令与交互

假设OneNET平台的接入地址和端口已经拿到,设备参数也配好了,用以下命令发起订阅:

mosquitto_sub -h 平台接入地址 -p 端口 -i device001 -u 产品ID -P 设备鉴权信息 -t "sensor/temperature" -q 1

命令执行后,客户端会先建立TCP连接,然后发送CONNECT,收到CONNACK后才会发SUBSCRIBE。为了让抓包过程可观察,建议在终端前台运行,保持订阅不退出,这样方便持续观察。

如果手边没有装Mosquitto,也可以用Python的paho-mqtt库快速模拟:

import paho.mqtt.client as mqtt client = mqtt.Client(client_id="device001") client.username_pw_set("产品ID", "设备鉴权信息") client.connect("平台接入地址", 端口, 60) client.subscribe("sensor/temperature", qos=1) client.loop_start() import time time.sleep(10) client.loop_stop() client.disconnect()

这两种方式产生的报文在格式上没有任何区别,选择顺手的那一种就好。

5.2 Wireshark定位三帧关键报文

抓包开始前先设置过滤器,我一般输下面这行,直接把范围限定到目标服务器:

ip.addr == 平台接入地址 && tcp.port == 接入端口

执行订阅命令后,Wireshark里会依次出现TCP握手的三帧、CONNECT、CONNACK、SUBSCRIBE和SUBACK。在显示过滤器里进一步敲:

mqtt.msgtype == 0x08 || mqtt.msgtype == 0x09

就能把订阅请求和订阅确认单独筛出来。选中SUBSCRIBE这一帧,在报文详情面板里展开MQTT协议层,你能直接看到:

  • Message Type: Subscribe Request
  • Packet Identifier
  • Topic
  • Requested QoS

再打开十六进制面板,找到82开头的原始字节,逐个比对是否和预期一致。紧接着看下一帧SUBACK,能看到granted QoS。如果这两帧紧挨着出现,中间没有TCP重传,说明链路质量很好。

5.3 抓包结果与代码日志的交叉验证

抓完包后,一定要把Wireshark里的结论和设备端日志摆在一起看。常见的矛盾场景是这样的:设备库返回"Subscribe success",但SUBACK返回码是0x80。这是因为某些MQTT客户端库把订阅请求发送成功直接封装成"订阅成功",根本没有解析SUBACK里的返回码。这时只有抓包能看到真相。

我自己踩过的一个坑是:设备端订阅时把主题写成了sensor/temperature/,末尾多了一个斜杠。平台把这当作完全不同的主题,返回SUCCESS但它永远不会收到消息。抓包后对照ASCII码才发现多了一个字符0x2F。类似这种问题靠日志根本看不出来,只能抓包。

6. 订阅异常时的报文特征与排查链路

最后把最常见的订阅异常和对应的报文特征梳理一遍。这里的排查思路比死记硬背报错更有用。

6.1 常见异常:等不到SUBACK、0x80、QoS降级、通配符受限

第一种异常是SUBSCRIBE发出后一直等不到SUBACK。抓包时如果只看到SUBSCRIBE,没有后续任何MQTT报文,大概率是平台根本没通过这个订阅,或者连接已经被服务器静默断开。这时要往上翻TCP层,看看有没有RST或FIN包。如果有RST,通常连接已经异常;如果没RST,可能是平台等待某些额外信息超时,比如鉴权未通过。

第二种异常是SUBACK返回0x80。解决方案是回到OneNET控制台核对设备是否有该主题的订阅权限,以及主题名是否带上了非法字符。部分平台也会对主题层级数量做限制,比如超过8层直接拒绝。

第三种异常是QoS降级。请求QoS为2,SUBACK回0x01或者0x00,这不算错误,但会直接影响消息投递质量。后面客户端如果仍然按QoS 2去处理PUBLISH的确认流程,就会出各种奇怪问题。

第四种异常是通配符受限。平台侧部分产品可能不允许使用多级通配符#,强制订阅带#的主题会返回0x80。抓包时能看到载荷里的0x23字符。遇到这种情况,换用具体的主题层级,或者改用平台提供的其他消息路由方式。

6.2 排查链路:从TCP层到MQTT层逐层看

不要一上来就盯着MQTT层。我推荐的排查链路是自底向上:

先看TCP层,过滤tcp.flags.reset == 1或tcp.stream,确认连接有没有异常断开;再看MQTT层的CONNACK,确认识别码是0(表示连接接受);接着抓SUBSCRIBE和SUBACK,对比包标识符和返回码;最后看后续的PUBLISH/PUBACK,确认消息是不是真的按granted QoS在推。

某一次线上问题就是这样排查出来的:TCP层一切正常,CONNACK也是0,SUBACK返回0x01看起来成功了,但后续完全没有PUBLISH。后来发现设备连接时使用了clean session=0的持久会话,旧会话里残留的旧订阅关系把新订阅挤掉了。平台恢复的是旧会话的订阅,而不是这次新发的订阅。把clean session改成1重新连接后,一切恢复正常。

6.3 几个容易误导人的细节:包ID重复、DUP重传、会话持久化

最后补充几个容易在抓包时误判的细节。

包标识符重复不一定出问题。如果客户端因为网络原因重发了SUBSCRIBE,且DUP位被置1,服务器会识别为重传。真正出问题的是:客户端在同一连接中,用同一个包标识符发了两包不同的订阅请求,比如一包订阅"A",一包订阅"B"。这时服务器无法区分新订阅和重传,可能导致订阅关系丢失。抓包时看到连续两包SUBSCRIBE包标识符相同,要立刻警觉。

DUP重传标志位在SUBSCRIBE里表现为第一字节从0x82变成0x8A(高四位类型不变,bit3被置1)。遇到重传时先检查之前是不是丢包了,而不是急着改订阅逻辑。

会话持久化这一点很多人会忽略。clean session=0时,订阅关系在连接断开后依然保存在服务器端。断线重连后如果客户端不发SUBSCRIBE,而是直接等消息,平台也会按上次订阅关系推送。看起来一切正常,但如果你在抓包里找不到新的SUBSCRIBE帧,别急着说设备订阅丢了——它可能在吃之前会话的老本。

7. 最后分享一点实操体会

抓包这件事,工具门槛很低,真正的门槛在于"愿意逐字节相信数据而不是相信日志"。MQTT协议本身并不复杂,SUBSCRIBE和SUBACK加在一起也就几十个字节,但恰恰是这些短报文,把设备端、平台端、消息链路之间的所有信任关系都暴露在明处。

我自己处理订阅问题的心得是:先准备一个干净的复现环境,用Mosquitto或短Python脚本把问题报文稳定触发出来,再抓包拆解;不要把线上设备的日志当作唯一依据。抓包时多花一分钟看TCP层,会省掉后面一小时的瞎猜。

如果你现在正在调OneNET的MQTT订阅,建议直接把上面的过滤表达式和报文对照表存下来,遇到问题先抓一帧SUBSCRIBE、再抓一帧SUBACK,两帧对齐,多半就能定位问题所在。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进