3. MQTT报文结构:固定头、可变头、有效载荷与剩余长度编码

好,咱们今天来啃MQTT报文结构这块硬骨头。说实话,很多初学者一上来就被协议栈吓住了,觉得报文结构太抽象。其实没那么复杂——你把它想象成寄快递就明白了:固定头是快递单上的必填信息,可变头是选填的备注,有效载荷就是包裹里的东西。嗯,咱们一个一个拆开看。

3.1 固定头(Fixed Header)——所有报文都有的“身份证”

固定头是每个MQTT报文都必须有的部分。它只有两个字段,但信息量很大。

Bit 7 6 5 4 3 2 1 0
Byte 1 报文类型 (4位) 标志位 (4位)
Byte 2~n 剩余长度 (可变字节)

第一个字节的高4位:报文类型。从1到14,一共15种报文(0和15保留)。比如CONNECT是1,PUBLISH是3,SUBSCRIBE是8。我刚开始记这些类型号的时候,就靠一张小卡片贴在显示器边上,看多了自然就熟了。

第一个字节的低4位:标志位。不同类型的报文,标志位的含义不同。比如PUBLISH报文的低4位是DUP、QoS、RETAIN。这里有个坑——CONNECT、SUBSCRIBE等报文,标志位是固定的,必须按协议规定写,不能乱改。我曾经见过一个同事把SUBSCRIBE的标志位写成了0x0A,结果服务端直接断开连接,排查了半天才发现是标志位不对。

重要提醒:固定头至少2个字节。如果剩余长度超过127字节,固定头会变成3个、4个甚至更多字节。后面会讲编码规则。

3.2 可变头(Variable Header)——不同报文有不同“备注”

可变头不是所有报文都有。比如PINGREQ、PINGRESP就没有可变头。但像CONNECT、PUBLISH这些核心报文,可变头里藏着关键信息。

可变头里最常见的是协议名、协议级别、连接标志、保活时间这些。拿CONNECT报文来说,可变头长这样:

// CONNECT报文可变头结构
Byte 1~2: 协议名长度 (0x00 0x04)
Byte 3~6: 协议名 ("MQTT")
Byte 7:    协议级别 (0x04 表示MQTT 3.1.1)
Byte 8:    连接标志 (Clean Session、Will等)
Byte 9~10: 保活时间 (单位秒)

我个人习惯把可变头看作“报文的个性化配置”。比如PUBLISH报文,可变头里是主题名。主题名是UTF-8编码的字符串,前面两个字节是长度。这里要注意:主题名不能有通配符,通配符只在订阅时用。我踩过这个坑——发布时用了“sensor/+/temp”,结果服务端直接拒绝。

3.3 报文标识符(Packet Identifier)——可变头里的“追踪号”

报文标识符是可变头里一个特殊的字段。它出现在需要确认的报文里,比如PUBLISH(QoS>0时)、SUBSCRIBE、UNSUBSCRIBE。说白了,就是给报文编个号,方便服务端和客户端对账。

报文标识符占2个字节,范围从1到65535。0是保留的,不能用。我建议你从1开始递增分配,用完65535后再从1开始。但要注意:同一个标识符不能同时用于多个未确认的报文。也就是说,你发了一个标识符为1的PUBLISH,在收到对应的PUBACK之前,不能再发标识符为1的报文。

小技巧:在嵌入式设备上,我通常用一个uint16_t的计数器来管理报文标识符。每次发送前自增,如果自增到0就重置为1。简单可靠,不会出问题。

3.4 有效载荷(Payload)——真正的“包裹内容”

有效载荷是报文的实际数据。不是所有报文都有有效载荷。比如PINGREQ、PINGRESP、PUBACK这些控制报文,有效载荷是空的。

有有效载荷的报文包括:

  • CONNECT:包含客户端标识符、遗嘱主题、遗嘱消息、用户名、密码
  • PUBLISH:应用消息本身(比如传感器数据)
  • SUBSCRIBE:订阅的主题过滤器列表(主题+QoS)
  • UNSUBSCRIBE:取消订阅的主题过滤器列表

有效载荷的编码规则很简单:字符串用UTF-8编码,前面加2字节长度。二进制数据直接放。比如一个PUBLISH报文,有效载荷就是你要发送的原始数据,没有任何额外包装。

这里有个容易忽略的点:CONNECT报文的客户端标识符不能为空(除非Clean Session为1且服务端允许零长度客户端ID)。我遇到过设备因为没设置客户端ID,连接一直失败,日志里也看不出原因。后来抓包才发现,客户端ID字段长度是0。

3.5 剩余长度(Remaining Length)——最“烧脑”的编码算法

剩余长度是固定头的第二个字段(或后续字节)。它表示可变头+有效载荷的总字节数。注意,它不包含固定头自身占用的字节。

剩余长度的编码采用可变字节整数(Variable Byte Integer)方式。每个字节只用低7位表示数据,最高位是延续标志位。如果最高位为1,表示后面还有字节;为0表示这是最后一个字节。

编码规则:

// 剩余长度编码示例
值 0 ~ 127:    1个字节 (0x00 ~ 0x7F)
值 128 ~ 16383: 2个字节
值 16384 ~ 2097151: 3个字节
值 2097152 ~ 268435455: 4个字节

举个例子,剩余长度是300:

300 = 0x012C
第一步:300 % 128 = 44,低7位是44,最高位置1 → 0xAC
第二步:300 / 128 = 2,低7位是2,最高位置0 → 0x02
结果:0xAC 0x02

解码算法也很直接:

// 剩余长度解码
multiplier = 1
value = 0
do
  byte = next byte from stream
  value += (byte & 0x7F) * multiplier
  multiplier *= 128
  if (multiplier > 128*128*128)
    // 错误:超出最大长度
while ((byte & 0x80) != 0)

注意:剩余长度最大支持268435455字节(约256MB)。但在嵌入式设备上,我建议你根据实际内存限制一个更小的最大值。比如我常用的ESP8266,一般限制在4096字节以内,防止内存溢出。

说实话,这个编码算法我第一次看的时候也觉得绕。后来自己手写了一遍编码和解码函数,就彻底明白了。你想想看,它本质上就是用7位有效数据+1位标志位,把大整数拆成多个字节传输。这种设计在物联网协议里很常见,CoAP协议也用类似的方式。

3.6 实战经验:报文结构调试技巧

最后分享几个我在项目中用到的调试方法:

  • 抓包分析:用Wireshark配合MQTT插件,可以直观看到每个报文的固定头、可变头、有效载荷。我调试设备连接问题时,第一件事就是抓包。
  • 手动构造报文:在开发初期,我习惯用十六进制编辑器手动构造一个CONNECT报文,然后用TCP工具发送给服务端。这样能彻底理解每个字节的含义。
  • 边界测试:测试剩余长度编码时,一定要测127、128、16383、16384这些边界值。我曾经因为没测128这个边界,导致设备在发送稍大报文时崩溃。

嗯,报文结构这部分就讲到这里。下一节咱们会深入PUBLISH报文的QoS机制,那才是MQTT协议的精髓所在。