STM32 从 0 到 1 做智能家居(十二):USART2 分时复用 —— ESP-12F + MQTT

文章目录

    • 一、为什么必须"分时复用"
      • [1.1 硬件只有一组引脚](#1.1 硬件只有一组引脚)
      • [1.2 三条铁律](#1.2 三条铁律)
      • [1.3 软件状态怎么表示](#1.3 软件状态怎么表示)
    • 二、ESP-12F:先把硬件搞对
      • [2.1 它不是"插上就能用"的模块](#2.1 它不是"插上就能用"的模块)
      • [2.2 供电:最容易"看起来正常"的坑](#2.2 供电:最容易"看起来正常"的坑)
      • [2.3 三个启动引脚](#2.3 三个启动引脚)
      • [2.4 上电日志是 74880,不是 115200](#2.4 上电日志是 74880,不是 115200)
      • [2.5 一条必须记住的时序](#2.5 一条必须记住的时序)
    • [三、进入 MQTT 的完整路径](#三、进入 MQTT 的完整路径)
      • [3.1 按键链路](#3.1 按键链路)
      • [3.2 主循环里的切换代码](#3.2 主循环里的切换代码)
      • [3.3 四处"顺序不能反"](#3.3 四处"顺序不能反")
      • [3.4 别被日志里的波特率误导](#3.4 别被日志里的波特率误导)
    • [四、初始化全景:11 步,一步都不能少](#四、初始化全景:11 步,一步都不能少)
      • [4.1 总表](#4.1 总表)
      • [4.2 代码骨架](#4.2 代码骨架)
      • [4.3 为什么必须"试发一条"](#4.3 为什么必须"试发一条")
    • 五、`Esp_Probe`:先探活,再谈别的
      • [5.1 四步策略](#5.1 四步策略)
      • [5.2 结论怎么读](#5.2 结论怎么读)
      • [5.3 探测失败时为啥还要挂中断](#5.3 探测失败时为啥还要挂中断)
    • [六、`Esp_Cmd_Expect`:把"收满 64 字节"换成"逐字节匹配"](#六、Esp_Cmd_Expect:把"收满 64 字节"换成"逐字节匹配")
      • [6.1 错误写法与它的三个死法](#6.1 错误写法与它的三个死法)
      • [6.2 正确写法:边收边匹配](#6.2 正确写法:边收边匹配)
      • [6.3 四个必须记住的细节](#6.3 四个必须记住的细节)
      • [6.4 超时时间表:每个数字都有来历](#6.4 超时时间表:每个数字都有来历)
    • [七、`Esp_Rx_Poll`:绕过 HAL 直接读寄存器](#七、Esp_Rx_Poll:绕过 HAL 直接读寄存器)
      • [7.1 为什么不用 `HAL_UART_Receive`](#7.1 为什么不用 HAL_UART_Receive)
      • [7.2 `Esp_Flush` 为什么要"先读 SR 再读 DR"](#7.2 Esp_Flush 为什么要"先读 SR 再读 DR")
      • [7.3 打印应答时要把 `\r\n` 转义](#7.3 打印应答时要把 \r\n 转义)
    • 八、`AT+MQTTCLEAN`:本章最隐蔽的一个坑
      • [8.1 症状](#8.1 症状)
      • [8.2 原因:ESP 把配置存进了 flash](#8.2 原因:ESP 把配置存进了 flash)
      • [8.3 为什么 `AT+MQTTDISCONN` 不够](#8.3 为什么 AT+MQTTDISCONN 不够)
      • [8.4 万一还是被拒:`skip_conn` 兜底](#8.4 万一还是被拒:skip_conn 兜底)
    • [九、MQTT 参数与主题:每个数字都别乱改](#九、MQTT 参数与主题:每个数字都别乱改)
      • [9.1 `AT+MQTTUSERCFG` 八个参数](#9.1 AT+MQTTUSERCFG 八个参数)
      • [9.2 `AT+MQTTCONN` 与自动重连](#9.2 AT+MQTTCONN 与自动重连)
      • [9.3 两个主题:为什么必须分开](#9.3 两个主题:为什么必须分开)
      • [9.4 ClientID 冲突:两台设备互踢](#9.4 ClientID 冲突:两台设备互踢)
      • [9.5 订阅](#9.5 订阅)
    • [十、发布:为什么必须用 `AT+MQTTPUBRAW`](#十、发布:为什么必须用 AT+MQTTPUBRAW)
      • [10.1 直接 `AT+MQTTPUB` 会失败](#10.1 直接 AT+MQTTPUB 会失败)
      • [10.2 `PUBRAW` 的三段式握手](#10.2 PUBRAW 的三段式握手)
      • [10.3 四个关键点](#10.3 四个关键点)
      • [10.4 关中断的副作用(要知道)](#10.4 关中断的副作用(要知道))
    • [十一、接收:环形缓冲 + 按行解析](#十一、接收:环形缓冲 + 按行解析)
      • [11.1 中断只做一件事](#11.1 中断只做一件事)
      • [11.2 主循环按行解析](#11.2 主循环按行解析)
      • [11.3 订阅消息怎么从行里抠出来](#11.3 订阅消息怎么从行里抠出来)
      • [11.4 ESP 重启检测:一条最容易被忽略的逻辑](#11.4 ESP 重启检测:一条最容易被忽略的逻辑)
    • 十二、和主循环怎么接
      • [12.1 命令执行](#12.1 命令执行)
      • [12.2 两条通道的对照](#12.2 两条通道的对照)
    • 十三、一次完整的联调日志(照着对)
      • [13.1 切到 MQTT](#13.1 切到 MQTT)
      • [13.2 运行中](#13.2 运行中)
      • [13.3 H5 端看到的](#13.3 H5 端看到的)
    • 十四、调不通:按这个顺序查
      • [14.1 初始化阶段](#14.1 初始化阶段)
      • [14.2 发布阶段](#14.2 发布阶段)
      • [14.3 收不到 H5 命令](#14.3 收不到 H5 命令)
    • 十五、本章易错点总结
    • 十六、小结与下一篇
    • [附录 A:本章完整代码](#附录 A:本章完整代码)
      • [A.1 环形缓冲与取字节](#A.1 环形缓冲与取字节)
      • [A.2 直接读寄存器的轮询](#A.2 直接读寄存器的轮询)
      • [A.3 逐字节增量匹配](#A.3 逐字节增量匹配)
      • [A.4 探活](#A.4 探活)
      • [A.5 MQTT 初始化主流程](#A.5 MQTT 初始化主流程)
      • [A.6 发布(运行期)](#A.6 发布(运行期))
      • [A.7 最小验证:五步确认 MQTT 通](#A.7 最小验证:五步确认 MQTT 通)

系列第十二篇。前面十一章铺的所有地基,都是为这一章服务:一组串口引脚、两个模块、两种波特率、一套"一问一答"的 AT 协议。

这一章我卡了整整三天。最难受的不是"连不上",而是每一步都回 OK,最后发布却永远 ERROR:

text 复制代码
setEchoOff : OK!!!
setWifiMode:OK!!!
connectWifi : OK!!!
setMqttPara : OK!!!
MqttConn : OK!!!
MqttSub : OK!!!
[PUB] FAILED

后来才知道:ESP 上电后自己就已经把 broker 连上了 ,LinkID 0 被它占着,我再发 AT+MQTTUSERCFG=0,... 只能回 ERROR;而"回 OK"的那几条,是它沿用旧会话在敷衍我。

这一篇会把整条链路的每一步、每个超时数字、每个"看起来成功其实没成功"的地方都拆开讲。

一、为什么必须"分时复用"

1.1 硬件只有一组引脚

STM32F411 的 USART2 固定在 PA2(TX)/ PA3(RX),不能重映射到别处。而工程里有两个串口模块:

模块 用途 波特率
HC-06 蓝牙 手机近场控制(第 11 章) 9600
ESP-12F(ESP8266 + AT 固件) WiFi → MQTT → H5 远程控制 115200

解决办法只有两条,缺一不可:

  • 硬件:双刀双掷拨码开关,把 PA2/PA3 整体切到某一个模块(第 3 章 5.2 节);
  • 软件:进 MQTT 时把 USART2 改成 115200,回蓝牙时改回 9600。

1.2 三条铁律

  1. 软件读不到拨码开关。 没有任何一根 GPIO 接在开关上,所以"硬件在哪一侧"必须靠人记住,并且和 g_net 保持一致。
  2. 切换必须成对。 只切 PA2 不切 PA3,两个模块的 TX 会同时驱动同一根线,现象是随机乱码------比完全不通更难查。
  3. 切换顺序固定:先断电 / 先停收发 → 拨开关 → 再上电。 带电热插拔容易在切换瞬间产生电平对打。

后面所有"MQTT 初始化全 FAIL",第一个要查的就是拨码开关。这不是套路话,是命中率最高的一条。

1.3 软件状态怎么表示

c 复制代码
#define NET_BT    0    // 蓝牙:9600
#define NET_MQTT  1    // WiFi/ESP-MQTT:115200

volatile uint8_t g_net       = NET_BT;        // 当前协议(中断和主循环都会读)
volatile uint8_t g_net_req   = NET_REQ_NONE;  // 中断置的切换请求,主循环执行
volatile uint8_t g_mqtt_inited = 0;           // 1=已完整初始化过一次
volatile uint8_t g_net_busy    = 0;           // 1=正在连 WiFi/broker,KEY4 请求丢弃
volatile uint8_t mqtt_ready    = 0;           // 1=会话真正可用(mqtt.c 内部)

四个标志各管一件事,别混:

标志 谁写 含义
g_net 主循环 当前走哪个协议(决定中断把字节送进哪个缓冲)
g_net_req 中断写、主循环清 "请切换"的请求,避免在中断里做耗时操作
g_mqtt_inited 主循环 是否已经成功走过一遍初始化(失败要能重来)
mqtt_ready mqtt.c 会话现在是否可用(ESP 重启后要清 0)

二、ESP-12F:先把硬件搞对

2.1 它不是"插上就能用"的模块

ESP-12F 是裸 ESP8266 模组,买到手大致有三种情况,你只能上电试:

情况 表现
出厂带较新的 AT 固件 115200 发 AT 回 OK,能直接做本章实验
带很老的 AT 固件 回 OK,但 AT+MQTTUSERCFG 等指令不支持(回 ERROR)
空片 / 烧的是别的固件 发 AT 一个字都不回

本工程是在 ESP-AT v2.2.0 上验证的。指令集不同的版本,本章的 AT 指令会大面积 FAIL。

三条路:

  • 买标注 "ESP-AT 固件" 且版本够新的模组;
  • 自己用 USB 转 TTL 烧 AT 固件(本章不展开);
  • 先发 AT+GMR 看版本号,低于 v2.x 就别硬试了。

怎么判断固件在不在 :上电后 115200 发 AT,回 OK 就是有;一个字都不回,先怀疑固件或波特率。

2.2 供电:最容易"看起来正常"的坑

ESP8266 发射瞬间电流能冲到 300mA 以上,平均也有 70~80mA。

现象 原因
一联网就复位 / 串口打印 rst cause:2 供电电流不够,电压跌到 3.0V 以下
平时能通、一发数据就掉线 同上,用 USB 转 TTL 的 3.3V 直接带不动

所以:ESP-12F 的 VCC 必须用独立、能出 500mA 的 3.3V ,并且和 STM32 共地。别用 STM32 板上的 3.3V 排针硬带,也别用 USB 转 TTL 的 3.3V 输出。

2.3 三个启动引脚

引脚 正常工作时 说明
EN(CH_PD) 高(3.3V) 拉低就停在上电复位状态,模块"死"了
GPIO15 低(GND) 拉高会进别的启动模式,AT 固件跑不起来
GPIO0 高(悬空/上拉) 拉低是"下载模式",不是运行模式

裸模块必须自己加:EN 上拉到 3.3V(10k)、GPIO15 下拉到 GND(10k)。买带底板的模组通常已经处理好。

2.4 上电日志是 74880,不是 115200

ESP8266 的 ROM 启动日志固定 74880 波特率输出,内容大致是:

text 复制代码
ets Jan  8 2013,rst cause:2, boot mode:(3,6)
...
ready

你用 115200 去收,看到的就是一串乱码(日志里那些 NIIT、K 就是这么来的)。

这条日志非常有用:

  • 看到乱码 → 说明线是通的,只是波特率不对(或被别的波特率占着);
  • 一个字都没有 → 线没通(拨码、供电、交叉接线);
  • 能看懂 ready → ESP 已经跑起来了,AT 固件在等指令。

2.5 一条必须记住的时序

text 复制代码
STM32 上电 ──────────────► 立刻跑 main()
ESP 上电   ── 1~2 秒启动 ──► 才开始响应 AT

ESP 启动比你慢 1~2 秒。上电后马上发 AT,它不会理你 。所以 Mqtt_Server_Init() 第一句就是:

c 复制代码
HAL_Delay(3000);   // 等 ESP 从上电/复位里完全出来
Esp_Flush();       // 把这 3 秒里它喷的启动日志清干净

少这一句,后面的探测就会把"还没启动完"误判成"没接线"。

三、进入 MQTT 的完整路径

3.1 按键链路

text 复制代码
上电:MODE_MANUAL(手动,KEY4=加热)
  │
  ├─ KEY2 ──► MODE_AUTO
  │
  ├─ KEY4 ──► MODE_REMOTE(默认 NET_BT,USART2=9600)
  │
  └─ KEY4 ──► NET_MQTT(USART2=115200,首次要初始化十几秒)
        │
        └─ KEY4 ──► 回 NET_BT(9600)

所以从冷启动进 MQTT,需要按 KEY2 → KEY4 → KEY4 三次。

3.2 主循环里的切换代码

c 复制代码
if(g_net_req != NET_REQ_NONE)
{
  uint8_t req = g_net_req;
  g_net_req = NET_REQ_NONE;                  // 先清,避免重复执行

  if(req == NET_REQ_MQTT)
  {
    g_net = NET_MQTT;                        // ★ 先置协议标志
    Uart2_SetBaudrate(115200);               // 再切波特率(此时不能挂中断)

    if(!g_mqtt_inited)
    {
      g_net_busy = 1;                        // 初始化期间丢弃 KEY4
      printf("[NET] Switch HW dial to WiFi(W_TXD/W_RXD)! MQTT init ~10s...\r\n");
      if(Mqtt_Server_Init() == HAL_OK)
        g_mqtt_inited = 1;                   // 只有全部成功才算"已初始化"
      else
        printf("[NET] MQTT init failed (see FAIL line above). press KEY4 twice to retry\r\n");
      g_net_busy = 0;
    }
    else
    {
      HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1);   // 连过,只重挂接收
    }
    printf("[NET] WiFi/MQTT @%lu\r\n", (unsigned long)huart2.Init.BaudRate);
  }
  else
  {
    g_net = NET_BT;
    Uart2_SetBaudrate(9600);
    HAL_UART_Receive_IT(&huart2, bt_buf, 1);
    printf("[NET] Bluetooth @9600\r\n");
  }
}

3.3 四处"顺序不能反"

顺序 反了会怎样
先 g_net 再切波特率 切换瞬间到达的字节会走错分支(进蓝牙缓冲,或反之)
切波特率时不能挂中断 Mqtt_Server_Init() 内部用阻塞收发,挂着 IT 会让 HAL_UART_Receive 返回 HAL_BUSY,recv 永远为空
先 g_net_busy=1 再初始化 初始化十几秒里按 KEY4,"切回蓝牙"的请求会插进 AT 指令流,整轮初始化报废
初始化失败也要挂接收 否则 ESP 的报错打印全丢,你连失败原因都看不到

3.4 别被日志里的波特率误导

c 复制代码
printf("[NET] WiFi/MQTT @%lu\r\n", (unsigned long)huart2.Init.BaudRate);

这里打印的是实际生效的波特率,不是写死的 115200。如果探测阶段发现 ESP 只认 9600,它会自动降速,日志就会是:

text 复制代码
[ESP] online @9600 (not 115200), keep 9600
[NET] WiFi/MQTT @9600

这是正常的 ,不是配置错了。第 11 章的蓝牙分支反过来,是写死的 @9600 字面量。

四、初始化全景:11 步,一步都不能少

4.1 总表

步 动作 函数 超时 失败后果
① 等 ESP 启动 + 清缓冲 HAL_Delay(3000) / Esp_Flush() --- 后面全部无应答
② 探测 ESP 是否在线 Esp_Probe() 见第五节 直接返回失败,不白等
③ 关回显 setEchoOff() → ATE0 1000ms ×3 回显占满缓冲,OK 被挤后
④ Station 模式 setWifiMode(1) → AT+CWMODE=1 1000ms 连不上路由器
⑤ 连路由器 connectWifi() → AT+CWJAP 20000ms 后续全失败
⑥ 复位 MQTT 链路 AT+MQTTDISCONN=0 + AT+MQTTCLEAN=0 1500/2000ms USERCFG 必回 ERROR
⑦ 配置 MQTT 参数 setMqttPara() → AT+MQTTUSERCFG 1500ms 连不上 broker
⑧ 连 broker MqttConn() → AT+MQTTCONN 15000ms ×2 无会话
⑨ 订阅命令主题 MqttSub() → AT+MQTTSUB 1500ms 收不到 H5 命令
⑩ 试发一条 Mqtt_Pub_Raw_Blocking() 1500 + 2000ms 判失败,可重试
⑪ 切中断接收 HAL_UART_Receive_IT(...,1) --- 运行时收不到任何东西

为什么"任何一步失败就停" :后面的步骤依赖前面的结果。WiFi 都没连上,还硬跑 CONN/SUB,只会白等几十秒然后满屏 ERROR。代码用 do{...}while(0) 只是为了能 break,它不是循环。

4.2 代码骨架

c 复制代码
uint8_t Mqtt_Server_Init(void)
{
  uint8_t ok = 1;
  uint8_t skip_conn = 0;          // 1=ESP 已自己连上 broker,跳过 USERCFG/CONN

  printf("[NET] MQTT init start...\r\n");
  HAL_Delay(3000);
  Esp_Flush();

  if(!Esp_Probe())                // ② 探活失败 → 立刻返回,不浪费后面的时间
  {
    printf("[NET] ESP probe failed -> skip WiFi/broker init (cause printed above)\r\n");
    mqtt_ready = 0;
    HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1);   // 仍挂上,方便看 ESP 打印
    return HAL_ERROR;
  }

  do
  {
    if(setEchoOff()   != HAL_OK) { ok = 0; break; }
    if(setWifiMode(1) != HAL_OK) { ok = 0; break; }
    if(connectWifi("7", "66668888") != HAL_OK) { ok = 0; break; }

    /* ⑥ 关键:先彻底擦掉 LinkID 0 的旧配置 */
    Esp_Cmd_Expect("AT+MQTTDISCONN=0\r\n", 1500);
    HAL_Delay(500);
    Esp_Cmd_Expect("AT+MQTTCLEAN=0\r\n", 2000);
    HAL_Delay(500);

    if(setMqttPara() != HAL_OK)
    {
      if(Mqtt_Link_Connected("after cfg fail"))      // 配置被拒,但会话其实已存在
        skip_conn = 1;
      else { ok = 0; break; }
    }

    if(!skip_conn)
    {
      HAL_Delay(500);                                // 给 ESP 一点时间装载配置
      if(MqttConn() != HAL_OK) { ok = 0; break; }
    }
    if(MqttSub() != HAL_OK) { ok = 0; break; }
  } while(0);

  if(ok)      /* ⑩ 试发一条,真收到 OK 才算通 */
  {
    char     json[64] = {0};
    uint16_t jlen = Mqtt_Build_Json(json, sizeof(json));
    printf("[NET] test publish: %s\r\n", json);

    if(Mqtt_Pub_Raw_Blocking(json, jlen) != HAL_OK)
    {
      printf("MqttPub(test) : FAIL,recv"); Esp_Print_Resp();
      ok = 0;
    }
    else printf("MqttPub(test) : OK!!!\r\n");
  }

  mqtt_ready = ok;
  if(ok) printf("[NET] MQTT ready (WiFi + broker + sub all OK)\r\n");
  else   printf("[NET] MQTT init FAILED at step above. press KEY4 twice to retry\r\n");

  /* ⑪ 无论成败都挂中断接收 */
  HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1);
  last_pub_tick = HAL_GetTick();      // 试发刚结束,2 秒上报计时从现在开始
  return ok ? HAL_OK : HAL_ERROR;
}

4.3 为什么必须"试发一条"

AT+MQTTCONN 回 OK、AT+MQTTSUB 也回 OK,但随后 PUB 一直回 ERROR 的情况真实发生过(就是文章开头那段日志)。

光看前两步会误判成"已经成功",然后进主循环每 2 秒发一次、每 2 秒报一次 ERROR,把串口刷满。

所以初始化末尾一定要真发一条:broker 收下了,才算 MQTT 可用。

五、Esp_Probe:先探活,再谈别的

5.1 四步策略

text 复制代码
① 115200 发 AT,3 次(每次间隔 300ms)
② 不行 → 切 9600,再 3 次
③ 还不行 → 切回 115200 再补 1 次(可能刚启动完)
④ 全失败 → 看"有没有收到过任何字节",给出结论

为什么还要试 9600:如果上一个实验把 ESP 的 AT 波特率改成了 9600,你只看 115200 就会误判成"没接线",然后把线拆了重接,白折腾一小时。

为什么第 ③ 步要补一次:ESP 启动日志是 74880,这段时间收什么都是乱码;等几秒它就绪了。不补这一次,就会把"还没启动完"当成"坏了"。

5.2 结论怎么读

日志 含义 处理
[ESP] online @115200 正常 继续
[ESP] online @9600 (not 115200) 波特率被人改过 可以继续,全程按 9600 走
[ESP] online @115200 (was still booting) 刚才只是没启动完 继续
zero bytes received -> USART2 RX not connected to ESP (dial on Bluetooth side? ESP unpowered?) 一个字节都没收到 拨码、供电、TX/RX 交叉,三选一
no valid answer, but bytes did arrive -> baud mismatch (or ESP still booting) 收到了,但是乱码 波特率不对 / 还在启动

这两句话是整个工程里最有价值的诊断信息,一定要会读:

  • 0 字节 = 物理层不通;
  • 有字节但乱码 = 物理层通了,参数不对。

5.3 探测失败时为啥还要挂中断

c 复制代码
HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1);
return HAL_ERROR;

失败也要挂接收,因为 ESP 之后可能还在往外喷启动日志/报错。挂着中断,这些内容就会进环形缓冲,由主循环打印出来------失败原因往往就在这些被忽略的字节里。

六、Esp_Cmd_Expect:把"收满 64 字节"换成"逐字节匹配"

6.1 错误写法与它的三个死法

很多人(包括我)第一版是这么写的:

c 复制代码
HAL_UART_Receive(&huart2, buf, 64, 5000);
if(strstr((char*)buf, "OK")) ...

死法一:缓冲区被事件流占满。ESP 连 WiFi 时会先刷一串:

text 复制代码
WIFI DISCONNECT
+CWJAP:1
WIFI CONNECTED
WIFI GOT IP
OK

64 字节正好被前几行塞满,真正的 OK 还在后面 → 被误判成 FAIL。

死法二:RxState 不是 READY 。上一步的接收没收干净,HAL_UART_Receive 直接返回 HAL_BUSY,buf 里啥也没有。

死法三:ORE 溢出。字节来太快没取走,硬件置 ORE,HAL 中止本次接收并返回错误。

6.2 正确写法:边收边匹配

c 复制代码
static uint8_t Esp_Cmd_Expect(const char *cmd, uint32_t timeout)
{
  uint16_t n = 0;
  uint32_t t0, t_close;

  esp_last_resp[0] = '\0';
  Esp_Flush();                                  // ★ 每条指令前都清残留

  if(HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), 200) != HAL_OK)
    return HAL_ERROR;                           // 发送失败要当场报,别白等

  t0 = HAL_GetTick();
  while(HAL_GetTick() - t0 < timeout)
  {
    char b;
    if(!Esp_Rx_Poll(&b, 20)) continue;          // 20ms 一轮,外层还有总超时

    if(n < sizeof(esp_last_resp) - 1)
    {
      esp_last_resp[n++] = b;
      esp_last_resp[n]   = '\0';                // 边收边补结束符,随时能 strstr
    }

    if(strstr(esp_last_resp, "ERROR") || strstr(esp_last_resp, "FAIL"))
      return HAL_ERROR;
    if(strstr(esp_last_resp, "OK") || strstr(esp_last_resp, "+MQTTCONNECTED"))
      return HAL_OK;
  }

  /* 超时后再多收 300ms:把迟到的应答留下,否则下一条的 Flush 会丢掉它 */
  t_close = HAL_GetTick() + 300;
  ...
  return HAL_ERROR;
}

6.3 四个必须记住的细节

① FAIL 也要当失败。

连不上路由器时,ESP 回的是 +CWJAP:1 / FAIL,不是 ERROR。只认 ERROR 的话,你会一直等到 20 秒超时,日志上还看不出原因。

② +MQTTCONNECTED 也算成功。

AT+MQTTCONN 有时不急着回 OK,而是先推 +MQTTCONNECTED:0,1,"broker.emqx.io",1883。你要是只认 OK,就会误判失败。

③ 每条指令前必须 Esp_Flush()。

上一条的 ERROR 如果留在缓冲里,下一条指令刚发出去就被判失败------日志表现为 recv:K → ERROR 交错,非常难查。

④ 超时后多收 300ms。

这条只影响排错,不影响功能:没有它,迟到的应答会被下一条的 Flush 清掉,日志只剩一个 (len=0)",你根本不知道 ESP 说了什么。

6.4 超时时间表:每个数字都有来历

指令 超时 为什么是这个数
AT(探活) 500ms 正常几毫秒就回,500ms 足够
ATE0 / AT+CWMODE 1000ms 简单配置,回得快
AT+CWJAP 20000ms 找不到 AP 时 ESP 会静默扫描十几秒,一个字节都不回;给 10 秒会误判失败
AT+MQTTDISCONN 1500ms 没连时回 ERROR,正常现象
AT+MQTTCLEAN 2000ms 要擦 flash,稍慢
AT+MQTTUSERCFG 1500ms 写配置
AT+MQTTCONN 15000ms 要先 DNS 解析再 TCP 握手,网络差时十几秒;给 5 秒必误判
AT+MQTTSUB 1500ms 本地操作,快
AT+PING 5000ms 用来区分"网络不通"和"broker 拒绝"
AT+MQTTCONN? 1000ms 查询,快

这张表怎么用:所有"FAIL, recv(len=0)"基本都是超时给短了。

七、Esp_Rx_Poll:绕过 HAL 直接读寄存器

c 复制代码
static uint8_t Esp_Rx_Poll(char *pc, uint32_t wait_ms)
{
  uint32_t t0 = HAL_GetTick();
  do
  {
    uint32_t sr = huart2.Instance->SR;

    if(sr & USART_SR_RXNE)
    {
      *pc = (char)(huart2.Instance->DR & 0xFF);   // 读 DR 自动清 RXNE
      return 1;
    }
    if(sr & USART_SR_ORE)
    {
      (void)huart2.Instance->DR;                  // 溢出:读一下 DR 清标志
    }
  } while(HAL_GetTick() - t0 < wait_ms);

  return 0;
}

7.1 为什么不用 HAL_UART_Receive

三个理由:RxState 检查、ORE 中止、按固定长度返回。第 10 章 7.2 节详细讲过,这里再补一条工程经验:

初始化阶段用阻塞收发(直接读寄存器),运行阶段用中断 + 环形缓冲。 两套机制不要同时开:

  • 初始化时如果已经挂了 Receive_IT,HAL_UART_Receive 会因为 RxState=BUSY 直接返回 HAL_BUSY;
  • 反过来,Esp_Rx_Poll 读走字节后,中断的计数也会乱。

所以本工程的顺序是:先阻塞跑完初始化,最后一行才挂中断。

7.2 Esp_Flush 为什么要"先读 SR 再读 DR"

c 复制代码
static void Esp_Flush(void)
{
  esp_rx_head = 0;
  esp_rx_tail = 0;
  (void)huart2.Instance->SR;   // 先读 SR
  (void)huart2.Instance->DR;   // 再读 DR
}

读 SR 再读 DR 这一对动作,能同时清掉 RXNE 和 ORE/NE/FE/PE。

踩过的坑:原来用 __HAL_UART_CLEAR_FLAG(&huart2, UART_FLAG_RXNE),它是往 SR 写位,对 RXNE 其实不起作用。残留字节会串到下一条指令的应答里,日志看起来像"ESP 回了两个 OK"。

7.3 打印应答时要把 \r\n 转义

c 复制代码
static void Esp_Print_Resp(void)
{
  const char *s = esp_last_resp;
  printf("(len=%u)\"", (unsigned)strlen(esp_last_resp));
  while(*s)
  {
    if(*s == '\r')      printf("\\r");
    else if(*s == '\n') printf("\\n");
    else                printf("%c", *s);
    s++;
  }
  printf("\"\r\n");
}

ESP 的应答几乎都以 \r\n 开头。直接 printf("%s", resp),终端上就是一行空白 ------日志里那个孤零零的 ERROR 就是这么来的。转义之后你会看到:

text 复制代码
recv(len=7)"\r\nERROR"

一眼就知道:收到了,内容是 ERROR,不是没收到。

八、AT+MQTTCLEAN:本章最隐蔽的一个坑

8.1 症状

text 复制代码
setMqttPara : FAIL,recv(len=7)"\r\nERROR"

前面 CWJAP 明明 OK,为什么配置参数会被拒?

8.2 原因:ESP 把配置存进了 flash

ESP-AT 会把 MQTT 配置持久化。上电、WiFi 一恢复,它就自己把 broker 连上了,日志里会出现:

text 复制代码
[ESP] +MQTTCONNECTED:0,1,"broker.emqx.io",1883

这时 LinkID 0 已经被占用,你再发:

text 复制代码
AT+MQTTUSERCFG=0,1,"ESP12F_2604_HH","","",0,0,""

它只能回 ERROR------因为"0 号连接"正在用着。

8.3 为什么 AT+MQTTDISCONN 不够

DISCONN 只是断开连接,配置还在 flash 里。ESP 拿着旧配置马上又连回去(实测断开三次都失败,因为它比你想的快)。

真正管用的是:

text 复制代码
AT+MQTTCLEAN=0      // 把 LinkID 0 的配置一起擦掉,回到"未配置"状态

所以顺序必须是:

c 复制代码
Esp_Cmd_Expect("AT+MQTTDISCONN=0\r\n", 1500);   // 没连时回 ERROR,正常,忽略
HAL_Delay(500);
Esp_Cmd_Expect("AT+MQTTCLEAN=0\r\n", 2000);     // ★ 真正复位
HAL_Delay(500);

两个 HAL_Delay(500) 不是凑数:ESP 内部要时间真正释放连接,紧接着发下一条容易被回 busy p...。

8.4 万一还是被拒:skip_conn 兜底

c 复制代码
if(setMqttPara() != HAL_OK)
{
  if(Mqtt_Link_Connected("after cfg fail"))   // 查 AT+MQTTCONN?
  {
    skip_conn = 1;                            // 会话其实是好的,直接沿用
  }
  else { ok = 0; break; }
}

AT+MQTTCONN? 回 +MQTTCONN:0,1,... 就说明链路已连。这时跳过 USERCFG 和 CONN,只重新订阅一次------重连之后原来的订阅一定丢了,不重订就收不到 H5 命令。

查询本身失败(固件不支持/超时)按"未连接"处理,不阻断流程。

九、MQTT 参数与主题:每个数字都别乱改

9.1 AT+MQTTUSERCFG 八个参数

text 复制代码
AT+MQTTUSERCFG=0,1,"ESP12F_2604_HH","","",0,0,""
位置 值 含义
① 0 连接 ID(LinkID),全工程固定用 0
② 1 MQTT 版本 3.1.1
③ "ESP12F_2604_HH" ClientID,见 9.4 节的坑
④ "" 用户名(公共 broker 留空)
⑤ "" 密码(留空)
⑥ 0 不清除会话
⑦ 0 不使用 SSL(1883 是明文端口)
⑧ "" 遗嘱主题(留空 = 不用)

9.2 AT+MQTTCONN 与自动重连

text 复制代码
AT+MQTTCONN=0,"broker.emqx.io",1883,1

最后一个 1 = 使能自动重连:连接断了 ESP 会自己重试。

这就是为什么"WiFi 恢复后 ESP 自己又连上了 broker"------自动重连 + flash 配置,两个特性叠加,才有了 8.2 节那个坑。

代码里重试 2 次,每次 15 秒;失败后还会:

  1. 查 AT+MQTTCONN?(可能只是 OK 迟到);
  2. 发 AT+PING="broker.emqx.io",把"网络不通"和"broker 拒绝"分开:
PING 结果 含义
+PING:45 通,延迟 45ms
+PING:0 / ERROR 域名解析不到,或没有外网

9.3 两个主题:为什么必须分开

c 复制代码
#define TOPIC_PUB   "2604/topic-HH"   // 设备 → broker → H5(温湿度)
#define TOPIC_SUB   "2604/cmd-HH"     // H5 → broker → 设备(命令)

如果只用一个主题,会出两个问题:

  1. 自己收到自己发的消息:设备每 2 秒发一次 JSON,自己也会收到一次,如果解析不当就会把 JSON 当成命令;
  2. 命令和数据混在一条流里:H5 要过滤掉温湿度、设备要过滤掉命令,两边都变复杂。

分开之后,订阅的永远是命令,发布的永远是数据,各自只看自己关心的。

班级号要改成自己的。 2604 是班级编号,公共 broker 上同班的设备共用这套主题。你如果直接照抄,会收到同学设备的命令------表现是"没人操作,风扇自己转了"。

9.4 ClientID 冲突:两台设备互踢

MQTT 协议规定:同一个 ClientID 只能有一个在线连接。后连的会把先连的踢掉。

如果你两台板子都用 ESP12F_2604_HH:

  • 现象 A:板子 A 正常,板子 B 一上电,A 就掉线;
  • 现象 B:日志里 [NET] ESP rebooted -> MQTT session lost 反复出现;
  • 现象 C:PUB 时好时坏。

处理 :ClientID 加后缀区分,比如 ESP12F_2604_HH_01、_02。H5 那边不用动,主题不变就行。

9.5 订阅

c 复制代码
Esp_Cmd_Expect("AT+MQTTSUB=0,\"" TOPIC_SUB "\",1\r\n", 1500);

最后一个 1 是 QoS1 (至少送达一次)。发布端 PUBRAW 的第 4 个参数也是 1,两边一致。

十、发布:为什么必须用 AT+MQTTPUBRAW

10.1 直接 AT+MQTTPUB 会失败

要发的 JSON:

json 复制代码
{"t":26,"h":55,"a":1,"c":412,"v":88}

里面有双引号 。用 AT+MQTTPUB 时,引号必须转义成 \":

text 复制代码
AT+MQTTPUB=0,"2604/topic-HH","{\"t\":26,...}",1,0

而 ESP-AT v2.2.0 不认这种转义 ,直接回 ERROR。

我当时做的对照实验:

测试 结果
MqttPub(test)(带引号的 JSON) FAIL
MqttPub(test,plain)(纯文本,无引号) OK

纯文本能过、JSON 不过,就是转义问题。结论:JSON 一律走 AT+MQTTPUBRAW。

10.2 PUBRAW 的三段式握手

text 复制代码
① 发命令:AT+MQTTPUBRAW=0,"2604/topic-HH",<长度>,1,0\r\n
② 等 ESP 回 '>' 提示符     ← 见到它才允许发数据
③ 发原始 JSON(不带 \r\n!)
④ 等 OK

对应代码:

c 复制代码
static uint8_t Mqtt_Pub_Raw_Blocking(const char *json, uint16_t len)
{
  char     cmd[96] = {0};
  char     b;
  uint16_t n = 0;
  uint32_t t0;
  uint8_t  got_prompt = 0;

  snprintf(cmd, sizeof(cmd), "AT+MQTTPUBRAW=0,\"" TOPIC_PUB "\",%u,1,0\r\n", (unsigned)len);

  esp_last_resp[0] = '\0';
  Esp_Flush();
  if(HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), 200) != HAL_OK)
    return HAL_ERROR;

  /* ① 等 '>' */
  t0 = HAL_GetTick();
  while(HAL_GetTick() - t0 < 1500)
  {
    if(!Esp_Rx_Poll(&b, 20)) continue;
    if(b == '>') { got_prompt = 1; break; }
    if(n < sizeof(esp_last_resp) - 1)
    {
      esp_last_resp[n++] = b;
      esp_last_resp[n]   = '\0';
    }
    if(strstr(esp_last_resp, "ERROR")) return HAL_ERROR;
  }
  if(!got_prompt) return HAL_ERROR;

  /* ② 原样发数据:绝对不能带 \r\n */
  if(HAL_UART_Transmit(&huart2, (uint8_t*)json, len, 200) != HAL_OK)
    return HAL_ERROR;

  /* ③ 等 OK */
  esp_last_resp[0] = '\0';
  n = 0;
  t0 = HAL_GetTick();
  while(HAL_GetTick() - t0 < 2000)
  {
    if(!Esp_Rx_Poll(&b, 20)) continue;
    if(n < sizeof(esp_last_resp) - 1)
    {
      esp_last_resp[n++] = b;
      esp_last_resp[n]   = '\0';
    }
    if(strstr(esp_last_resp, "ERROR")) return HAL_ERROR;
    if(strstr(esp_last_resp, "OK"))    return HAL_OK;
  }
  return HAL_ERROR;
}

10.3 四个关键点

① 长度必须精确。

第 3 个参数是"后面要发的字节数"。多算一个,ESP 会多等一个字节,卡住;少算一个,JSON 被截断,H5 JSON.parse 直接抛异常。

② 数据后面绝对不能带 \r\n。

这段是消息内容 ,不是 AT 指令。多一个 \r\n,H5 收到的 JSON 末尾就有两个不可见字符,JSON.parse 在某些写法下会失败。

③ '>' 提示符可能出现得很晚。

1500ms 是实测值。网络差的时候会慢,别给 100ms。

④ 发布必须"整段同步做完"。

c 复制代码
uint8_t MqttPub()
{
  ...
  __HAL_UART_DISABLE_IT(&huart2, UART_IT_RXNE);   // 握手期间不让中断抢字节
  ret = Mqtt_Pub_Raw_Blocking(json, jlen);
  __HAL_UART_ENABLE_IT(&huart2, UART_IT_RXNE);    // 结束再开
  ...
}

原因是我遇到过一个非常难受的现象:成功一次、失败一次,交替出现。

拆开看才发现:'>' 提示符被中断存进了环形缓冲,主循环轮询时抢不到;于是下一条命令被 ESP 当成"上一次的数据"吃掉了。

握手是一个原子过程,中间不能有别人碰串口。 关中断接收这段时间大约是几十毫秒。

10.4 关中断的副作用(要知道)

关 RXNE 中断的这几十毫秒里,如果 H5 正好下发命令,那一条会丢。

  • 概率:2 秒一次、每次几十毫秒,大概 1%~3%;
  • 表现:点了按钮没反应,再点一次就好;
  • 处理:H5 端做"无回显超时重发",或者接受这个概率(本工程选择接受)。

没有两全其美:要么握手可能被抢字节,要么极少数命令会丢。选一个能接受的。

十一、接收:环形缓冲 + 按行解析

11.1 中断只做一件事

c 复制代码
void Esp_Uart_RxByte(uint8_t b)
{
  uint16_t next = (esp_rx_head + 1) % ESP_RXBUF_SIZE;
  if(next == esp_rx_tail) return;          // 满:丢弃新字节
  esp_rxbuf[esp_rx_head] = b;
  esp_rx_head = next;
}

ESP_RXBUF_SIZE = 512,head 只由中断写、tail 只由主循环写(第 10 章 8.2 节)。

115200 下一个字节约 86.8µs,512 字节约 44ms 的缓冲量。只要主循环不在某处卡 44ms 以上,就不会溢出。

11.2 主循环按行解析

c 复制代码
void Mqtt_Poll(void)
{
  static char line[256] = {0};
  static uint16_t len = 0;
  char c;

  while(Esp_Rx_GetByte(&c))               // 没有 while(1),取完就返回
  {
    if(c == '\n')                         // 换行 = 一整行结束
    {
      line[len] = '\0';
      Esp_Handle_Line(line);
      len = 0;
    }
    else if(c != '\r')                    // 回车丢掉
    {
      if(len < sizeof(line) - 1) line[len++] = c;
      else                       len = 0; // 太长(乱码)→ 丢弃重来
    }
  }

  if(mqtt_ready && (HAL_GetTick() - last_pub_tick >= MQTT_PUB_PERIOD))
  {
    last_pub_tick = HAL_GetTick();
    MqttPub();                            // 每 2 秒上报一次
  }
}

三个细节:

  1. \r 丢、\n 断行 :ESP 的应答是 \r\n,保留 \r 会让字符串末尾多一个字符,strstr 比较时容易出幺蛾子;
  2. 行太长直接清零 :防止 line[len++] 越界;
  3. 上报必须 mqtt_ready :broker 没连上时发 PUBRAW 只会一直回 ERROR,既刷屏又把串口塞满。

11.3 订阅消息怎么从行里抠出来

ESP 推送订阅消息的格式:

text 复制代码
+MQTTSUBRECV:0,"2604/cmd-HH",6,FAN_ON
段 含义
+MQTTSUBRECV 事件名
0 LinkID
"2604/cmd-HH" 主题
6 数据长度
FAN_ON 真正的消息内容

代码数第 3 个逗号之后的内容:

c 复制代码
p = strstr(line, "+MQTTSUBRECV");
if(p)
{
  uint8_t commas = 0;
  char *q = p;
  while(*q != '\0' && commas < 3)
  {
    if(*q == ',') commas++;
    q++;
  }
  if(commas == 3)
  {
    strncpy(esp_sub_data, q, sizeof(esp_sub_data) - 1);
    esp_sub_data[sizeof(esp_sub_data) - 1] = '\0';
    esp_sub_ready = 1;                   // 置标志,交给 main.c 取用
    return;
  }
}

注意:这里不是"按逗号 split",只是数到第 3 个逗号然后把后面整段拷走------命令里不会有逗号,所以够用。如果以后要传 JSON 命令,就得改成按长度截取(第 4 段的长度值)。

11.4 ESP 重启检测:一条最容易被忽略的逻辑

c 复制代码
if(strstr(line, "ready"))
{
  if(mqtt_ready)
  {
    mqtt_ready = 0;
    printf("\r\n[NET] ESP rebooted -> MQTT session lost, stop publishing. press KEY4 twice to re-init\r\n");
  }
  return;
}

ESP 掉电/复位后一定打印 ready。这时:

  • WiFi 会自动重连(自动重连 + flash 配置);
  • 但 MQTT 会话已经丢了 ,此后 PUB 只会一直回 ERROR。

所以看到 ready 就把 mqtt_ready 清 0,停止上报。主循环再同步:

c 复制代码
if(g_mqtt_inited && !Mqtt_Is_Ready())
  g_mqtt_inited = 0;      // 下次切 MQTT 会重新走一遍初始化

没有这段逻辑,你会看到"设备一直发、H5 一直没数据",而且日志满屏 ERROR。 这是我看过的最多同学卡住的地方。

十二、和主循环怎么接

12.1 命令执行

c 复制代码
if(g_mode == MODE_REMOTE && g_net == NET_MQTT)
{
  Mqtt_Poll();

  if(esp_sub_ready)
  {
    esp_sub_ready = 0;                     // 先清,防止同一条命令被执行两次
    printf("[MQTT] cmd: %s\r\n", esp_sub_data);
    Remote_Cmd_Exec(esp_sub_data, 0);      // ★ echo_back=0:绝不回显
  }

  if(g_mqtt_inited && !Mqtt_Is_Ready())
    g_mqtt_inited = 0;
}

echo_back=0 是硬性要求 :MQTT 通道下往 USART2 发任何东西,都会被 ESP 当成 AT 指令,回你 ERROR,甚至打断正在进行的握手。

12.2 两条通道的对照

项目 蓝牙(第 11 章) MQTT(本章)
波特率 9600 115200(可能被探测降到 9600)
命令来源 手机 APP 直连 H5 → broker → ESP
命令格式 字符串 FAN_ON 同样字符串,从 +MQTTSUBRECV 里抠出
回显 必须回(echo_back=1) 必须不回(echo_back=0)
上报 {"t":26,"h":55} 两项 {"t":26,"h":55,"a":1,"c":412,"v":88} 五项
接收方式 单字节 + 主循环拼 中断环形缓冲 + 按行解析
初始化 无需(模块透传) 11 步 AT 指令,十几秒

同一个 Remote_Cmd_Exec() 服务两条通道,所以 H5 和手机 APP 发的命令字面量必须完全一样。

十三、一次完整的联调日志(照着对)

13.1 切到 MQTT

text 复制代码
[MODE] Remote (KEY4) | Bluetooth ON
[NET] Bluetooth @9600
[KEY4] request MQTT
[NET] Switch HW dial to WiFi(W_TXD/W_RXD)! MQTT init ~10s...
[NET] MQTT init start...
[ESP] online @115200
setEchoOff : OK!!!
setWifiMode:OK!!!
connectWifi : OK!!!
[NET] reset MQTT link (DISCONN + CLEAN)...
setMqttPara : OK!!!
MqttConn : OK!!!
MqttSub : OK!!!
[NET] test publish: {"t":26,"h":55,"a":1,"c":412,"v":88}
MqttPub(test) : OK!!!
[NET] MQTT ready (WiFi + broker + sub all OK)
[NET] WiFi/MQTT @115200

13.2 运行中

text 复制代码
[PUB] OK                      ← 每 2 秒一条
[ESP] +MQTTSUBRECV:0,"2604/cmd-HH",6,FAN_ON
[MQTT] cmd: FAN_ON
[CMD] FAN ON

13.3 H5 端看到的

在 H5 页面上,温湿度每 2 秒刷新一次,空气质量三项也会跟着变。如果温湿度在动、但 H5 的空气质量一直不更新 ,多半是 Mqtt_Set_Env() 没被调用(第 6、7 章的传感器读取没跑),不是 MQTT 的问题。

十四、调不通:按这个顺序查

14.1 初始化阶段

日志 原因 处理
[ESP] zero bytes received 物理层不通 拨码切到 WiFi 侧 / 查 ESP 供电 / TX-RX 交叉 / 共地
[ESP] no valid answer, but bytes did arrive 收到乱码 波特率不对(ESP 可能被改过);或还在启动,等 3 秒再试
probe recv: "NIIT..." 74880 启动日志 正常,等它跑完
setEchoOff : FAIL 模块没有 AT 固件 / 波特率错 换固件或降速
connectWifi : FAIL,recv(len=0) 超时太短 / AP 找不到 确认 SSID 密码,超时给 20s
connectWifi : FAIL 后 +CWJAP:1 密码错 重新确认密码
setMqttPara : FAIL LinkID 0 被占用 补 AT+MQTTCLEAN=0
MqttConn : FAIL(try 1) 但 link ... connected=1 OK 迟到 代码已兜底,继续
MqttConn : FAIL + PING ERROR 没外网 / 域名解析失败 换网络,或改 broker 地址
MqttSub : FAIL 会话没建立 先看 CONN 是否真成功
MqttPub(test) : FAIL 详见 14.2 ---

14.2 发布阶段

现象 原因 处理
PUB 一直 ERROR 会话已丢 / 用了 AT+MQTTPUB 发 JSON 查 mqtt_ready;改用 PUBRAW
PUB 成功一次失败一次 '>' 被中断抢走 握手期间关 RXNE 中断
PUB 回 ERROR 且 payload 有乱码 长度算错 / 数据后多了 \r\n 用 strlen 算长度,别手写
满屏 ERROR 刷屏 mqtt_ready 为 0 还在发 发布前判 mqtt_ready

14.3 收不到 H5 命令

  1. H5 发到的是不是 2604/cmd-HH? 主题不一致,设备永远收不到;
  2. 设备订阅成功了吗? 日志里要有 MqttSub : OK!!!;
  3. 有没有 +MQTTSUBRECV 行? 没有 → 消息没到设备;有但没执行 → 解析问题;
  4. 班级号对不对? 公共 broker 上主题写错一位就串到别人那儿;
  5. ESP 是不是重启过? 看有没有 [NET] ESP rebooted;
  6. H5 和设备的 ClientID 是不是撞了? 撞了会互踢,表现是"时好时坏"。

十五、本章易错点总结

现象 真正原因 处理
所有 AT 指令全 FAIL,recv 为空 拨码还在蓝牙侧 切到 WiFi 侧,断电重上
ESP 一点回应都没有 没有可用的 AT 固件(空片或版本太老) 先发 AT+GMR 看版本,低于 v2.x 就烧固件
一发数据就复位 供电电流不够 独立 3.3V/500mA,别用板载排针
完全收不到字节 EN 没拉高 / GPIO15 没拉低 EN→3.3V,GPIO15→GND
收到的都是乱码 ESP 启动日志是 74880 等 3 秒再发 AT
AT+CWJAP 白等 20 秒 找不到 AP 时 ESP 静默扫描 确认 SSID/密码;超时给足 20s
setMqttPara : FAIL LinkID 0 被 flash 里的旧配置占用 AT+MQTTCLEAN=0
DISCONN 之后还是 ERROR 只断连不擦配置,ESP 会自己连回去 必须 CLEAN
CONN 明明成功却回 FAIL OK 迟到,ESP 只推了 +MQTTCONNECTED 把 +MQTTCONNECTED 也算成功
PUB 一直 ERROR 用了 AT+MQTTPUB 发带引号 JSON 改 AT+MQTTPUBRAW
PUB 成功/失败交替 '>' 被中断接收抢进环形缓冲 握手期间关 RXNE 中断
订阅收到但 JSON 被截断 PUBRAW 长度参数算错 用 strlen,别手写数字
H5 收到 JSON 解析报错 数据后面多发了 \r\n 原始数据不带换行
没人操作设备自己动 主题没改班级号,收到同学的设备命令 改成自己的班号
两台板子轮流掉线 ClientID 相同,互相踢线 ClientID 加唯一后缀
ESP 重启后一直 ERROR 会话已丢,mqtt_ready 还是 1 检测 ready 行并清 0
初始化十几秒像卡死 正常行为(阻塞式 AT 往返) 期间别按键,等它跑完
初始化期间按键把流程搞乱 KEY4 请求插进 AT 指令流 g_net_busy 丢弃请求
切协议后第一个字节丢失 g_net 和挂接收的顺序反了 先置协议标志,再挂中断
日志里 [NET] WiFi/MQTT @9600 以为是配错 探测发现 ESP 只认 9600,主动降速 正常,不用改

十六、小结与下一篇

这一章记住八件事:

  • 拨码开关软件读不到 ,硬件和 g_net 必须人工一致;
  • ESP 比 STM32 慢启动 1~2 秒,上电先等 3 秒;
  • 探活的结论只有两种:0 字节 = 物理层不通;有字节但乱码 = 参数不对;
  • AT 应答必须逐字节增量匹配 ,HAL_UART_Receive(64) 会被事件流塞满;
  • FAIL 也是失败 ,+MQTTCONNECTED 也是成功;
  • AT+MQTTCLEAN 必须有,否则 flash 里的旧配置会占住 LinkID 0;
  • JSON 必须走 PUBRAW,且握手要整段独占串口;
  • ESP 重启 = 会话丢失 ,看到 ready 就要清 mqtt_ready。

下一篇是前端:H5 页面怎么连 broker、怎么发命令、怎么显示数据。你会发现,页面上那几个按钮背后,就是这一章订阅/发布的两条主题。

如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。


附录 A:本章完整代码

A.1 环形缓冲与取字节

c 复制代码
#define ESP_RXBUF_SIZE   512

uint8_t esp_rx_byte = 0;                    // 中断单字节落点
static uint8_t  esp_rxbuf[ESP_RXBUF_SIZE];
static volatile uint16_t esp_rx_head = 0;   // 中断写
static volatile uint16_t esp_rx_tail = 0;   // 主循环读

void Esp_Uart_RxByte(uint8_t b)
{
  uint16_t next = (esp_rx_head + 1) % ESP_RXBUF_SIZE;
  if(next == esp_rx_tail) return;           // 满:丢新字节
  esp_rxbuf[esp_rx_head] = b;
  esp_rx_head = next;
}

static uint8_t Esp_Rx_GetByte(char *pc)
{
  if(esp_rx_head == esp_rx_tail) return 0;  // 空
  *pc = (char)esp_rxbuf[esp_rx_tail];
  esp_rx_tail = (esp_rx_tail + 1) % ESP_RXBUF_SIZE;
  return 1;
}

static void Esp_Flush(void)
{
  esp_rx_head = 0;
  esp_rx_tail = 0;
  (void)huart2.Instance->SR;   // 先读 SR
  (void)huart2.Instance->DR;   // 再读 DR:一起清 RXNE + ORE/NE/FE/PE
}

A.2 直接读寄存器的轮询

c 复制代码
static uint8_t Esp_Rx_Poll(char *pc, uint32_t wait_ms)
{
  uint32_t t0 = HAL_GetTick();
  do
  {
    uint32_t sr = huart2.Instance->SR;
    if(sr & USART_SR_RXNE) { *pc = (char)(huart2.Instance->DR & 0xFF); return 1; }
    if(sr & USART_SR_ORE)  { (void)huart2.Instance->DR; }
  } while(HAL_GetTick() - t0 < wait_ms);
  return 0;
}

A.3 逐字节增量匹配

见 6.2 节完整代码。调用约定:

c 复制代码
// 返回 HAL_OK:见到 OK 或 +MQTTCONNECTED
// 返回 HAL_ERROR:见到 ERROR / FAIL,或超时
// 无论成败,esp_last_resp 里都留有最近一次应答(用 Esp_Print_Resp 打印)

A.4 探活

c 复制代码
static uint8_t Esp_Probe(void)
{
  uint8_t i;
  probe_got_bytes = 0;

  for(i = 0; i < 3; i++)                                  // ① 115200 ×3
  {
    if(Esp_At_Ping()) { printf("[ESP] online @115200\r\n"); return 1; }
    HAL_Delay(300);
  }

  Uart2_SetBaudrate(9600);                                // ② 9600 ×3
  for(i = 0; i < 3; i++)
  {
    if(Esp_At_Ping()) { printf("[ESP] online @9600 (not 115200), keep 9600\r\n"); return 1; }
    HAL_Delay(300);
  }

  Uart2_SetBaudrate(115200);                              // ③ 回 115200 补 1 次
  if(Esp_At_Ping()) { printf("[ESP] online @115200 (was still booting)\r\n"); return 1; }

  if(probe_got_bytes)
    printf("[ESP] no valid answer, but bytes did arrive -> baud mismatch (or ESP still booting)\r\n");
  else
    printf("[ESP] zero bytes received -> USART2 RX not connected to ESP (dial on Bluetooth side? ESP unpowered?)\r\n");

  return 0;
}

A.5 MQTT 初始化主流程

见 4.2 节。要点回顾:

顺序 动作
1 HAL_Delay(3000) + Esp_Flush()
2 Esp_Probe(),失败直接返回
3 ATE0(3 次重试)
4 AT+CWMODE=1
5 AT+CWJAP="SSID","PWD"(20s)
6 AT+MQTTDISCONN=0 → 500ms → AT+MQTTCLEAN=0 → 500ms
7 AT+MQTTUSERCFG=0,1,"ClientID","","",0,0,""
8 AT+MQTTCONN=0,"broker.emqx.io",1883,1(15s ×2)
9 AT+MQTTSUB=0,"2604/cmd-HH",1
10 试发一条(PUBRAW),失败判整体失败
11 HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1)

A.6 发布(运行期)

见 10.2 节与 10.3 节。调用点:

c 复制代码
// Mqtt_Poll() 内部,每 2 秒一次
if(mqtt_ready && (HAL_GetTick() - last_pub_tick >= MQTT_PUB_PERIOD))
{
  last_pub_tick = HAL_GetTick();
  MqttPub();
}

A.7 最小验证:五步确认 MQTT 通

第一步:确认硬件

  • 拨码 → WiFi 侧;
  • ESP:VCC(3.3V/≥500mA)、GND、EN→3.3V、GPIO15→GND;
  • ESP TXD → PA3,RXD → PA2,共地;
  • 上电后串口能看到一大串 74880 乱码,然后出现 ready(用 115200 看是乱码,正常)。

第二步:确认探活

按 KEY2 → KEY4 → KEY4,日志应出现:

text 复制代码
[ESP] online @115200
  • zero bytes received → 回第一步;
  • bytes did arrive → 波特率不对,等 3 秒再试。

第三步:确认会话

日志里依次出现 setEchoOff : OK!!! ... MqttSub : OK!!! ... MqttPub(test) : OK!!! ... [NET] MQTT ready。

任何一步 FAIL,对照第十四节的表。

第四步:确认上报

主循环运行时,日志每 2 秒一条 [PUB] OK。H5 上温湿度开始跳动。

第五步:确认命令

H5 点一下风扇,日志应出现:

text 复制代码
[ESP] +MQTTSUBRECV:0,"2604/cmd-HH",6,FAN_ON
[MQTT] cmd: FAN_ON
[CMD] FAN ON

五步全过,整条链路就通了。接下来第 13 章,我们站到 H5 那一侧看。

相关推荐
我不会起名字3222 小时前
TCP 三次握手为什么是三次:丢包、重传与初始序列号
网络·tcp·原理·握手
Yang96112 小时前
鼎讯信通DXA-3S光纤熔接机:光缆教学中的技术精粹解析
网络
芯岭技术郦2 小时前
XL4460 FSK 无线透传模组
stm32·单片机·嵌入式硬件
xbzb3 小时前
IP、掩码、网关、DNS 到底谁管什么?网络基础与七种设备一次讲清
网络·网关·mac·ip·dns·掩码
硅基造物主3 小时前
STM32 从 0 到 1 做智能家居(十):串口与 printf 重定向
stm32·单片机·智能家居·usart·中断
前进吧-程序员4 小时前
C++20 协程入门实战:从 co_await 到一个 100 行的异步任务调度器
网络·c++·c++20
前进吧-程序员4 小时前
实战:C++20 协程 + io_uring 写一个 400 行的 echo server
网络·c++·c++20
云飞云共享云桌面5 小时前
机械制造研发部门:一台SolidWorks云主机替代 10 台传统图形工作站
运维·服务器·网络·3d·自动化·制造
loulanyue_6 小时前
从碎片文档到工业知识网络:TDengine工业知识图谱架构设计与大模型落地破局——读涛思数据OpenDay实录
网络·知识图谱·tdengine