【MQTT】报文的结构:固定头、剩余长度与 UTF-8

【MQTT】报文的结构:固定头、剩余长度与 UTF-8 用 Wireshark 抓过 MQTT 流量之后多半盯着报文列表发过呆第一条 CONNECT 开头是0x10 0x43加一串看起来随机的字节第二条 PUBLISH 开头是0x32。你大概知道第一个字节代表报文类型但0x10和0x32是什么关系0x43是哪来的如果一条消息很大剩余长度超过 127 是怎么塞进一个字节的再进一步如果 Topic 中带有中文字符客户端怎么知道字符串在哪里结束Broker 如何知道是否合法这些答案都在固定头Fixed Header里。MQTT 所有报文共享同一个头结构「类型 标志位 剩余长度」后面的载荷部分根据报文类型来设置。本文将沿着MQTTPacket.c的编解码函数逐个拆解最后拼出完整的 CONNECT 报文。这里先给出结论第一个字节高 4 位是报文类型低 4 位是标志位。CONNECT 报文类型是 1左移 4 位就是0x10。低 4 位只有在 PUBLISH 里才有含义DUP/QoS/RETAIN其它报文类型的标志位是保留位3.1.1 收到非零值算协议错误。剩余长度可变长编码每个字节 7 位有效数据 最高位表示后面是否还有数据最多 4 字节可以表示 256MB 长度。MQTTPacket_encode中实现了剩余长度的编码逻辑。MQTT 字符串2 字节大端长度 UTF-8 字节组成。Paho 用MQTTLenString结构来保存源码utf-8.c负责校验合法性。固定头与剩余长度第一个字节类型在高 4 位标志在低 4 位MQTT 报文的第一个字节是固定头的身份字节8 个 bit 分为两部分bit 7 6 5 4 │ bit 3 2 1 0 Type │ Flags高 4 位是报文类型低 4 位是标志位。内存布局上「类型在高位」所以类型值要左移 4 位才能得到最终字节这就是为什么CONNECTtype1的字节是0x10而不是0x01。PUBLISH是0x30type3、PUBACK是0x40type4。代码里的结构长这样typedefunion{/*unsigned*/charbyte;/** the whole byte */struct{bit retain:1;/** retained flag bit */unsignedintqos:2;/** QoS value, 0, 1 or 2 */bit dup:1;/** DUP flag bit */unsignedinttype:4;/** message type nibble */}bits;}Header;Header在MQTTPacket.h里只是一个字节语义上再拆成type/flags两个 4 位。其中标志位只有在 PUBLISH 里才有实际含义bit3是 DUP重发标记bit2-1是 QoS 级别0/1/2bit0是 RETAIN。其它 14 种报文的标志位都是协议保留位MQTT 3.1/3.1.1 要求它们必须为 0收到非零值视为协议错误MQTT 5.0 对几个报文CONNACK 的 session present、SUBACK 等放宽了部分位但总体上「非 PUBLISH 的 flags 默认全 0」仍成立抓包时看第一个字节的低 4 位可以判断是不是 PUBLISH、以及 QoS 是多少0x32 PUBLISH QoS 10x30 | 0x020x3A PUBLISH QoS 1 DUP0x30 | 0x08 | 0x02。剩余长度1 到 4 字节的可变长编码第一个字节之后是剩余长度Remaining Length固定头之后还剩多少字节。它不用固定 4 字节而是 1-4 字节的可变长编码每个字节用 7 位存数据最高位0x80表示「后面还有字节」1 字节最多 1272 字节最多 163833 字节最多 20971514 字节最多 268435455256 MB超过 4 字节就是非法报文剩余长度通过MQTTPacket_encode计算// paho.mqtt.c/src/MQTTPacket.c:302-317intMQTTPacket_encode(char*buf,size_tlength){intrc0;do{chardlength%128;// 取低 7 位length/128;// 剩下的右移128 进制一位if(length0)d|0x80;// 还有后续字节 → 置最高位if(buf)buf[rc]d;// 输出一个字节elserc;}while(length0);returnrc;}通过几个例子来理解编码格式长度字节序列说明00x00无载荷如 PINGREQ1270x7F1 字节上限1280x80 0x01128 % 128 0最高位置 1128 / 128 1进下一字节163830xFF 0x7F2 字节上限2684354550xFF 0xFF 0xFF 0x7F4 字节上限Paho 实现解码按照从字节流中每次读取一个字节来解析如果最高位是 1 就继续向后读否则就停止解析如果超过了 4 字节报错非法报文。// paho.mqtt.c/src/MQTTPacket.c:1081-1099intMQTTPacket_VBIdecode(int(*getcharfn)(char*,int),unsignedint*value){charc;intmultiplier1;intlen0;#defineMAX_NO_OF_REMAINING_LENGTH_BYTES4*value0;do{if(lenMAX_NO_OF_REMAINING_LENGTH_BYTES)returnMQTTPACKET_READ_ERROR;// 超过 4 字节非法数据(*getcharfn)(c,1);*value(c127)*multiplier;// 当前 7 位乘上 128 的幂multiplier*128;}while((c128)!0);// 最高位为 1 就继续读returnlen;}Paho 提供了两个解码函数MQTTPacket_decodeMQTTPacket.c:330从 socket 直接读MQTTPacket_decodeBufMQTTPacket.c:1121从内存 buffer 读。MQTTPacket_decode用于MQTTPacket_Factory收包边读边解码先读一个字节判断剩余长度占几字节再读够整包。MQTTPacket_decodeBuf用于报文已经在内存里的场景比如 WebSocket 或代理隧道转发。MQTTPacket_decode的关键在于「先知道长度才能把整包读出来」它按需逐字节读等最高位为 0 才确定剩余长度占了几字节然后read()凑齐payload。剩余长度编码有个「0 陷阱」0x80 0x00这种多余的前导零字节虽然按照存储算法上等于 0但按规范是非法的编码必须用最短形式。Paho 的解码循环遇到0x80会继续读下一个字节读到 0 结束算出长度 128×000。不会崩溃或异常但严格校验的 Broker 会拒绝这类报文。自己构造报文时也别这么写。字符串2 字节长度 UTF-8MQTT 字符串的字节格式MQTT 里的字符串topic、clientID、username、password 等不是 C 字符串那样「以 \0 结尾」而是一个固定格式0-1 字节长度大端2 字节单位是字节数 2~ 字节UTF-8 编码的内容以async_publish示例中发布的 topichello为例在报文里是00 05 68 65 6c 6c 6f长度 5 5 个ASCII 字节。注意长度使用大端高字节在前存储。长度只占 2 字节所以 MQTT 字符串最长 65535 字节这是协议规定的上限超出的 topic/clientID 在编码时就会被截断。MQTTLenString长度 指针内存中用MQTTLenString表示字符串定义在MQTTProperties.h中// paho.mqtt.c/src/MQTTProperties.h:88-93typedefstruct{intlen;/** the length of the string */char*data;/** pointer to the string data */}MQTTLenString;字段是len 指针data不是char[n]数组。好处是零拷贝收包时data直接指向接收缓冲里字符串的起始位置len标记边界不需要为每个字符串做malloc 拷贝。代价是不保证以\0结尾用strlen、printf %s当 C 字符串处理可能越界读。编码方向把{len, data}落成报文字节// paho.mqtt.c/src/MQTTPacket.c:1023-1027voidwriteMQTTLenString(char**pptr,MQTTLenString lenstring){writeInt(pptr,lenstring.len);// 2 字节大端长度memcpy(*pptr,lenstring.data,lenstring.len);// 内容紧跟着*pptrlenstring.len;}解码方向MQTTLenStringRead先确认缓冲里还剩足够字节读完长度后再用「字符串终点 ≤ 缓冲终点」兜底防止报文被截断时越界// paho.mqtt.c/src/MQTTPacket.c:1031-1049intMQTTLenStringRead(MQTTLenString*lenstring,char**pptr,char*enddata){intlen-1;if(enddata-(*pptr)1)// 够读 2 字节长度{lenstring-lenreadInt(pptr);// 读长度pptr 偏移 2 字节if((*pptr)[lenstring-len]enddata)// 内容没超出缓冲{lenstring-data(char*)*pptr;// 零拷贝直接指过去*pptrlenstring-len;len2lenstring-len;}}returnlen;}注意enddata贯穿整个 Packet 层的解码所有变长字段都是通过「指针推进 终点不越界」的方式读写读源码看到enddata就知道是防截断用的。零拷贝指针直接指向内存固然快但是 C 程序中的内存泄露、野指针和内存重复释放也是令人头疼的问题。我们在使用这种方式编程时一定要有一套统一的内存释放规则避免多处释放或者忘记释放内存。UTF-8 校验为什么必须有这一层报文里写「UTF-8」不只是声明3.1.1 规范要求收发双方校验topic 等字符串必须是合法 UTF-8且不能包含 U0000NUL、代理区 UD800~UDFFF、以及超过 U10FFFF 的编码。这些字符会破坏订阅匹配和显示所以协议直接禁止。Paho 在utf-8.c中校验实现「逐字符判定字节数 查合法范围表」// paho.mqtt.c/src/utf-8.c:76-119节选staticconstchar*UTF8_char_validate(intlen,constchar*data){intcharlen2;/* 先定这个字符占几个字节 */if((data[0]128)0)charlen1;// 0xxxxxxx → 1 字节elseif((data[0]0xF0)0xF0)charlen4;// 11110xxx → 4 字节elseif((data[0]0xE0)0xE0)charlen3;// 1110xxxx → 3 字节// 其余 110xxxxx → 2 字节if(charlenlen)gotoexit;// 声称的字节数超过了实际长度/* 用合法范围表逐字节核对比如代理区、超范围编码都会被拒 */for(i0;iARRAY_SIZE(valid_ranges);i){/*...*/}}UTF8_validateString在创建客户端、连接、订阅、取消订阅、发布这 5 个流程的入口校验 clientId、username、password、topic 的 UTF-8 编码合法性校验失败认为报文非法返回错误。亲手拼出一个 CONNECT我们把前面的内容串起来拼一个最简单的 MQTT 3.1.1 CONNECT 报文clientID paho_test、keepalive 60、clean session true无 will、无用户名密码。第一步算载荷。 3.1.1 的 CONNECT 载荷只有一项 clientID载荷 UTF-8 字符串 paho_test 2 字节长度 9 字节内容 11 字节第二步算可变头。 3.1.1 的 CONNECT 可变头四段协议名 MQTT → 00 04 M Q T T 24 6 字节 协议级别 4 → 04 1 字节 Connect Flags → 02 bit1 clean session 1 字节 Keep Alive 60 → 00 3C 2 字节可变头共6112 10字节载荷 11 字节所以剩余长度 21 0x15小于 1271 字节就够。第三步拼固定头。 第一字节0x10type1 左移 4 位flags 全 0第二字节剩余长度0x15。完整的 23 字节报文10 15 00 04 4D 51 54 54 04 02 00 3C 00 09 70 61 68 6F 5F 74 65 73 74 │ │ └──────┬──────┘ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └ └ 协议名 MQTT │ │ │ keepalive │ clientID paho_test9 字节 固定头 └级─└flags └功能全在低 4 位、字符串全在 (11字节) 2 字节长度 UTF-8 里通过nc命令向 Broker 发送报文来验证# 中间字节省略自行填充printf\x10\x15\...|nc127.0.0.11883报文成功发送并收到 Broker 的 CONNACK 连接成功的报文。MQTT 5.0 的 CONNECT 在可变头里多了一段 propertieskeepalive 之后、载荷之前。总结回看开头的三个结论第一字节 类型 4 | flags所以 CONNECT 是0x10低 4 位只有 PUBLISH 有含义剩余长度是 128 进制的可变长编码1 到 4 字节、上限 256MB编解码函数MQTTPacket_encode/MQTTPacket_VBIdecode字符串 2 字节大端长度 UTF-8MQTTLenString用「长度 指针」零拷贝承载utf-8.c 负责 3.1.1 的合法性校验。现在你能看懂任意 MQTT 报文的第一个字节和长度字段了。下一篇「第一次握手CONNECT/CONNACK 与版本协商」将拼好的 CONNECT 报文发出去追踪从 C 的connect()到 Broker 回 CONNACK 的完整链路参数校验、CONNECT 报文构造、TCP 发送、以及客户端与 Broker 之间的版本协商逻辑。