这是我做 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 步ATRST重启模块确保状态干净。第4步ATCWMODE:设置WiFi模式。第 6 步ATCWJAP连 WiFi。注意看如果之前连过它可能直接回OK而不回WIFI CONNECTED这点我在代码里做了兼容。第 7 步ATMQTTUSERCFG配置客户端 IDESP12F_2604_HH这是你的身份证必须唯一。第 8 步ATMQTTCONN连服务器。看到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主机号全 0192.168.3.0代表这个网络本身主机号全 1192.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 还是 UDPMQTT 选了 TCP协议优点缺点典型场景TCP有连接、可靠确认/重传/CRC不丢不重不乱序慢、开销大表单提交、即时消息、文件传输UDP快、轻不可靠、易丢包视频通话、直播MQTT 构建在TCP/IP之上天生继承 TCP 可靠性再在应用层用 QoS 补一层消息级保证。1.5 顺带虚拟机 NAT/桥接 与 pingNAT 模式虚拟机 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 IDBroker 区分设备的唯一标识重复会互踢ESP12F_2604_HHkeepalive 心跳维持 TCP 长连接的探活间隔由模组固件维持调试阶段可先用MQTT.fx当「假设备」填 Broker 端口1883TLS 是 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); } } } } // 订阅主题ATMQTTSUB0,topic,QoS uint8_t MqttSub(void) { pos 0; uint8_t cmd[] ATMQTTSUB0,\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 模式1Station连别人热点 static uint8_t setWifiMode(uint8_t mode) { pos 0; (void)mode; // 样例固定为1要灵活可拼接成 ATCWMODE%d uint8_t cmd[] ATCWMODE1\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; } } // 连接 WiFiATCWJAPssid,pwd。连接慢给 10 秒超时。 static uint8_t connectWifi(const char *ssid, const char *pwd) { pos 0; (void)ssid; (void)pwd; // 样例指令里固定写死要灵活用 sprintf 拼接 uint8_t cmd[] ATCWJAP\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 客户端身份ATMQTTUSERCFGlinkID,ver,clientID,user,pwd,clean,ssl,willTopic static uint8_t setMqttPara(void) { /* 参数① 0 LinkID(固定0) ② 1 MQTT版本(13.1.1) ③ ESP12F_2604_HH 客户端ID(必须全局唯一!) ④ 用户名 ⑤ 密码 ⑥ 0 不清除会话 ⑦ 0 不用SSL ⑧ 遗嘱主题(留空不用) */ pos 0; uint8_t cmd[] ATMQTTUSERCFG0,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; } } // 连接 BrokerATMQTTCONNlinkID,host,port,reconnect。末位1启用自动重连 static uint8_t MqttConn(void) { pos 0; uint8_t cmd[] ATMQTTCONN0,\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; } } // 发布消息ATMQTTPUBlinkID,topic,msg,QoS,retain static uint8_t MqttPub(const char *msg) { pos 0; // 用 msg 拼出发布指令保留样例的固定主题 uint8_t cmd[128] {0}; sprintf((char *)cmd, ATMQTTPUB0,\%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-12F115200-8-N-1 // 若进阶要做传感器数据这里再初始化 ADCMX_ADC1_Init(); // 上电一键上云 Mqtt_Server_Init(); // 进入发布/接收主循环内部 while(1) outputMsg(); }4.4 进阶把光照 ADC 采集值发到云端闭环改造把之前那个光敏电阻 ADC 项目接进来让「Hello World」变成真正会报数的物联网终端。ADC 侧沿用 12 位满量程 4095、vref3.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); // 例如 light1.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就能实时看到板子报上来的lightx.xxV一个「采集 → 上云 → 远程可视化」的最小闭环就成立了。五、易错点清单血泪复盘网络底层类私有 IP 不能直接访问另一台私有 IP两个局域网设备互通必须靠公网中转Broker别幻想用内网 IP 直连。连不上网先ping同 WiFi 电脑ping broker.emqx.io先排除环境路由器没联网、密码错、外网被墙再查代码。虚拟机默认 NAT 不能互访/网段不定要真实局域网身份改桥接。MQTT 协议类Client ID 必须全局唯一公共 Broker 上重名会互相踢下线现象是「连上几秒被踢、反复掉线」。QoS 1 至少一次 ≠ 恰好一次可能重复投递业务要「只处理一次」得自己做去重。端口别填错MQTT over TCP 是 1883over TLS/SSL 是 8883用 SSL 时MQTTUSERCFG的 SSL 位也要置 1。AT 指令时序类最容易翻车ESP-12F 上电启动比 STM32 慢 1~2 秒不加HAL_Delay(3000)第一条指令被启动日志冲掉、不收OK全链路连环失败。忘关回显ATE0 最隐蔽的坑回显撑爆 64 字节缓冲区真正的OK还没进缓冲区就超时返回strstr误判失败。放在最前面 3 次重试是「从全不通到全通」的分水岭。收应答绝不能用HAL_MAX_DELAYESP 没回就死在那行。给有限超时——普通 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」升级为真正的物联网终端。