本节目标:搞懂发布/订阅模型、Topic、QoS、Keep Alive、遗嘱消息等概念,理解 MQTT 为什么这么设计,为第 19 节写代码打基础
一、本节要解决的问题
用 HTTP 做物联网通信有几个别扭的地方:
- HTTP 是"客户端请求一次、服务器回答一次"的模型,服务器不能主动给设备推消息,设备想看新数据只能不停轮询;
- 每次请求都要带一大堆头部信息,报文开销大;
- 设备数量一多,轮询会让服务器压力巨大。
MQTT 面向的正是这种场景:设备多、带宽小、网络不稳定、功耗敏感。它把通信模型从"请求/响应"换成了"发布/订阅",服务器(Broker)负责中转,任何一方都能主动发消息。
本节不写代码,先把协议本身讲清楚——第 19 节的每一行 ESP-IDF 代码,背后都是本节的一个概念。
二、相关知识
MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议):
- 由 IBM 在 1999 年发布;
- 构建在TCP/IP 之上,属于应用层协议,和 HTTP、FTP 是同一层;
- 基于发布/订阅(publish/subscribe)模式;
- 最大优点是"以极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务"——低开销、低带宽占用,非常适合物联网、小型设备、移动应用。
第 19 节的工程用 ESP-IDF 自带的 esp-mqtt 组件实现,
sdkconfig中CONFIG_MQTT_PROTOCOL_311=y,即MQTT 3.1.1版本协议。协议本身与芯片无关,换成 STM32、树莓派也是同一套协议。
三、知识点
3.1 发布/订阅模型与 Broker
| 角色 | 职责 |
|---|---|
| 发布者 Publisher | 把消息发布到某个 Topic |
| 订阅者 Subscriber | 向 Broker 订阅自己关心的 Topic |
| Broker | 消息服务器,保存订阅关系,收到消息后转发给所有匹配的订阅者 |
三者完全解耦:发布者不知道谁在收,订阅者不知道消息从哪来,新增一个观察者不需要改动发布者。
教程的例子:手机、电脑先订阅了主题temperature;温度传感器向temperature发布一条内容为21℃的消息;Broker 收到后查出有哪些客户端订阅了该主题,再把消息转发给它们。
3.2 Topic 主题与通配符
Topic(主题)是消息传递的基础,代表消息的流向;本质是一串字符串,可以用正斜杠/分级。
- 订阅
/编程知识/嵌入式/C语言,只会收到这个主题的消息;别人发的是/编程知识/嵌入式/C++,因为主题不匹配,收不到; - 通配符(只能用于订阅,不能用于发布):
+只能匹配当前一层,例如/编程知识/+/C语言;#能匹配往下所有层,只能出现在末尾,例如订阅/编程知识/嵌入式/#,可以收到/编程知识/嵌入式/C++、/编程知识/嵌入式/C、/编程知识/嵌入式/C/函数指针用法等。
教程口诀:
+只能匹配当前一层,#能匹配往下所有。注意:
/test/topic1和test/topic1是两个不同的主题——开头带/意味着第一个层级是空的。发布和订阅两边的字符串必须一字不差(大小写敏感),本工程用的就是带前导斜杠的/test/topic1、/test/topic2。
3.3 MQTT 报文长什么样
每个报文 =固定包头 + 可变包头 + 载荷(后两部分不一定有,由报文类型决定)。
固定包头第 1 字节:
| 位 | 含义 |
|---|---|
| 7~4 | 报文类型(1~14) |
| 3~0 | 该报文的特殊标志位 |
对PUBLISH报文,标志位是最有用的:bit0 = RETAIN、bit1~2 = QoS、bit3 = DUP。其余报文大多填 0000,SUBSCRIBE/UNSUBSCRIBE/PUBREL 固定填 0010;接收方收到不可用的标志位,必须关闭连接。
- DUP:发布消息的副本标志,置 1 表示这是一次重发(只对 QoS>0 的消息有意义),用于保证可靠传输,但不能用来检测消息是否重复;
- QoS:发布消息的服务质量,见 3.8;
- RETAIN:置 1 时 Broker 会为该主题保存这条消息,新订阅者一订阅就立刻收到,见 3.9。
从第 2 字节开始是剩余长度:可变包头 + 载荷的总长度,最多允许 4 个字节。每个字节只有低 7 位有效,最高位起"延续位"作用(为 1 表示后面还有长度字节),所以最后一个字节最大只能是0x7F(127);4 个字节能表示的最大长度是 268 435 455 字节。
教程里的解码例子,三个字节0xE7 0x8D 0x11:
第1字节 0xE7:最高位=1,取低7位 0x67 第2字节 0x8D:最高位=1,取低7位 0x0D 第3字节 0x11:最高位=0,长度结束,取低7位 0x11 剩余长度 = 0x67 + (0x0D << 7) + (0x11 << 14) = 103 + 1664 + 278528 = 2802953.4 报文类型一览
| 编号 | 报文 | 作用 |
|---|---|---|
| 1 | CONNECT | 客户端请求连接 |
| 2 | CONNACK | 服务端确认连接 |
| 3 | PUBLISH | 发布消息 |
| 4 | PUBACK | QoS1 发布确认 |
| 5 | PUBREC | QoS2:发布收到 |
| 6 | PUBREL | QoS2:发布释放 |
| 7 | PUBCOMP | QoS2:发布完成 |
| 8 | SUBSCRIBE | 订阅主题 |
| 9 | SUBACK | 订阅确认 |
| 10 | UNSUBSCRIBE | 取消订阅 |
| 11 | UNSUBACK | 取消订阅确认 |
| 12 | PINGREQ | 心跳请求 |
| 13 | PINGRESP | 心跳响应 |
| 14 | DISCONNECT | 客户端主动断开 |
教程里这张表以图片给出,正文没有编号。上表按 MQTT 3.1.1 标准补充,方便对照抓包与理解后文。
3.5 CONNECT / CONNACK:建立连接
在发布、订阅之前,客户端必须先发CONNECT报文,Broker 验证信息后回CONNACK告知结果,得到肯定回应才能进行后续操作。
CONNECT 的可变包头由协议名、协议等级、连接标志、Keep Alive 秒数构成;载荷按固定顺序排列:客户端标识符、遗嘱主题、遗嘱消息、用户名、密码(只有连接标志里指示存在的字段才会出现)。
连接标志里的关键位:
| 标志 | 含义 |
|---|---|
| Clean Session | 0 = 建立持久会话,Broker 保存该客户端的订阅和未接收消息;1 = 不保存,并在连接时清除该客户端之前保存的会话数据 |
| Will Flag | 是否使用遗嘱消息 LWT |
| Will QoS | 发布遗嘱消息时的 QoS |
| Will Retain | 遗嘱消息的 Retain 标识 |
| User Name / Password | 载荷中是否带用户名、密码 |
CONNACK 的可变包头只有两个字节:连接确认标志 + 返回码。当 CONNECT 中 Clean Session=1 时,确认标志里的"当前会话标志"(Session Present)为 0。
返回码的含义:
| 返回码 | 含义 |
|---|---|
| 0x00 | 连接接受 |
| 0x01 | 不支持的协议版本 |
| 0x02 | 客户端标识符被拒绝 |
| 0x03 | 服务器不可用 |
| 0x04 | 用户名或密码错误 |
| 0x05 | 未授权 |
调试思路:如果 ESP32 反复连不上 Broker,抓包看到 CONNACK 返回 0x04/0x05,就是账号密码问题而不是网络问题。
3.6 Keep Alive 心跳保活
TCP 连接"看起来还在",不代表对方还活着(拔网线、设备断电时,本机 TCP 不会立刻察觉)。Keep Alive 用来检测这种"半死"连接:
- 建连接时客户端在 CONNECT 里承诺一个时间(秒),比如 60 秒;
- 空闲(没有其他报文)时,客户端按这个周期发送PINGREQ,Broker 用PINGRESP应答;
- Broker 如果在1.5 倍 Keep Alive时间内没有收到该客户端的任何报文,就认为它掉线,关闭连接——如果客户端注册过遗嘱消息,这时就会发布遗嘱(见 3.7)。
在 ESP-IDF 中对应session.keepalive,默认120 秒,并且客户端会按大约一半的时间主动通信,留出重试余地;心跳由 esp-mqtt 组件后台自动完成,应用代码不用管。本工程没有显式配置,用的就是默认值。
小坑:在 esp-mqtt 里把
session.keepalive设为 0 并不会关闭心跳,而是回退到默认值;真想关闭要用disable_keepalive。Keep Alive 设得越长,Broker 判定掉线越慢,遗嘱消息也就越晚发出。
3.7 遗嘱消息 Will(LWT)
设备可能突然断电、断网,自己没机会说"我下线了"。遗嘱消息就是提前写好交给 Broker 的遗言:
- 连接时(Will Flag=1)把"遗嘱主题 + 遗嘱内容 + Will QoS + Will Retain"告诉 Broker;
- 当 Broker 检测到客户端非正常断开(心跳超时、TCP 异常断开)时,代替客户端把这条消息发布到遗嘱主题;
- 如果客户端主动发 DISCONNECT 正常断开,Broker 不会发布遗嘱;
- 典型用法:设备上线时在
/status主题发布online,同时把遗嘱设为在/status发布offline,订阅者就能实时知道设备掉线。
3.8 QoS:三个消息等级
| QoS | 语义 | 消息流程 | 特点 |
|---|---|---|---|
| 0 | 接收方最多收到一次 | PUBLISH | 最快,无确认,可能丢消息;适合高频、可丢的数据 |
| 1 | 接收方最少收到一次 | PUBLISH → PUBACK | 不丢,但对方没回 PUBACK 就重发,可能收到重复 |
| 2 | 接收方只能收到一次 | PUBLISH → PUBREC → PUBREL → PUBCOMP | 最可靠,四次握手开销最大;适合计费、开关控制等 |
MQTT 还规定:消息最终按"发布 QoS 和订阅 QoS 中较小的那个"投递。例如发布用 QoS2、订阅用 QoS1,最终按 QoS1 交付。所以 QoS 需要发布、订阅两端配合设置,而不是"订阅端写得高就一定更可靠"。
3.9 RETAIN 保留消息
- PUBLISH 报文里 RETAIN=1 时,Broker 会为该主题保存最后一条retained 消息;
- 之后有新的订阅者订阅该主题,Broker 会立即把这条消息发给它,不用等下一次发布;
- 常与遗嘱配合:设备状态用 retained 消息发布,新接入的观察者一订阅就能看到当前状态,而不是干等状态变化。
3.10 这些概念在 ESP-IDF 里对应什么
第 19 节工程的配置里,本节的概念都能找到对应字段(以 ESP-IDF 5.x 的esp_mqtt_client_config_t分组结构为准):
| MQTT 概念 | ESP-IDF 配置字段 / 使用位置 |
|---|---|
| Broker 地址与端口 | broker.address.uri、broker.address.port |
| 客户端 ID | credentials.client_id |
| 用户名 / 密码 | credentials.username/credentials.authentication.password |
| QoS | 发布:esp_mqtt_client_publish(..., qos, ...);订阅:subscribe 的参数 |
| RETAIN | esp_mqtt_client_publish的 retain 参数 |
| Keep Alive | session.keepalive(默认 120 秒) |
| 遗嘱消息 | session.last_will.topic/.msg/.qos/.retain |
| Clean Session | session.disable_clean_session(默认 false,即使用 Clean Session) |
本工程(
esp32-board/mqtt)只显式配置了前三行——地址、客户端 ID、用户名密码;QoS 和 RETAIN 在调用发布/订阅 API 时传入;Keep Alive、遗嘱、Clean Session 都没写,走组件默认值。另外:ESP-IDF 4.x 的配置字段是扁平写法(
.uri、.client_id、.keepalive、.lwt_topic),5.x 才改成分组结构。对照网上老教程抄代码时,先确认自己用的版本。