bash上一篇 SPI 是板级高速总线,这篇 MQTT 是万物互联的"普通话"。 如果说 I2C/SPI 让你点亮了传感器,MQTT 让你把传感器数据送到全球任何人的手机上。
学完本文,你将能用 STM32 + ESP8266 把 DHT11 温湿度推送到阿里云 / OneNET / 私有 EMQX 服务器,并在手机 APP 上实时看到。
1. 为什么物联网离不开 MQTT?
1.1 HTTP vs MQTT
想象你要每3秒上报一次温湿度:
-
HTTP: 每次都要 TCP握手 -> 发请求头(几百字节)-> 服务器响应 -> 断开,开销巨大,像每次发信都要重新写信封、贴邮票、跑一趟邮局。
-
MQTT: 一次连接后长连接保持,报文最小 2字节,像打电话,接通后一直说"25度""26度"就行。
| 特性 | HTTP | MQTT |
|---|---|---|
| 模型 | 请求/响应,客户端主动拉 | 发布/订阅,Broker中介解耦 |
| 报文开销 | 大,Header 几百字节 | 极小,固定头2字节 |
| 实时性 | 需轮询,假实时 | 真推送,订阅即收到 |
| 流量 | 高,不适合2G/4G | 低,专为不稳定网络设计 |
| 适用 | 网页、API | 物联网、低带宽、电池供电 |
1.2 核心概念:发布/订阅 + Broker 中转

-
Broker (代理/服务器): 中心人,如 EMQX, Mosquitto, 阿里云物联网平台。所有消息都经它转发。
-
Publisher (发布者): STM32传感器,发数据的人。
-
Subscriber (订阅者): 手机APP、另一个STM32,收数据的人。
-
Topic (主题): 消息的地址,有层级,如
sensor/dht11/temp、home/led/cmd。发布订阅都靠它匹配。 -
ClientID: 设备唯一身份,Broker靠它踢掉重复登录的设备。绝对不能重复!
解耦的魅力: 发布者根本不认识订阅者,只认识Broker和Topic。于是可以轻松实现 1对多、多对1广播。
Topic 通配符(订阅时可用)
-
+单层通配:sensor/+/temp可匹配sensor/dht11/temp和sensor/bmp280/temp,但不匹配sensor/dht11/room/temp -
#多层通配:sensor/#匹配所有 sensor 开头的所有层级,必须放最后。
2. 报文结构:MQTT的"信封格式"
所有MQTT报文都由三部分组成:Wireshark 抓包必看。

2.1 Fixed Header 固定头(所有报文都有)
-
Byte1: 高4位=报文类型,低4位=标志位(QoS, Retain, DUP)
-
剩余长度: 1-4字节,用变长编码。
14种报文类型:
| 值 | 报文 | 方向 | 作用 |
|---|---|---|---|
| 1 | CONNECT | 客户端->Broker | 请求连接 |
| 2 | CONNACK | Broker->客户端 | 连接确认 |
| 3 | PUBLISH | 双向 | 发布消息 |
| 4 | PUBACK | 双向 | QoS1 确认 |
| 5 | PUBREC | 双向 | QoS2 第一步 |
| 6 | PUBREL | 双向 | QoS2 第二步 |
| 7 | PUBCOMP | 双向 | QoS2 第三步 |
| 8 | SUBSCRIBE | 客户端->Broker | 订阅主题 |
| 9 | SUBACK | Broker->客户端 | 订阅确认 |
| 10 | UNSUBSCRIBE | 客户端->Broker | 取消订阅 |
| 11 | UNSUBACK | Broker->客户端 | 取消确认 |
| 12 | PINGREQ | 客户端->Broker | 心跳请求 |
| 13 | PINGRESP | Broker->客户端 | 心跳响应 |
| 14 | DISCONNECT | 客户端->Broker | 优雅断开 |
最小报文 PINGREQ 只有 2字节 : 0xC0 0x00
2.2 Variable Header 可变头
CONNECT 报文的可变头最复杂,10字节:
bash00 04 4D 51 54 54 04 C2 00 3C | | | | |____|____ KeepAlive 60秒 (0x003C) | | | | 连接标志 C2 = 1100 0010 = UserName + Password + CleanSession | | | 协议级别 04 = v3.1.1 | | MQTT 字符串 | 长度 4
CONNECT 连接标志位(最容易错):
-
Bit1 CleanSession:1=清理会话(常用),0=保留离线消息
-
Bit2 Will Flag:是否启用遗嘱
-
Bit3-4 Will QoS
-
Bit5 Will Retain
-
Bit6 Password Flag
-
Bit7 UserName Flag
常见 0xC2 = 11000010 = 清理会话+用户名+密码,无遗嘱。
2.3 Payload 有效载荷
按序出现:ClientID + Will Topic + Will Message + UserName + Password,每个字段前面 2字节大端长度。
所以一条完整的 CONNECT 报文例子是:10 25 00 04 4D 51 54 54 04 C2 00 3C 00 09 31 32 33... 也就是 AT指令背后ESP8266真正发的东西。
3. 核心机制:QoS、保留、遗嘱、心跳

3.1 QoS 0/1/2 服务质量等级
这是 MQTT 的灵魂,选错等级导致数据丢失或重复。
-
QoS0 At most once 最多一次(0): 发就完了,不管到没到。像发微信不看对方是否已读。优点:最快最省流量,适合高频传感器数据。
bashPublisher -> Broker (一条 PUBLISH 就结束) -
QoS1 At least once 至少一次(1): 保证至少到一次,可能重复。发 -> 等PUBACK确认,没等到就重发。像寄快递要签收。
bashPublisher -> PUBLISH -> Broker -> PUBACK -> Publisher适合: 控制指令,如
开灯,宁可重复开一次,也不能不开。 -
QoS2 Exactly once 只有一次(2): 最可靠,4步握手保证不丢不重,但开销大。
bashPublisher -> PUBLISH -> Broker Broker -> PUBREC -> Publisher Publisher -> PUBREL -> Broker Broker -> PUBCOMP -> Publisher适合: 计费、支付等绝对不能重复的场景。物联网一般不用。
实战建议: 90%场景:传感器上行用QoS0,控制下行用QoS1。别迷信QoS2,费电又费流量。
3.2 Retain 保留消息
普通消息:订阅者只有在消息发布后在线才能收到,离线期间的消息丢失。
保留消息:Broker 会保留该 Topic 的最后一条 retained 消息,新订阅者一订阅,立刻收到最后一条。
典型应用: 设备状态 device/led/status 用 retain=1 发布 ON,手机APP一上线就能立刻知道灯是开的,不用等下一次发布。
3.3 LWT 遗嘱机制 (Last Will and Testament)
最实用的离线检测机制!
设备连接时在 CONNECT 中设置:
-
Will Topic: device/sensor-01/status -
Will Message: {"status":"offline"} -
Will QoS: 1, Retain: 1
正常断开: 设备发 DISCONNECT 优雅下线,Broker 删除遗嘱,不触发。
异常断开: 断网、没电、没发心跳。Broker 在 1.5倍 KeepAlive 时间后没收到 PING,判断设备挂了,自动发布遗嘱消息!
组合拳实现在线监测:
bash
// 1. CONNECT时设置遗嘱 offline (retain=1)
// 2. 连接成功后,立刻发布 online retain=1 覆盖掉遗嘱的离线
PUBLISH topic=device/01/status retain=1 payload={"status":"online"}
// 3. 现在:在线时 retain=online,任何人订阅都能看到 online
// 异常离线时 Broker 自动发 offline 覆盖,订阅者收到 offline
3.4 KeepAlive 心跳
客户端告诉 Broker:我每 KeepAlive 秒至少发一次报文(哪怕发个 PINGREQ)。如果 1.5 * KeepAlive 没收到,Broker 判它掉线,踢掉并触发遗嘱。
STM32上建议 60秒,太短费流量,太长离线检测慢。
4. STM32 三种主流实现方案

| 方案 | 硬件 | 协议栈 | 难度 | 成本 | 推荐 |
|---|---|---|---|---|---|
| 方案A ESP8266 AT指令 | F103C8T6 + ESP-01S | AT指令,STM32只需串口收发 | ⭐最简单 | 低 | 入门首选,本文重点 |
| 方案B LWIP+Paho | F407 + 以太网PHY | LWIP + 开源Paho MQTT | ⭐⭐⭐ | 中 | 商业产品,稳定 |
| 方案C EC20 4G Cat1 | 任意STM32 + EC20 | AT指令 MQTT | ⭐⭐ | 高 | 无WiFi场景 |
5. 实战一:STM32F103 + ESP8266 AT指令 接入 EMQX / OneNET(最经典)
这是正点原子、野火教程的经典方案,代码量极小。
5.1 硬件接线
bash
ESP8266 STM32F103
3V3 ---- 3.3V (不能5V!)
GND ---- GND
RX ---- PA2 (USART2_TX) 注意交叉!
TX ---- PA3 (USART2_RX)
RST ---- PA4 GPIO控制复位 (可选)
EN ---- 3.3V
5.2 ESP8266 MQTT AT流程

1. 基础AT测试
bashAT // 响应 OK AT+CWMODE=1 // Station模式 AT+CWJAP="Xiaomi","12345678" // 连WiFi,等待 GOT IP
2. MQTT配置(新版AT固件 1.7+)
bash// 配置ClientID, 用户名密码(OneNET/阿里云需要token算法生成,这里以EMQX公共服务器为例) AT+MQTTUSERCFG=0,1,"stm32_001","user","password",0,0,"" // 连接Broker,HiveMQ公共测试服务器 AT+MQTTCONN=0,"broker.hivemq.com",1883,0 // 订阅命令下行主题 AT+MQTTSUB=0,"led/cmd",1 // 发布温湿度 AT+MQTTPUB=0,"sensor/dht11","{\"temp\":25.6,\"humi\":60}",0,0
3. STM32 端代码封装(HAL库状态机+环形缓冲,生产级)
参考了开源项目最佳实践,解决AT响应格式不统一、丢数据问题:1
bash// mqtt_esp8266.h #define WIFI_SSID "Xiaomi" #define WIFI_PASS "12345678" #define MQTT_BROKER "broker-cn.emqx.io" #define MQTT_CLIENTID "stm32_001_%06X" // 用芯片ID保证唯一! typedef enum { MQTT_STATE_IDLE=0, MQTT_STATE_WIFI_OK, MQTT_STATE_MQTT_CONNECTED, MQTT_STATE_SUB_OK } mqtt_state_t; HAL_StatusTypeDef ESP8266_SendCmd(char *cmd, char *expect, uint32_t timeout_ms) { HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), 1000); // 非阻塞等待 expect,实际应使用 环形缓冲+状态机解析,这里简化用轮询 uint32_t tick = HAL_GetTick(); while(HAL_GetTick()-tick < timeout_ms) { if(strstr((char*)usart2_rx_buf, expect)) return HAL_OK; HAL_Delay(10); } return HAL_TIMEOUT; } void MQTT_Init(void) { printf("AT Reset...\r\n"); ESP8266_SendCmd("AT+RST\r\n","ready",3000); HAL_Delay(2000); ESP8266_SendCmd("AT\r\n","OK",1000); ESP8266_SendCmd("AT+CWMODE=1\r\n","OK",1000); char cmd[128]; sprintf(cmd,"AT+CWJAP=\"%s\",\"%s\"\r\n",WIFI_SSID,WIFI_PASS); ESP8266_SendCmd(cmd,"WIFI GOT IP",10000); sprintf(cmd,"AT+MQTTUSERCFG=0,1,\"%s\",\"%s\",\"%s\",0,0,\"\"\r\n", MQTT_CLIENTID,"username","password"); ESP8266_SendCmd(cmd,"OK",2000); sprintf(cmd,"AT+MQTTCONN=0,\"%s\",1883,0\r\n",MQTT_BROKER); if(ESP8266_SendCmd(cmd,"OK",5000)==HAL_OK) printf("MQTT Connected!\r\n"); } void MQTT_Publish(char *topic, char *payload) { char cmd[256]; // qos0 retain0 snprintf(cmd,sizeof(cmd),"AT+MQTTPUB=0,\"%s\",\"%s\",0,0\r\n",topic,payload); HAL_UART_Transmit(&huart2,(uint8_t*)cmd,strlen(cmd),1000); } void MQTT_Subscribe(char *topic) { char cmd[128]; snprintf(cmd,sizeof(cmd),"AT+MQTTSUB=0,\"%s\",1\r\n",topic); HAL_UART_Transmit(&huart2,(uint8_t*)cmd,strlen(cmd),1000); } // 在串口中断回调中解析下行:+MQTTSUBRECV:0,"led/cmd",6,ON void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if(huart==&huart2) { if(strstr((char*)rx_buf,"+MQTTSUBRECV")) { // 简易解析,生产环境用 cJSON char *p = strstr((char*)rx_buf,"led/cmd"); if(p) { if(strstr((char*)rx_buf,"ON")) HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_RESET); else HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_SET); } } HAL_UART_Receive_IT(&huart2,&rx_byte,1); } } int main(void) { // ... HAL Init MQTT_Init(); MQTT_Subscribe("led/cmd"); while(1) { int temp=25, humi=60; // 实际从DHT11读取 char json[128]; sprintf(json,"{\"id\":\"123\",\"params\":{\"temp\":{\"value\":%d},\"humi\":{\"value\":%d}}}",temp,humi); MQTT_Publish("sensor/data", json); HAL_Delay(5000); } }
5.3 接入 OneNET / 阿里云的关键区别
-
OneNET 新版MQTT: 用户名是
产品ID,密码是 Token(需用token工具根据product_id + 设备名 + key计算),ClientID 是设备名。Topic 必须是$sys/{pid}/{device}/thing/property/post投稿格式。 -
阿里云: 三元组
ProductKey DeviceName DeviceSecret用 HMAC-SHA256 算出clientId, username, password。官方有MQTT_Password生成小工具。Topic 为/sys/{ProductKey}/{DeviceName}/thing/event/property/post -
建议: 开发期用 EMQX 公共 Broker
broker.emqx.io:1883联调,跑通后再切阿里云/OneNET,省去鉴权烦恼。
6. 实战二:高级版 STM32F407 + LWIP + Paho MQTT(裸机协议栈)
如果你用 F407/F767 自带以太网,或想更深入理解MQTT报文,可用开源 Paho MQTT Embedded-C。
它只有4个文件:
MQTTPacket, MQTTClient, MQTTConnect, MQTTSubscribe
bash// 参考封装 [5](https://www.cnblogs.com/jgijue/p/19679044) // 网络层用 LWIP socket 封装 int transport_sendPacketBuffer(int sock, unsigned char* buf, int buflen) { return lwip_send(sock, buf, buflen, 0); } // 初始化 MQTTClient mqttClient; Network n; MQTTPacket_connectData data = MQTTPacket_connectData_initializer; unsigned char buf[1024], readbuf[1024]; NetworkInit(&n); NetworkConnect(&n, "broker.emqx.io", 1883); MQTTClientInit(&mqttClient, &n, 1000, buf, 1024, readbuf, 1024); data.clientID.cstring = "stm32f407_client"; data.keepAliveInterval = 60; data.cleansession = 1; data.username.cstring = "user"; data.password.cstring = "pass"; data.willFlag = 1; data.will.topicName.cstring = "device/status"; data.will.message.cstring = "offline"; data.will.retained = 1; MQTTConnect(&mqttClient, &data); // 发布 MQTTMessage msg; msg.qos = QOS1; msg.retained = 0; msg.payload = "{\"temp\":26}"; msg.payloadlen = strlen(msg.payload); MQTTPublish(&mqttClient, "sensor/data", &msg); // 订阅 MQTTSubscribe(&mqttClient, "control/led", QOS1, messageArrived); void messageArrived(MessageData *md) { printf("Recv: %.*s\n", md->message->payloadlen, (char*)md->message->payload); }此方案可移植 FreeRTOS,中断+消息队列,适合商用。
7. 调试神器:MQTTX + Wireshark
-
MQTTX:EMQX 官方出的跨平台客户端,比 MQTT.fx 好用100倍,可同时模拟发布和订阅,看 retained / qos 效果。
-
Wireshark 抓包: 过滤
mqtt,直接看 2字节包头 + 报文类型,心跳是否正常一眼看清。 -
EMQX Dashboard:
broker.emqx.io:18083可看在线客户端、订阅关系。
8. 排坑指南:血泪总结
-
ClientID 重复导致互踢: 两个板子用同一个
stm32_client,结果疯狂掉线。解决:ClientID = 设备MAC/芯片ID,唯一! -
AT固件版本太旧不支持 MQTT:
AT+MQTT返回 ERROR。解决:去安信可官网烧录ESP8266 AT v1.7+ MQTT固件。 -
OneNET/阿里云 鉴权失败 CONNACK=5: Token算错、时间戳不对。解决:用官方小工具动态生成,别手算。
-
订阅收不到?
-
QoS不匹配?没事,都能收到,只是降级。
-
主题写错大小写?
-
是否订阅后立刻发布?Broker 转发有 10-100ms 延迟,测试时加延时。
-
-
内存溢出:
sprintf拼 JSON 时 128字节缓冲区溢出导致 HardFault。改用snprintf并检查返回值。 -
KeepAlive 内没发 PING: 60秒内既没Pub也没Sub,Broker踢掉。解决:空闲时在主循环定时发
AT+MQTTPING或MQTTClient_yield保持心跳。 -
WiFi断了MQTT不会自动重连: ESP8266 AT版掉WiFi后不会自动连MQTT。必须在代码里检测
WIFI DISCONNECT,状态机回到AT+CWJAP重连。
9. 总结与下篇预告
-
MQTT 本质:轻量级发布/订阅 + Broker中转
-
保命三件套:QoS(可靠性) + Retain(状态记忆) + LWT(掉线感知)
-
STM32 接入:新手用 ESP8266 AT 版 30分钟跑通,上量用 F4+LWIP+Paho
参考与工具
-
官方文档《MQTT具体报文格式解析》- 报文结构详解 3
-
工程实践《STM32+ESP8266+Android基于MQTT三端通信》- 状态机+环形缓冲最佳实践 1
-
OneNET/阿里云 STM32 AT指令接入教程 2
-
工具:MQTTX 下载 | EMQX公共Broker
本文所有配图原创,代码基于 HAL 库亲测 F103C8T6 + ESP-01S 可用。