MQTT 协议学习:002- 通信报文的构成

2023-04-22,,

背景

之前工作中参与有关协议调试的时候,发现对于协议帧的解析是比较重要的。

参考:《MQTT协议 -- 消息报文格式》、《基于STM32实现MQTT》、《MQTT协议从服务端到客户端详解》

英文资料:《MQTT Control Packets》

MQTT协议数据包结构

此图是 PUBLISH 报文的组成

在MQTT协议中,一个MQTT数据包由:固定头(Fixed header)、可变头(Variable header)、消息体(payload)三部分构成。

(1)固定头(Fixed header)。存在于所有MQTT数据包中,表示数据包类型及数据包的分组类标识。
(2)可变头(Variable header)。存在于部分MQTT数据包中,数据包类型决定了可变头是否存在及其具体内容。
(3)消息体(Payload)。存在于部分MQTT数据包中,表示客户端收到的真正内容。 与可变头一样,在有些协议类型中有消息内容,有些协议类型中没有消息内容。

MQTT固定头

MQTT协议分很多种类型,如连接,发布,订阅,心跳等。所有类型的MQTT协议中,都必须包含固定头。

固定头包含两部分内容,首字节(Byte1)和剩余消息报文长度(从Byte2开始,最多占用4个字节)。

Bit 7 6 5 4 3 2 1 0
byte 1 MQTT报文类型 报文类型标志位
byte 2.. 剩余长度

MQTT Control Packet type 报文类型

Byte1的 Bit[7-4]: MQTT Control Packet type,报文类型。总共可以表示16种报文类型,其中0000和1111是保留字段。

报文类型 Bit[7-4]值 数据方向 描述
保留 0000 禁用 保留
CONNECT 0001 Client ---> Server 客户端连接到服务器
CONNACK 0010 Server ---> Client 连接确认
PUBLISH 0011 Client <--> Server 发布消息
PUBACK 0100 Client <--> Server 发不确认
PUBREC 0101 Client <--> Server 消息已接收(QoS2第一阶段)
PUBREL 0110 Client <--> Server 消息释放(QoS2第二阶段)
PUBCOMP 0111 Client <--> Server 发布结束(QoS2第三阶段)
SUBSCRIBE 1000 Client ---> Server 客户端订阅请求
SUBACK 1001 Server ---> Client 服务端订阅确认
UNSUBACRIBE 1010 Client ---> Server 客户端取消订阅
UNSUBACK 1011 Server ---> Client 服务端取消订阅确认
PINGREQ 1100 Client ---> Server 客户端发送心跳
PINGRESP 1101 Server ---> Client 服务端回复心跳
DISCONNECT 1110 Client ---> Server 客户端断开连接请求
保留 1111 禁用 保留

Flags specific to each MQTT Control Packet type 报文类型标志位

Byte1的 Bit[3-0]: Flags specific to each MQTT Control Packet type,字节位用作某些报文类型的标志位。

实际上只有少数报文类型有控制位,如下表。

报文类型 固定头标记 Bit 3 Bit 2 Bit 1 Bit 0
CONNECT 保留 0 0 0 0
CONNACK 保留 0 0 0 0
PUBLISH Used in MQTT 3.1.1 DUP QoS QoS RETAIN
PUBACK 保留 0 0 0 0
PUBREC 保留 0 0 0 0
PUBREL 保留 0 0 1 0
PUBCOMP 保留 0 0 0 0
SUBSCRIBE 保留 0 0 1 0
SUBACK 保留 0 0 0 0
UNSUBACRIBE 保留 0 0 1 0
UNSUBACK 保留 0 0 0 0
PINGREQ 保留 0 0 0 0
PINGRESP 保留 0 0 0 0
DISCONNECT 保留 0 0 0 0

我不想这么快就解释PUBLISH 报文中有关标志位的意义与用法,容易对学习造成困扰。

剩余长度

剩余长度的计算从理解上是一大难点。注意理解好下面2句加粗的句子。

Remaining Length意思是剩余长度,即可变头(Variable header) + 消息体(payload)的长度。

剩余长度从Byte 2开始,最长可达4字节。即:剩余长度范围是Byte2到Byte5。

计算: 剩余长度 所占用的字节数

MQTT协议规定,byte2(最高到byte5)的bit7(最高位)若为1,则表示还有后续字节存在。

记 N 为 消息报文中的 第n个byte, (2 < N < 5), (Byte 5 的 bit7肯定是0)

如果byte N 的 bit7 是1,那么Byte M (M = N + 1, M < 5 ) 作为剩余长度的一部分,可用于继续计算字节长度;

如果byte N 的 bit7 是0,那么Byte M (M = N + 1, M < 5 ) 就不能看作是剩余长度的一部分计算字节长度。

所以单个字节最大值:01111111,即:0x7F,10进制为127。

MQTT协议最多允许4个字节表示剩余长度。那么最大长度为:0xFF,0xFF,0xFF,0x7F。

计算:剩余长度 所代表长度(以Byte为单位)

消息长度可以简单理解为128进制的数据,4位长度最大可以表示128128128*128Byte=256MB。

注意:长度的计算有些特别,即低位在前,高位在后。

以下是消息长度的长度范围:

占用字节 长度范围的最小值 长度范围的最大值
1 0(0x00) 127(0x7F)
2 128 (0x80, 0x01) 16 383 (0xFF, 0x7F)
3 16 384 (0x80, 0x80, 0x01) 2 097 151 (0xFF, 0xFF, 0x7F)
4 2 097 152 (0x80, 0x80, 0x80, 0x01) 268 435 455 (0xFF, 0xFF, 0xFF, 0x7F)

剩余长度 的有关计算

为了方便读者理解,我们举例并计算一下。

若现收到一段MQTT数据报文: 0x20 0x02 0xAA 0xBB ,一共4个字节

根据 MQTT 数据结构可知,0x20 代表了 CONNACK 报文

第二个字节开始,与 剩余长度有关Remaining Length

显然 , Byte2 (0x02)中的byte2[7] 为 0,代表后面的0xAA 0xBB与剩余长度无关。

再有,Byte2 (0x02) Byte2[6:0] 值 = 2,代表后续的报文长度还有2个字节,它们是0xAA 0xBB

(我们先不关心与固定头无关的部分0xAA 0xBB代表了什么意思,实际上是我乱举例的。)

至此,固定头计算完毕。

这个例子比较简单,我们再来看一段稍微复杂一点的报文。

若现收到一段以 0x30 0x9B 0x01 ... 开头的 MQTT数据报文:

根据 MQTT 数据结构可知,0x30 代表了 PUBLISH 报文

第二个字节开始,与 剩余长度有关Remaining Length

显然,Byte 2 (0x9B) 中的bit7 为1,代表后面的0x01与剩余长度有关。

再有,Byte 3 (0x01) 中的bit7 为0,代表剩余长度`有关的报文在此字节为止。

知道了剩余长度有关的报文字节是 0x9B0x01 ,那么就是计算具体的剩余长度。

注意:要低位在前,高位在后。

Byte 2 中的 0x9b 中能够计算长度的只有 byte2[6:0] 即 (0x9b)&~(0x80) = 0x1B

那么: len = (0x01)* 128 + 0x1B = 155 ,即:后面的报文还有155个字节。

我们也可以通过这个例子知道报文的长度实际上是128进制的存储方式。

至此,固定头计算完毕。

以此类推,我们很容易知道,如果一段报文以 0x20 0xFF 0xFF 0xFF 0x7E开头,那么剩下的还有 266338303 个 报文字节

\[len = (7E_{16})*128^3 + (7F_{16})*128^2 + (7F_{16})*128^1 + (7F_{16})*128^0 = 266338303
\]

我们甚至可以写出一段"报文字节剩余长度计算"的c语言代码。

/*
# Copyright from Web, All Rights Reserved
#
# File Name: endecode_for_rl.c
# Created : Mon, Feb 3, 2020 7:47:02 PM
*/ #include <stdio.h>
typedef unsigned int uint32;
typedef unsigned short uint16;
typedef unsigned char uint8; /*
* buf 存放剩余长度 段的 容器
* length 设置的长度
* 返回值: buf 占用的 字节数
* */
int MQTTPacketSetPacketLenth(uint8 *buf, unsigned long length)
{
// ref : https://blog.csdn.net/weixin_42381351/article/details/89397776
unsigned long rc = 0;
unsigned char d;
do {
d = length % 128;
length /= 128;
/* if there are more digits to encode, set the top bit of this digit */
if (length > 0) {
d |= 0x80;
}
buf[rc++] = d;
} while (length > 0);
return rc;
} /*
* buf 作为 剩余长度 帧 的首地址
* */
unsigned long MQTTPacketGetPacketLenth(uint8 *buf)
{
// 改编自中文版文档中的伪代码
char encodedByte;
unsigned int multiplier = 1;
unsigned long rc = 0;
int i = 0; do {
encodedByte = buf[i++];
rc += (encodedByte & 0x7f) * multiplier;
if (multiplier > 128*128*128)
break; //throw Error(Malformed Remaining Length)
else
multiplier *= 128;
}while ((encodedByte & 0x80) != 0);
return rc;
} int main(void)
{
int i;
unsigned long rl;
int length_step; uint8 packet[256] = {0x80, 0x80, 0x80, 0x01}; // 除了剩余长度以外,没有其他部分
rl = MQTTPacketGetPacketLenth(packet);
printf("求出的长度为 : %ld\n", rl); length_step = MQTTPacketSetPacketLenth(packet, 16383);
rl = MQTTPacketGetPacketLenth(packet);
printf("求出的长度为 : %ld, 应该是 16383\n", rl); length_step = MQTTPacketSetPacketLenth(packet, 2097151);
rl = MQTTPacketGetPacketLenth(packet);
printf("求出的长度为 : %ld, 应该是 2097151\n", rl); length_step = MQTTPacketSetPacketLenth(packet, 268435455);
rl = MQTTPacketGetPacketLenth(packet);
printf("求出的长度为 : %ld, 应该是 268435455\n", rl); length_step = MQTTPacketSetPacketLenth(packet, 321);
rl = MQTTPacketGetPacketLenth(packet);
printf("求出的长度为 : %ld, 应该是 321\n", rl);
return 0;
}

MQTT 可变头

Variable Header的意思是可变化的消息头部。MQTT数据包中包含一个可变头,它驻位于可变头(Variable header)与消息体(payload)之间。

有些报文类型包含可变头部,如PUBLISH,SUBSCRIBE,CONNECT等等。可变头部在固定头部和消息内容之间,其内容根据报文类型不同而不同。

学习固定头的时候,我们可以一个个字节位进行分析计算,但学习可变头我个人认为应该根据具体的报文类型进行完整的分析。

可变头部不是可选的意思,而是指这部分在有些协议类型中存在,在有些协议中不存在。

可变头的内容因数据包类型而不同,较常的应用是做为包的标识:

Bi 7 6 5 4 3 2 1 0
byte 1 包标签符(MSB)
byte 2… 包标签符(LSB)

使用大端序(big-endian,高位字节在低位字节前面)。这意味着一个16位的字在网络上表示为最高有效字节(MSB),后面跟着最低有效字节(LSB)。

后面的字段也用到了这种编码,这里需要特意强调一下:

有关字符串,MQTT采用的是修改版的UTF-8编码,一般形式为如下,需要牢记:

bit 7 6 5 4 3 2 1 0
byte 1 String Length MSB
byte 2 String Length LSB
bytes 3 ... Encoded Character Data

头2个字节(byte1、byte2)组成为一个完整的无符号的16位数字,代表从byte3开始后面字符串字节长度。

后面的n个字节才是字符串真正的内容。

前后共2+n个字节。

可变头的 报文标识符

Packet Identifier 也可以叫做 Message Identifier,以后在文章中出现的 报文标识符,都以 Packet Identifier 指代。

报文标识符用来区分报文,特别是在重发的报文中用来标识是否是同一个报文,并在需要应答的场景中用于确定是对哪个发送报文的应答。可变报头的报文标识符(Packet Identifier)字段存在于在多个类型的报文里(占用2个字节)。这些报文是:

PUBLISH(QoS > 0时)PUBACKPUBRECPUBRELPUBCOMPSUBSCRIBE, SUBACKUNSUBSCRIBEUNSUBACK

其实是这样的。因为 在 MQTT 协议 中 ,有些报文在发出以后 需要有收到对应响应报文;为了避免不被混淆,所以才用 Packet Identifier 来 "绑定" 处理这些消息。如果没有 Packet Identifier 那么在通信中,连续多条一样的报文就变得无法处理。发送者不知道现在第几条消息被有效处理了,不知道第几条消息被拒绝了。

Bit 7 - 0
byte 1 报文标识符 MSB
byte 2 报文标识符 LSB

Package ID默认是从1(0x01)开始并自增,最大为255(0xff)。

SUBSCRIBEUNSUBSCRIBEPUBLISH(QoS大于0)控制报文必须包含一个非零的16位报文标识符(Packet Identifier)。

客户端每次发送一个新的这些类型的报文时都必须分配一个当前未使用的报文标识符
如果一个客户端要重发这个特殊的控制报文,在随后重发那个报文时,它必须使用相同的标识符

当客户端处理完这个报文对应的确认(ACK, CMP)后,这个报文标识符就释放可重用。

例如:QoS 1的PUBLISH对应的是PUBACK,QoS 2的PUBLISH对应的是PUBCOMP,与SUBSCRIBE或UNSUBSCRIBE对应的分别是SUBACKUNSUBACK

发送一个QoS 0的PUBLISH报文时,相同的条件也适用于服务端。

QoS等于0的PUBLISH报文不能包含报文标识符。

PUBACK, PUBREC, PUBREL报文必须包含与最初发送的PUBLISH报文相同的报文标识符。类似地,SUBACKUNSUBACK必须包含在对应的SUBSCRIBE和UNSUBSCRIBE报文中使用的报文标识符。

需要报文标识符的控制报文在 下表 - 包含报文标识符的控制报文 Control Packets that contain a Packet Identifier`中列出。

控制报文 报文标识符字段
PUBLISH YES(QoS > 0)
PUBACK YES
PUBREC YES
PUBREL YES
PUBCOMP YES
SUBSCRIBE YES
SUBACK YES
UNSUBSCRIBE YES
UNSUBACK YES

客户端和服务端彼此独立地分配报文标识符。因此,客户端服务端组合使用相同的报文标识符可以实现并发的消息交换。

换句话说, 假设客户端发送标识符为0x1234的PUBLISH报文,它有可能会在收到那个报文的PUBACK之前,先收到服务端发送的另一个不同的但是报文标识符也为0x1234的PUBLISH报文。

    Client                     Server

   PUBLISH Packet Identifier=0x1234--->

   <--PUBLISH Packet Identifier=0x1234

   PUBACK Packet Identifier=0x1234--->

   <--PUBACK Packet Identifier=0x1234

上边消息客户端给服务端发送一条消息,使用的Packet ID是0x1234,同时服务端给客户端发送了一条消息,也使用了Packet ID 0x1234。

然后客户端回复服务端,发送PUBACK,最后是客户端收到服务端的回复PUBACK。

Payload消息体

并非所有的报文类型需要包含Payload。

下表 - 包含有效载荷的控制报文 Control Packets that contain a Payload 列出了需要有效载荷的控制报文。

控制报文 是否包含Payload
CONNECT 需要
CONNACK 不需要
PUBLISH 可选
PUBACK 不需要
PUBREC 不需要
PUBREL 不需要
PUBCOMP 不需要
SUBSCRIBE 需要
SUBACK 需要
UNSUBSCRIBE 需要
UNSUBACK 不需要
PINGREQ 不需要
PINGRESP 不需要
DISCONNECT 不需要

根据上表我们可以知道,Payload消息体作为MQTT数据包的第三部分,被包含于CONNECTSUBSCRIBESUBACKUNSUBSCRIBEPUBLISH这些类型报文里面:

1)CONNECT,消息体内容主要是:客户端的ClientID、订阅的Topic、Message以及用户名和密码。

2)SUBSCRIBE,消息体内容是一系列的要订阅的主题以及QoS。

3)SUBACK,消息体内容是服务器对于SUBSCRIBE所申请的主题及QoS进行确认和回复。

4)UNSUBSCRIBE,消息体内容是要订阅的主题。

5)PUBLISH, 消息体内容是要实际消息的内容(虽然是可选的,可是PUBLISH的报文确实比较常用的)。

除了上面列出的报文类型,其它的报文类型都没有Payload。

接下来我们根据不同的报文类型进行分析。

MQTT 协议学习:002- 通信报文的构成的相关教程结束。

《MQTT 协议学习:002- 通信报文的构成.doc》

下载本文的Word格式文档,以方便收藏与打印。