3. MQTT控制报文详解:CONNECT/CONNACK、PUBLISH/PUBACK、SUBSCRIBE/SUBACK、PINGREQ/PINGRESP

MQTT协议的核心,说白了就是这几种控制报文在来回跑。你想想看,一个设备要连上服务器,总得先打个招呼吧?发完数据总得知道对方收到没?订阅个主题总得确认一下?心跳保活总得有个回应吧?嗯,今天我们就把这四对报文彻底讲透。

我个人习惯把MQTT控制报文分成两类:生命周期类数据交互类。CONNECT/CONNACK属于前者,PUBLISH/PUBACK属于后者。但不管哪一类,理解它们的报文结构,是你在嵌入式设备上做协议栈优化的基本功。

3.1 CONNECT与CONNACK:设备上线的第一声招呼

任何一个MQTT客户端,想要和Broker建立会话,第一步就是发CONNECT报文。我见过不少新手,上来就发PUBLISH,结果Broker直接断开连接——你都没注册,谁理你啊?

CONNECT报文的固定报头很简单,就两个字节:第一个字节是0x10(控制报文类型),第二个字节是剩余长度。但可变报头和有效载荷里,藏着不少关键信息。

CONNECT报文可变报头核心字段:

  • 协议名:固定为"MQTT",长度4字节,UTF-8编码
  • 协议级别:MQTT 3.1.1用0x04,MQTT 5.0用0x05
  • 连接标志:一个字节,决定了Clean Session、Will消息、用户名密码等
  • Keep Alive:2字节,单位秒,建议值60-300

这里我要重点说一下Keep Alive。我在项目中遇到过,有个客户把Keep Alive设成了0,意思是「我不需要心跳」。结果呢?Broker那边默认的会话超时时间一到,直接就把设备踢下线了。设备在野外跑着,突然断连,数据全丢了。

避坑指南:我曾经调试过一个NB-IoT项目,Keep Alive设成了300秒,但运营商网络会在180秒内释放连接。结果设备每隔5分钟就断一次。后来我把Keep Alive改成120秒,配合PINGREQ保活,问题才解决。

记住:Keep Alive值一定要小于网络中间设备的超时时间。

CONNACK报文是Broker对CONNECT的回应。它的固定报头是0x20,剩余长度固定为2。可变报头里有两个关键字节:

字节 含义 常见值
第1字节 Session Present(会话是否延续) 0x00或0x01
第2字节 连接返回码 0x00(成功)

返回码0x00表示连接成功。如果是0x05(未授权),那就要检查用户名密码了。我见过最坑的是0x04(用户名或密码错误),客户硬说密码没错,结果发现是字符串末尾多了个换行符——嵌入式设备上这种低级错误太常见了。

3.2 PUBLISH与PUBACK:数据发布与确认

PUBLISH报文是MQTT里最常用的报文。它的固定报头第一个字节是0x30加上QoS标志位。QoS 0是0x30,QoS 1是0x32,QoS 2是0x34。

可变报头里包含主题名报文标识符(Packet Identifier,简称Packet ID)。注意:QoS 0的PUBLISH没有Packet ID,只有QoS 1和QoS 2才有。为什么?因为QoS 0不需要确认,发完就完事了。

我的经验:在资源受限的MCU上,我建议尽量使用QoS 0。你想想看,一个8位单片机,RAM只有2KB,如果每个PUBLISH都要维护一个Packet ID表,内存很快就爆了。除非你的数据真的不能丢,否则别用QoS 1或2。

PUBACK报文是QoS 1的确认报文。它的固定报头是0x40,可变报头里只有2字节的Packet ID。就这么简单。但这里有个细节:PUBACK的Packet ID必须和对应的PUBLISH一致。如果对不上,Broker会认为这是一个无效报文。

我曾经调试过一个网关设备,发现PUBACK总是对不上。查了半天,原来是多线程环境下,两个任务同时发了PUBLISH,Packet ID冲突了。解决方案很简单:给每个任务分配独立的Packet ID池。

3.3 SUBSCRIBE与SUBACK:订阅主题与确认

SUBSCRIBE报文用于客户端向Broker订阅主题。它的固定报头是0x82。可变报头里包含Packet ID,有效载荷里是主题过滤器列表。

每个主题过滤器后面跟着一个字节的服务质量要求(Requested QoS)。注意,这里是客户端要求的QoS,不是最终能得到的QoS。最终能拿到什么级别,得看SUBACK的返回码。

SUBSCRIBE报文结构示例:

固定报头: 0x82 0x0A
可变报头: 0x00 0x01  (Packet ID = 1)
有效载荷: 
  0x00 0x04 't' 'e' 'm' 'p'  (主题: "temp")
  0x01                       (请求QoS 1)
  0x00 0x03 'h' 'u' 'm'     (主题: "hum")
  0x02                       (请求QoS 2)

SUBACK报文是Broker对SUBSCRIBE的回应。它的固定报头是0x90,可变报头里是Packet ID,有效载荷里是返回码列表。每个返回码对应一个主题过滤器。

返回码的含义:

  • 0x00:成功,最大QoS 0
  • 0x01:成功,最大QoS 1
  • 0x02:成功,最大QoS 2
  • 0x80:失败

嗯,这里要注意:你请求QoS 2,Broker可能只给你QoS 1。为什么?因为Broker可能不支持QoS 2,或者管理员限制了最大QoS。所以,永远不要假设你订阅的QoS就是你请求的QoS

避坑指南:我曾经在某个项目中,客户端请求了QoS 2,但Broker只返回了QoS 1。客户端代码没检查返回码,直接按QoS 2处理。结果呢?数据重复接收,业务逻辑全乱了。从那以后,我要求团队所有订阅操作必须检查SUBACK的返回码。

3.4 PINGREQ与PINGRESP:心跳保活

PINGREQ报文是MQTT里最简单的报文。固定报头就两个字节:0xC0 0x00。没有可变报头,没有有效载荷。就这么简单。

PINGRESP报文同样简单:0xD0 0x00。Broker收到PINGREQ后,必须回复PINGRESP。如果客户端在Keep Alive时间内没收到PINGRESP,就应该认为连接已断开。

为什么需要心跳?说白了,就是防止连接被网络中间设备切断。你想想看,一个设备连着Broker,但半天不发数据。运营商的路由器或者防火墙会想:「这个连接是不是死了?」然后默默把它关掉。PINGREQ就是告诉网络设备:「我还活着,别关我。」

我的建议:PINGREQ的发送间隔,我一般设为Keep Alive值的60%-70%。比如Keep Alive是60秒,那我就在35-40秒发一次PINGREQ。这样即使丢了一两个PINGRESP,还有时间重试,不会误判断连。

在低功耗设备上,PINGREQ的频率要格外小心。我做过一个电池供电的传感器,每5秒发一次PINGREQ。结果呢?电池两天就耗光了。后来我把PINGREQ间隔改成120秒,配合数据上报时顺便保活,电池撑了半年。

3.5 报文交互的完整流程

把这四对报文串起来,就是一个完整的MQTT会话流程:

  1. 客户端发CONNECT,Broker回CONNACK
  2. 客户端发SUBSCRIBE,Broker回SUBACK
  3. 客户端发PUBLISH(QoS 1),Broker回PUBACK
  4. 客户端定时发PINGREQ,Broker回PINGRESP

这个流程看起来简单,但每个环节都可能出问题。我见过最离谱的案例:客户端发了CONNECT后,不等CONNACK就直接发PUBLISH。Broker收到PUBLISH时,会话还没建立,直接丢弃。客户端还纳闷:「我明明发了数据,怎么Broker没收到?」

核心原则:MQTT是严格有序的协议。每个请求都必须等待对应的确认,才能进行下一步操作。别想着「我先发出去再说」,TCP层虽然能保证顺序,但MQTT的会话状态机可不认这个。

好了,这四对报文就讲到这里。下一章我们会深入报文编码的细节,包括剩余长度的计算、UTF-8字符串的编码规则,以及如何在资源受限的MCU上高效解析这些报文。到时候我会分享一个我实际用过的解析器代码,保证实用。