1. MQTT协议基础回顾:报文结构、固定头与可变头、QoS等级对内存的影响
好,咱们直接进入正题。MQTT协议,说白了就是一个轻量级的发布/订阅模型。很多刚入行的朋友觉得它简单,不就是发个消息嘛。但真正做嵌入式裁剪时,你会发现——每一个字节都得精打细算。
我个人习惯,在讲内存优化之前,先把协议底层的报文结构掰开揉碎。你连报文长什么样都不清楚,怎么去优化?
1.1 MQTT报文的三层结构
每个MQTT报文,都由三部分组成:
- 固定头(Fixed Header):所有报文都有,最少2个字节
- 可变头(Variable Header):部分报文有,比如CONNECT、PUBLISH
- 有效载荷(Payload):消息内容,只有PUBLISH等少数报文携带
嗯,这里要注意:固定头是强制存在的,可变头和载荷是可选。很多新手在写代码时,把可变头当固定头处理,结果内存分配多了两倍。
核心记忆点:固定头至少2字节,可变头按需分配,载荷才是真正占内存的大头。
1.2 固定头:第一个字节的秘密
固定头的第一个字节,包含了报文类型和标志位。我直接给你拆开看:
| Bit 7-4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 |
|---|---|---|---|---|
| 报文类型 | DUP标志 | QoS高位 | QoS低位 | RETAIN |
报文类型有14种,从CONNECT(1)到DISCONNECT(14)。但实际嵌入式场景中,你常用的也就那么五六种。我在项目中遇到过,有人把14种报文类型全部实现了,结果Flash多占了2KB。其实很多类型根本用不上。
我的建议:先只实现CONNECT、PUBLISH、SUBSCRIBE、PINGREQ、DISCONNECT这五种。其他类型按需添加。
1.3 剩余长度:变长编码的陷阱
固定头的第二个字节开始,是剩余长度(Remaining Length)。它采用变长编码,最多4个字节。为什么这么设计?说白了就是为了省内存。
你想想看,如果固定用4个字节表示长度,那发一条1字节的消息,光长度字段就占了4字节,浪费75%。
变长编码的规则是这样的:
- 每个字节的最高位是延续位(1表示还有后续字节)
- 低7位是有效数据
- 最多4个字节,最大能表示268435455字节
// 编码示例:发送长度1000
// 1000 = 0x03E8
// 第一个字节:0xE8 | 0x80 = 0xE8(有后续)
// 第二个字节:0x03 & 0x7F = 0x03(无后续)
// 结果:0xE8 0x03
我曾经踩过的坑:在STM32上实现MQTT客户端时,我直接用int类型存储剩余长度。结果发送大消息时,编码出来的字节数不对。后来才发现,变长编码的算法必须用无符号整数,否则右移操作会出问题。
1.4 可变头:不同报文的内存差异
可变头这部分,不同报文差别很大。我列个表给你看:
| 报文类型 | 可变头内容 | 最小长度 | 最大长度 |
|---|---|---|---|
| CONNECT | 协议名、协议级别、连接标志、保活时间 | 10字节 | 可变 |
| PUBLISH | 主题名、报文标识符(QoS>0时) | 2字节 | 可变 |
| SUBSCRIBE | 报文标识符(必须) | 2字节 | 2字节 |
| PINGREQ | 无 | 0字节 | 0字节 |
嗯,这里要注意:CONNECT的可变头里,协议名默认是"MQTT",占了4个字节。如果你确定只和特定Broker通信,可以把这个字段写死,不用每次都动态分配。
1.5 QoS等级对内存的影响
QoS是MQTT里最影响内存设计的因素。三个等级,内存开销天差地别:
- QoS 0:最多发一次,不需要报文标识符,不需要缓存。内存开销最小。
- QoS 1:至少发一次,需要报文标识符(2字节),需要缓存消息直到收到PUBACK。
- QoS 2:正好发一次,需要报文标识符,需要缓存消息,还需要记录状态机(等待PUBREC、PUBREL、PUBCOMP)。
为什么会这样?因为QoS越高,协议栈需要维护的状态越多。我做过一个测试:
| QoS等级 | 每条消息额外内存 | 状态机复杂度 |
|---|---|---|
| 0 | 0字节 | 无状态 |
| 1 | 约20字节(缓存+标识符) | 简单 |
| 2 | 约60字节(缓存+四步状态) | 复杂 |
实战建议:在资源受限的MCU上,优先使用QoS 0。如果必须保证送达,用QoS 1就够了。QoS 2在嵌入式场景下,除非是金融级数据,否则别碰。
1.6 报文标识符的复用技巧
报文标识符(Packet Identifier)是QoS 1和QoS 2的关键。它是一个16位的无符号整数,范围从1到65535。
我见过很多实现,每次发送都分配一个新的标识符,用完就扔。这在内存上太浪费了。其实可以做一个标识符池:
// 标识符池设计
#define MAX_INFLIGHT 4 // 同时最多4条未确认消息
uint16_t packet_id_pool[MAX_INFLIGHT];
uint8_t pool_index = 0;
uint16_t get_packet_id() {
// 循环使用标识符
uint16_t id = packet_id_pool[pool_index];
pool_index = (pool_index + 1) % MAX_INFLIGHT;
return id;
}
这样,你只需要4个uint16_t的空间,就能管理所有消息的标识符。而不是每条消息都动态分配。
我的经验:标识符池的大小,取决于你的网络延迟和消息频率。一般4-8个就够用了。我曾经在ESP8266上只用2个,也能跑得很稳。
1.7 内存优化的三个切入点
好了,回顾完基础,我总结一下内存优化的三个方向:
- 固定头优化:剩余长度编码用查表法代替循环计算,减少代码体积
- 可变头优化:写死协议名、主题名用短字符串、报文标识符复用
- QoS等级选择:能用0不用1,能用1不用2。必要时可以降级处理
说白了,MQTT的内存优化,就是跟每一个字节较劲。你省下10个字节,在8KB RAM的单片机上,可能就是多一条消息的缓存空间。
下一章,我会带你手写一个最小化的MQTT客户端,把固定头和可变头的内存占用压到极致。到时候你就知道,什么叫「每一分内存都用在刀刃上」。