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协议的精髓所在。