这是我做 STM32F411 + ESP-12F 跑通 MQTT 上云之后的一次系统复盘。上一篇讲了「网络地基 → MQTT 模型 → AT 指令遥控」三层逻辑,有同学说想要能直接抄走跑的代码。这篇我把全套实战代码 补上:
mqtt.h头文件、mqtt.c逐函数实现(每步为什么这么写都有注释)、main.c调用流程,外加一节「把光照 ADC 采集值发到云端」的进阶改造。先打地基、再讲模型、然后逐行过代码,最后附上实际咬过我的易错点清单。
前言:
这篇文章是我做 STM32F411 + ESP-12F 跑通 MQTT 上云之后的一次系统复盘。
很多同学问我:"为什么我代码写得和你一样,就是连不上?"
我的回答通常是:"你写代码前,先用串口助手手动发过指令吗?"
嵌入式联网开发有一条铁律:先手动确立连通性,再写自动化代码。
这篇不仅包含完整的 mqtt.c 实战代码,更要把这个"避坑心法"讲透。
第零步:磨刀不误砍柴工------先手动跑通 AT 指令(关键!)
在打开 Keil 写代码之前,强烈建议你先拿出一个串口调试助手(如安信可、SSCOM),直接连上 ESP-12F,手动发指令测试。
为什么要这么做?
- 验硬件:确认 TX/RX 没接反,波特率(115200)正确,模块没坏。
- 验环境:确认 WiFi 密码对、路由器能上网、MQTT 服务器地址在当前网络下可达。
- 定逻辑 :亲眼看到发完哪条指令回
OK,发完哪条回WIFI CONNECTED。这能让你写代码时心里非常有底。
我的测试记录(附截图)
这是我跑代码前的真实测试界面。你可以看到,我是严格按照顺序点击发送的:

解读这张图里的"通关密码":
- 第 3 步
AT+RST:重启模块,确保状态干净。 - 第4步AT+CWMODE:设置WiFi模式。
- 第 6 步
AT+CWJAP:连 WiFi。注意看,如果之前连过,它可能直接回OK而不回WIFI CONNECTED,这点我在代码里做了兼容。 - 第 7 步
AT+MQTTUSERCFG:配置客户端 ID(ESP12F_2604_HH),这是你的身份证,必须唯一。 - 第 8 步
AT+MQTTCONN:连服务器。看到OK或+MQTTCONNECTED才算成功。 - 第 9/10 步
SUB/PUB:订阅和发布。
结论: 当你在串口助手里能完整走完这一套流程,并且收到预期的回复时,恭喜你,硬件和网络环境已经 100% 没问题了。 接下来写代码,就只是把这套动作"翻译"成 C 语言而已。
一、先打地基:上云之前必须想明白的网络常识
ESP-12F 联网,本质是让一块 MCU 借模组的 WiFi 接入 TCP/IP 网络,所以第一个绕不开的是 IP 地址。
1.1 IP = 网络号 + 主机号
IPv4 是 32 位地址,点分十进制书写(如 202.38.64.10),底层是 32 位无符号整数。二级划分:
- 网络号 :判断两台设备是否在同一网段(局域网)。相同可直接二层互访,不同必须走网关。
- 主机号:本网段内唯一标识一台设备,同一局域网不能重复。
按首字节分五类:A 类(1126)、B 类(128191)、C 类(192223)、D 类组播(224239)、E 类保留。嵌入式设备拿到的基本是 C 类私有地址。
1.2 几个必须记住的特殊地址
0.0.0.0:服务端监听它 = 监听本机所有网卡 IP;- 主机号全 0(
192.168.3.0):代表这个网络本身; - 主机号全 1(
192.168.3.255):该网段广播地址; 127.0.0.1:回环地址,发往它的数据包在本机 loop back。
1.3 私有地址 vs 公有地址------这是理解「上云」的关键
内网私有段只有三段:10.0.0.0~10.255.255.255、172.16.0.0~172.31.255.255、192.168.0.0~192.168.255.255。三条铁律:
- 公有 IP 不能直接访问私有 IP,反之亦然;
- 私有 IP 之间互访,必须网络号相同;
- 私有设备上公网靠路由器做 NAT,把内网地址映射成公网地址出去。
这直接解释了代码里最关键的现象 :板子连家里 WiFi(私有 192.168.x.x)却能连公网 broker.emqx.io------不是设备被公网直接访问,而是我主动从内网向外发起 TCP 连接,经路由器 NAT 出去。这也回答了「两个不同局域网设备 A、B 怎么通信」:都去连公网上的中转服务器(MQTT Broker),由 Broker 转发。这正是 MQTT 架构存在的网络学理由。
1.4 TCP 还是 UDP?MQTT 选了 TCP
| 协议 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|
| TCP | 有连接、可靠:确认/重传/CRC,不丢不重不乱序 | 慢、开销大 | 表单提交、即时消息、文件传输 |
| UDP | 快、轻 | 不可靠、易丢包 | 视频通话、直播 |
MQTT 构建在 TCP/IP 之上,天生继承 TCP 可靠性,再在应用层用 QoS 补一层消息级保证。
1.5 顺带:虚拟机 NAT/桥接 与 ping
- NAT 模式 :虚拟机 IP 由宿主虚拟 DHCP 分配,网段不定甚至撞 IP,虚拟机间默认不能互访;
- 桥接模式:IP 由真实路由器分配,地位与宿主机平级------联网实验用真实局域网身份就选桥接;
- ping :测连通、由域名解析 IP、看 TTL 估算跳数(每过一路由器减 1,到 0 丢包)。连不上网第一步永远是同 WiFi 的电脑
ping broker.emqx.io,先排除环境问题。
二、中间层:MQTT 的发布/订阅模型
MQTT(消息队列遥测传输)是 IBM 1999 年提出、构建在 TCP/IP 上的轻量级发布/订阅 协议,专为低带宽、不稳定网络的物联网场景设计。它和写 Web 服务器最大的不同:Broker 只负责转发,不负责存储业务数据和应答处理,重点是订阅、发布、转发过程。
三个角色:代理 Broker (中心服务器,做消息路由,如 EMQX/Mosquitto/broker.emqx.io)、发布者 Publisher (往某 Topic 发消息)、订阅者 Subscriber (注册对某 Topic 的兴趣)。发布订阅者彼此不直连,全靠 Topic 字符串匹配、Broker 撮合------这就是它能解决「两个局域网设备通信」的原因。
| 概念 | 含义 | 我项目取值 |
|---|---|---|
| Topic 主题 | 消息频道,字符串匹配,支持 / 分层 |
2604/topic-HH(自发自收同一主题) |
| QoS 服务质量 | 0 最多一次 / 1 至少一次 / 2 恰好一次 | 1(至少一次,可能重复) |
| Retain 保留消息 | 置 1 时 Broker 存最后一条,新订阅者上线即收到 | MQTTPUB 末位 0,不保留 |
| Client ID | Broker 区分设备的唯一标识,重复会互踢 | ESP12F_2604_HH |
| keepalive 心跳 | 维持 TCP 长连接的探活间隔 | 由模组固件维持 |
调试阶段可先用 MQTT.fx 当「假设备」:填 Broker + 端口(1883,TLS 是 8883)+ 唯一 Client ID 连上,右侧变绿即成功;再开第二个客户端,一个订阅、一个发布,肉眼验证消息在 Broker 转发------先确认协议和服务器没问题,再怀疑单片机代码。
三、最上层:STM32 用 AT 指令「遥控」ESP-12F
架构职责先分清------MQTT 协议栈整个跑在 ESP-12F 里 (ESP8266 固件已内置 TCP/IP + LwIP + MQTT 3.1.1)。STM32 自己压根不碰网络,它只是个通过串口发 AT 指令的遥控器。
STM32(F411) --USART2/AT指令--> ESP-12F --WiFi--> 路由器 --NAT/公网--> Broker(broker.emqx.io:1883)
| ^ |
+------USART1 打印<-----------+<------- 订阅回推 +MQTTSUBRECV ----------+
整条工程 = 一条主线 + 一个统一模式 :每条指令都是「发指令 → 收应答 → strstr 找关键字」三件套。下面直接上全套代码。
四、实战代码(完整可编译)
4.1 头文件 mqtt.h
把对外接口集中在头文件,main.c 只需要包含它即可。
#ifndef __MQTT_H
#define __MQTT_H
#include "stm32f4xx_hal.h"
#include <stdint.h>
/* ===== 对用户开放的接口 ===== */
uint8_t Mqtt_Server_Init(void); // 一键上云:上电延时→关回显→连WiFi→配MQTT→连Broker→订阅
void outputMsg(void); // 主循环:周期性发布 + 打印云端回推
uint8_t MqttSub(void); // 订阅主题(也用于断线后重新订阅)
#endif
注意
Mqtt_Server_Init返回类型要写一致(原代码声明带参数、定义不带,编译会告警);这里统一为uint8_t Mqtt_Server_Init(void)。
4.2 实现文件 mqtt.c(逐函数完整实现)
/**
*******************************************************
* 文件名称 : mqtt.c
* 作 者 : huanghuai
* 版 本 : V1.0
* 编写日期 : 2026.10.13
* 功 能 : 通过 ESP-12F 完成 MQTT 发布/订阅
*******************************************************
*/
/* 包含头文件 ------------------------------------------ */
#include "stm32f4xx_hal.h"
#include "stdio.h"
#include "stdint.h"
#include "string.h"
#include "usart.h"
#include "mqtt.h"
/* 1. 宏定义区 ----------------------------------------- */
#define MQTT_SSID "7" // WiFi 名称(根据自己网络/热点设置)
#define MQTT_PWD "66668888" // WiFi 密码(根据自己网络/热点设置)
#define MQTT_BROKER "broker.emqx.io"
#define MQTT_PORT 1883
#define MQTT_CLIENTID "ESP12F_2604_HH" // 客户端ID:全局唯一!
#define MQTT_TOPIC "2604/topic-HH" // 自发自收的主题
/* 2. 全局变量区 --------------------------------------- */
// strstr 的返回值暂存(判断应答里是否含关键字)
char const *pos;
// 订阅消息共享缓冲区:MqttPub 收到的回推数据也转存到这里,由 outputMsg 统一打印
uint8_t subRecvBuf[256] = {0};
/* 3. 私有函数声明区 ----------------------------------- */
static uint8_t setEchoOff(void);
static uint8_t setWifiMode(uint8_t mode);
static uint8_t connectWifi(const char *ssid, const char *pwd);
static uint8_t setMqttPara(void);
static uint8_t MqttConn(void);
static uint8_t MqttPub(const char *msg);
/* 4. 全局函数定义区 ----------------------------------- */
// 一键上云:严格按「延时→关回显→WiFi→MQTT参数→连Broker→订阅」顺序
uint8_t Mqtt_Server_Init(void)
{
// ① 等 ESP-12F 上电启动完成。ESP 启动比 STM32 慢 1~2 秒,
// 启动期间串口吐日志,必须就绪后再发指令,否则首条指令被冲掉。
HAL_Delay(3000);
// ② 关回显:后续一切 strstr 判断可靠的前提(见易错点8)
setEchoOff();
// ③ 设 Station 模式,连接其他热点
setWifiMode(1);
// ④ 连接 WiFi
connectWifi(MQTT_SSID, MQTT_PWD);
// ⑤ 配置 MQTT 客户端身份
setMqttPara();
// ⑥ 连接 MQTT 服务器
MqttConn();
// ⑦ 订阅主题
MqttSub();
return HAL_OK;
}
// 主循环:周期性发布 + 打印云端回推
void outputMsg(void)
{
uint8_t retData[256] = {0};
uint8_t i = 0;
while (1)
{
// 发布一条消息(实际项目里把 msg 换成传感器数据即可)
MqttPub("Hello 2604-HH");
// 打印自发自收、在发布接收窗口内到达的回推数据
if (subRecvBuf[0] != 0)
{
printf("retData:%s\r\n", (char *)subRecvBuf);
memset(subRecvBuf, 0, 256);
}
// 持续轮询接收约 2 秒:20 个 100ms 小窗口,几乎不留「没人接收」空窗
for (i = 0; i < 20; i++)
{
HAL_UART_Receive(&huart2, retData, 256, 100);
if (retData[0] != 0)
{
printf("retData:%s\r\n", (char *)retData);
memset(retData, 0, 256);
}
}
}
}
// 订阅主题:AT+MQTTSUB=0,"topic",QoS
uint8_t MqttSub(void)
{
pos = 0;
uint8_t cmd[] = "AT+MQTTSUB=0,\"2604/topic-HH\",1\r\n";
uint8_t retData[64] = {0};
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
HAL_UART_Receive(&huart2, retData, 64, 1000); // 1秒超时,绝不无限等
pos = strstr((char *)retData, "OK");
if (pos) { printf("MqttSub : OK!!!\r\n"); return HAL_OK; }
else { printf("MqttSub : FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
/* 5. 私有函数定义区 ----------------------------------- */
// 关闭 ESP 指令回显。默认回显会把指令原样回发,一条占几十上百字节,
// 64字节缓冲区被回显塞满后真正的 OK 还没进来就超时返回,导致 strstr 误判。
static uint8_t setEchoOff(void)
{
uint8_t cmd[] = "ATE0\r\n";
pos = 0;
uint8_t retData[64] = {0};
uint8_t tryCnt = 0;
// 这是后续所有步骤的前提,失败最多重试3次(偶尔丢字节会回 ERROR)
for (tryCnt = 0; tryCnt < 3; tryCnt++)
{
memset(retData, 0, 64);
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
HAL_UART_Receive(&huart2, retData, 64, 1000);
pos = strstr((char *)retData, "OK");
if (pos) break;
}
if (pos) { printf("setEchoOff : OK!!!\r\n"); return HAL_OK; }
else { printf("setEchoOff : FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
// 设 WiFi 模式:1=Station(连别人热点)
static uint8_t setWifiMode(uint8_t mode)
{
pos = 0;
(void)mode; // 样例固定为1;要灵活可拼接成 AT+CWMODE=%d
uint8_t cmd[] = "AT+CWMODE=1\r\n";
uint8_t retData[64] = {0};
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
HAL_UART_Receive(&huart2, retData, 64, 1000);
pos = strstr((char *)retData, "OK");
if (pos) { printf("setWifiMode:OK!!!\r\n"); return HAL_OK; }
else { printf("setWifiMode:FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
// 连接 WiFi:AT+CWJAP="ssid","pwd"。连接慢,给 10 秒超时。
static uint8_t connectWifi(const char *ssid, const char *pwd)
{
pos = 0;
(void)ssid; (void)pwd; // 样例指令里固定写死;要灵活用 sprintf 拼接
uint8_t cmd[] = "AT+CWJAP=\"7\",\"66668888\"\r\n";
uint8_t retData[64] = {0};
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
HAL_UART_Receive(&huart2, retData, 64, 10000); // 连 WiFi 慢,10 秒
// 注意:已连过该热点时可能不再回 "WIFI CONNECTED",判断成功认 "OK"
pos = strstr((char *)retData, "OK");
if (pos) { printf("connectWifi : OK!!!\r\n"); return HAL_OK; }
else { printf("connectWifi : FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
// 配置 MQTT 客户端身份:AT+MQTTUSERCFG=linkID,ver,clientID,user,pwd,clean,ssl,willTopic
static uint8_t setMqttPara(void)
{
/*
参数:① 0 LinkID(固定0)
② 1 MQTT版本(1=3.1.1)
③ "ESP12F_2604_HH" 客户端ID(必须全局唯一!)
④ "" 用户名 ⑤ "" 密码
⑥ 0 不清除会话 ⑦ 0 不用SSL ⑧ "" 遗嘱主题(留空=不用)
*/
pos = 0;
uint8_t cmd[] = "AT+MQTTUSERCFG=0,1,\"ESP12F_2604_HH\",\"\",\"\",0,0,\"\"\r\n";
uint8_t retData[64] = {0};
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
HAL_UART_Receive(&huart2, retData, 64, 1000);
pos = strstr((char *)retData, "OK");
if (pos) { printf("setMqttPara : OK!!!\r\n"); return HAL_OK; }
else { printf("setMqttPara : FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
// 连接 Broker:AT+MQTTCONN=linkID,"host",port,reconnect。末位1=启用自动重连
static uint8_t MqttConn(void)
{
pos = 0;
uint8_t cmd[] = "AT+MQTTCONN=0,\"broker.emqx.io\",1883,1\r\n";
uint8_t retData[64] = {0};
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
HAL_UART_Receive(&huart2, retData, 64, 5000); // 连服务器慢,5秒
// 成功有两种应答:首次连接回 OK,重连场景可能直接回 +MQTTCONNECTED
if (strstr((char *)retData, "OK") || strstr((char *)retData, "+MQTTCONNECTED"))
{ printf("MqttConn : OK!!!\r\n"); return HAL_OK; }
else { printf("MqttConn : FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
// 发布消息:AT+MQTTPUB=linkID,"topic","msg",QoS,retain
static uint8_t MqttPub(const char *msg)
{
pos = 0;
// 用 msg 拼出发布指令(保留样例的固定主题)
uint8_t cmd[128] = {0};
sprintf((char *)cmd, "AT+MQTTPUB=0,\"%s\",\"%s\",1,0\r\n", MQTT_TOPIC, msg);
uint8_t retData[64] = {0};
HAL_UART_Transmit(&huart2, cmd, strlen((char *)cmd), HAL_MAX_DELAY);
// 发布是异步的:发完不立刻死等应答,回推数据由接收窗口自然到达
pos = strstr((char *)retData, "OK");
if (pos)
{
printf("MqttPub : OK!!!\r\n");
// 自发自收回推往往落在这次接收里,转存共享缓冲,由 outputMsg 统一打印
strncpy((char *)subRecvBuf, (char *)retData, 255);
return HAL_OK;
}
else { printf("MqttPub : FAIL,recv:%s\r\n", (char*)retData); return HAL_ERROR; }
}
4.3 主程序 main.c 调用流程
#include "main.h"
#include "mqtt.h"
int main(void)
{
HAL_Init();
SystemClock_Config(); // CubeMX 生成
MX_GPIO_Init();
MX_USART1_UART_Init(); // 调试打印口
MX_USART2_UART_Init(); // 接 ESP-12F,115200-8-N-1
// 若进阶要做传感器数据,这里再初始化 ADC:MX_ADC1_Init();
// 上电一键上云
Mqtt_Server_Init();
// 进入发布/接收主循环(内部 while(1))
outputMsg();
}
4.4 进阶:把光照 ADC 采集值发到云端(闭环改造)
把之前那个光敏电阻 ADC 项目接进来,让「Hello World」变成真正会报数的物联网终端。ADC 侧沿用 12 位满量程 4095、vref=3.3V:
// ADC 原始值转电压
static float adc_to_voltage(uint32_t adc_value, float vref)
{
return (adc_value * vref) / 4095.0f;
}
// 读一次光敏电阻电压
static uint32_t Read_Light_Sensor(void)
{
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 10);
return HAL_ADC_GetValue(&hadc1);
}
// 在 outputMsg 主循环里,把固定字符串换成实测数据:
void outputMsg_with_sensor(void)
{
uint8_t retData[256] = {0}, i = 0;
char payload[64];
while (1)
{
float v = adc_to_voltage(Read_Light_Sensor(), 3.3f);
sprintf(payload, "light=%.2fV", v); // 例如 light=1.87V
MqttPub(payload); // 发布光照电压到 2604/topic-HH
if (subRecvBuf[0] != 0) { printf("retData:%s\r\n",(char*)subRecvBuf); memset(subRecvBuf,0,256);}
for (i = 0; i < 20; i++)
{
HAL_UART_Receive(&huart2, retData, 256, 100);
if (retData[0] != 0) { printf("retData:%s\r\n",(char*)retData); memset(retData,0,256);}
}
}
}
这样 PC 端 MQTT.fx 只要订阅 2604/topic-HH,就能实时看到板子报上来的 light=x.xxV,一个「采集 → 上云 → 远程可视化」的最小闭环就成立了。
五、易错点清单(血泪复盘)
网络底层类
- 私有 IP 不能直接访问另一台私有 IP,两个局域网设备互通必须靠公网中转(Broker),别幻想用内网 IP 直连。
- 连不上网先
ping:同 WiFi 电脑ping broker.emqx.io,先排除环境(路由器没联网、密码错、外网被墙),再查代码。 - 虚拟机默认 NAT 不能互访/网段不定,要真实局域网身份改桥接。
MQTT 协议类
- Client ID 必须全局唯一,公共 Broker 上重名会互相踢下线,现象是「连上几秒被踢、反复掉线」。
- QoS 1 = 至少一次 ≠ 恰好一次,可能重复投递,业务要「只处理一次」得自己做去重。
- 端口别填错 :MQTT over TCP 是 1883,over TLS/SSL 是 8883,用 SSL 时
MQTTUSERCFG的 SSL 位也要置 1。
AT 指令时序类(最容易翻车)
- ESP-12F 上电启动比 STM32 慢 1~2 秒 ,不加
HAL_Delay(3000),第一条指令被启动日志冲掉、不收OK,全链路连环失败。 - 忘关回显
ATE0= 最隐蔽的坑 :回显撑爆 64 字节缓冲区,真正的OK还没进缓冲区就超时返回,strstr误判失败。放在最前面 + 3 次重试,是「从全不通到全通」的分水岭。 - 收应答绝不能用
HAL_MAX_DELAY:ESP 没回就死在那行。给有限超时------普通 1s、连 WiFi 10s、连服务器 5s。 - WiFi 连接成功别死盯
WIFI CONNECTED,已连过时可能只回OK/ALREADY CONNECTED,判断认OK。 MQTTCONN成功有两种应答 :OK或+MQTTCONNECTED,两种都要认。- AT 指令格式细节 :必须大写、必须
\r\n(CRLF) 结尾、字符串参数带双引号、默认 115200 波特率,漏一个 ESP 直接不回或回ERROR。 - 发布是否成功以「订阅端收到回推」为准,发布是异步的,端到端跑通的真正证据是自发自收能打印出回推内容。
六、总结
这个项目代码量小,却把网络基础、MQTT 协议、AT 指令交互串成一条完整因果链,核心体会三条:
第一,上云是分层的事,卡住了分层定位。 最底层 IP/TCP------先想清「私有 IP 上公网靠 NAT 主动外连」,连不通先 ping;中间层 MQTT 的 Pub/Sub、Topic 匹配、QoS、Client ID 唯一性;最上层才是 AT 逐条交互。绝大多数「上云失败」根因在网络底层或 AT 时序,而不在 MQTT 本身。
第二,AT 指令交互本质是异步串口协议,难点全在时序和缓冲区。 上电等模组就绪、先关回显避免 strstr 误判、收应答设有限超时不死等、连接判据兼容多种应答。守住这条时序纪律,链路稳一大半。
第三,先跑通再工程化。 用 MQTT.fx 当假设备先验证协议和服务器,用轮询版跑通自发自收,正式项目再迁 DMA + 空闲中断 解决丢消息、补 keepalive 心跳与断线重连提升健壮性,并把发布内容换成真实传感器数据,从「Hello World」升级为真正的物联网终端。