STM32 和 ESP-01S 这套组合,做物联网课设、毕业设计、设备联网改造都绕不开。很多人拿到 ESP-01S 后第一反应是找现成云平台 SDK,或者直接套 MQTT 库,结果一换芯片、一换平台就抓瞎。我这里记录的是手打代码的方式:STM32 只负责串口收发和状态机,ESP-01S 跑 AT 固件,所有 AT 指令、MQTT 报文、心跳、订阅、发布都自己拼。云平台可以选 OneNET,也可以换成任意支持 MQTT 的物联网平台,参数按平台文档填。适合已经能点亮 LED、会串口收发、想搞懂“设备到底怎么连上云平台”的人。整套流程不依赖冷门库,核心代码都能自己敲出来,调试过程也能看清每一层到底在干什么。
1. 方案选型与整体链路设计
1.1 为什么不用现成 SDK 直接开搞
很多教程一上来就让人移植 ESP8266 的 SDK,或者在 STM32 上跑 MQTT 库。这样做的确能跑通,但对初学者不友好。ESP-01S 本身资源小,默认 AT 固件已经把 WiFi 协议栈、TCP/IP 都做好了,STM32 只需要通过串口发命令,就能建立 TCP 连接。既然目标只是连接云平台,没必要把整个网络协议栈再塞进 STM32。
手打代码的价值在于,你能清楚看到设备联网的每一步:先设置 WiFi 模式,再连路由器,再建立 TCP,再发 MQTT 连接报文,最后等平台返回 CONNACK。中间任何一步失败,你都能通过串口日志定位。用现成库时,报错往往是一句“连接失败”,排查范围太大。自己写一遍之后,换云平台、换模组、换芯片,思路都是通的。
还有一个现实原因:ESP-01S 的 AT 固件版本很多,有的支持 MQTT AT 指令,有的只支持 TCP。如果不确定手上模组固件,最稳妥的办法就是把它当纯 TCP 透传模块用,MQTT 报文自己在 STM32 里拼。这样不管模组是哪个版本,只要支持基础 AT 指令就能用。
1.2 STM32 和 ESP-01S 各自负责什么
这套方案里,STM32 是主控,负责三件事:第一,初始化串口,和 ESP-01S 通信;第二,维护一个接收缓冲区,处理 ESP-01S 返回的响应和云平台下发的数据;第三,拼装 MQTT 报文,按业务流程发布和订阅。ESP-01S 只负责联网和 TCP 传输,它不关心你发的是 MQTT 还是 HTTP,只负责把字节流送到云平台。
这样分工有个好处:STM32 的业务代码和网络代码可以分开写。网络层只提供esp_at_send、esp_tcp_send、esp_tcp_read这类接口,业务层负责调用。以后要换成 4G 模组或者以太网,只需要替换网络层,MQTT 层和业务层不用大改。
硬件连接上,我习惯用两个串口:USART1 接 USB-TTL,作为调试口,用来打印日志;USART2 接 ESP-01S,作为通信口。调试口非常重要,因为 ESP-01S 返回的数据有时是二进制,直接看很难受,把原始字节打印成 HEX 会方便很多。
1.3 云平台协议选择:MQTT 比 HTTP 更合适
云平台常见的接入协议有 HTTP、MQTT、TCP 透传。HTTP 适合低频上报,每次请求都要重新建立连接,开销大;TCP 透传最灵活,但平台侧通常要写解析脚本,通用性差。MQTT 是物联网平台最常用的协议,特点是长连接、发布订阅、心跳保活、支持 QoS,适合设备持续在线。
对 STM32 来说,MQTT 报文虽然需要自己拼,但结构并不复杂。固定头只有两个字节起步,剩余长度用变长编码,可变头和载荷按字段拼。只要把 CONNECT、SUBSCRIBE、PUBLISH、PINGREQ 这几个报文写出来,就能完成大部分业务。相比 HTTP 每次拼 JSON 请求,MQTT 的长连接反而更省资源。
选云平台时,优先看它是否提供 MQTT 接入。OneNET、阿里云 IoT、腾讯云 IoT、EMQX 自建服务都可以。不同平台的连接参数不同,但 MQTT 协议本身一样。这篇记录以通用 MQTT 参数为主,OneNET 的接入信息放在配置章节里说明。
2. 硬件接线与开发环境搭建
2.1 物料清单与供电坑
先列一下我实际用到的东西:STM32F103C8T6 最小系统板一块,ESP-01S 模组一个,ESP-01S 转接板一个,USB-TTL 调试器一个,3.3V 电源模块一个,杜邦线若干。如果手头是 ESP-01S 裸模组,最好配一个转接板,因为它的引脚间距小,直接接线容易短路。
供电是第一个大坑。ESP-01S 在发射 WiFi 信号时峰值电流能到 200mA 以上,有些甚至接近 300mA。STM32 最小系统板上的 3.3V LDO 通常只适合给 MCU 和少量外设供电,再带一个 WiFi 模组很容易掉电重启。我一开始用板载 3.3V,模组每次连 WiFi 就重启,串口打印一堆乱码。后来单独用一个 3.3V 电源模块给 ESP-01S 供电,GND 和 STM32 共地,问题立刻消失。
还有一点,ESP-01S 的 EN 脚和 GPIO0 脚要处理。正常运行时 EN 接 3.3V,GPIO0 悬空。如果 GPIO0 拉低,上电会进入下载模式。转接板一般已经处理好,裸模组要自己注意。电源正负极不要接反,ESP-01S 没有反接保护,接反很容易损坏。
2.2 串口接线和电平匹配
STM32 和 ESP-01S 之间只连四根线:STM32 的 USART2_TX 接 ESP-01S 的 RX,STM32 的 USART2_RX 接 ESP-01S 的 TX,VCC 接 3.3V,GND 接 GND。注意 TX 和 RX 要交叉,很多人接成 TX 对 TX,结果收不到任何响应。
电平方面,STM32F103 的 IO 是 3.3V,ESP-01S 也是 3.3V,可以直接连,不需要电平转换。如果用的是 5V 单片机,比如某些 Arduino,就要加电平转换,否则 ESP-01S 的 RX 可能被 5V 损坏。串口波特率初始化先设 115200,ESP-01S 默认通常是 115200。如果没响应,可以尝试 9600 或者 74880,有些老固件默认波特率不同。
调试串口 USART1 也接 USB-TTL,波特率同样设 115200。PC 端用串口助手打开,能看到 STM32 打印的日志。最好把 ESP-01S 的原始返回也通过调试串口转发出来,格式化成字符串或者 HEX,这样调试时不用来回插拔。
2.3 Keil 工程与串口收发框架
开发环境我用 Keil MDK5,芯片包装 STM32F1 系列。新建工程时选好芯片型号,勾选 CMSIS 的 Core 和 Device Startup,然后手动添加main.c、usart.c、esp01s.c、mqtt.c这些文件。不用勾选复杂中间件,这个方案不需要 RTOS,也不需要网络库。
串口初始化用 HAL 库或者标准库都行。我为了代码直观,HAL 库和标准库都用过。HAL 库的HAL_UART_Receive_IT适合做单字节中断接收,标准库直接写中断服务函数也可以。关键是接收不能丢字节。ESP-01S 返回的数据可能很快,如果主循环来不及读,就会丢包。
我建议用一个环形缓冲区,中断里只做一件事:把收到的字节放进缓冲区,然后重新开启接收中断。主循环或者状态机再去解析缓冲区。下面是一个简化的环形缓冲区结构:
#define ESP_RX_BUF_SIZE 512 typedef struct { uint8_t buf[ESP_RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuf_t; RingBuf_t esp_rx; void ring_push(RingBuf_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % ESP_RX_BUF_SIZE; if (next != rb->tail) { rb->buf[rb->head] = data; rb->head = next; } } int ring_pop(RingBuf_t *rb, uint8_t *data) { if (rb->head == rb->tail) return 0; *data = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) % ESP_RX_BUF_SIZE; return 1; }中断回调里这样写:
uint8_t esp_rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart2) { ring_push(&esp_rx, esp_rx_byte); HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1); } }初始化时调用一次HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1);让中断先跑起来。调试串口的打印函数最好也重定向一下,方便用printf输出日志。注意printf可能阻塞,不要在中断里调用。
3. ESP-01S AT 指令驱动层手写
3.1 串口中断接收与超时等待
AT 指令驱动层最核心的两个函数,一个是发送命令,一个是等待响应。发送命令时,先把接收缓冲区清空,避免旧数据干扰;然后通过串口发送命令字符串,末尾加\r\n;接着在超时时间内不断从环形缓冲区取数据,拼成字符串,查找期望的响应关键字。
等待响应不能死等,要有超时。ESP-01S 有时候不回复,如果代码里写while(!found),整个程序就卡死了。我一般设 2 秒到 5 秒的超时,具体看命令。连接 WiFi 可能耗时较长,可以设 10 秒。超时后返回失败,业务层决定是否重试。
下面是一个简化实现:
uint8_t esp_wait_ack(const char *ack, uint32_t timeout_ms) { char resp[256]; uint16_t idx = 0; uint32_t start = HAL_GetTick(); memset(resp, 0, sizeof(resp)); while ((HAL_GetTick() - start) < timeout_ms) { uint8_t ch; while (ring_pop(&esp_rx, &ch)) { if (idx < sizeof(resp) - 1) { resp[idx++] = ch; resp[idx] = '\0'; } if (strstr(resp, ack) != NULL) { return 0; } } } return 1; }这个实现没有处理二进制数据,只适合 AT 阶段的文本响应。MQTT 阶段返回的 CONNACK 是二进制,不能用strstr判断,要单独解析。
3.2 AT 发送、重试与状态机
发送函数可以这样封装:
uint8_t esp_send_cmd(const char *cmd, const char *ack, uint32_t timeout_ms) { while (ring_pop(&esp_rx, &(uint8_t){0})); // 清空缓冲区,实际写法要循环弹出 uart_send_string(&huart2, cmd); uart_send_string(&huart2, "\r\n"); return esp_wait_ack(ack, timeout_ms); }实际代码里不要用复合字面量取地址那种写法,老老实实用一个临时变量循环弹出。发送后等待OK或者指定响应。如果失败,可以重试两次。但重试不是万能的,比如 WiFi 密码错了,重试一百次也连不上。所以重试次数不要太多,失败后要打印错误日志。
状态机适合管理联网流程。比如定义状态:ESP_STATE_IDLE、ESP_STATE_WIFI_CONNECTING、ESP_STATE_TCP_CONNECTING、ESP_STATE_MQTT_CONNECTING、ESP_STATE_ONLINE。主循环里根据状态调用对应函数,成功就切换下一个状态,失败就回到空闲并延时重试。这样不会因为某一步卡死导致整个程序无响应。
3.3 连接 WiFi 和 TCP 的完整时序
上电后先发AT,确认模组有响应。如果返回OK,说明串口和波特率正确。接着发AT+CWMODE=1,设置为 Station 模式。然后发AT+CWJAP="WiFi名称","WiFi密码",等待WIFI CONNECTED和WIFI GOT IP,最后返回OK。这一步耗时较长,超时要设大一点。
连上 WiFi 后,如果只需要一条 TCP 连接,发AT+CIPMUX=0,设置单连接模式。然后发AT+CIPSTART="TCP","mqtt.example.com",1883,把域名和端口换成云平台提供的地址。如果模组不支持域名解析,就先用电脑解析出 IP,再填 IP。连接成功后返回CONNECT和OK。
发送数据时,先发AT+CIPSEND=长度,等模组返回>提示符,再发送指定长度的原始数据。注意长度是字节数,不是字符数。MQTT 报文里可能有0x00,串口发送函数不能遇到0x00就停止,要按长度发送。
void esp_tcp_send(uint8_t *data, uint16_t len) { char cmd[32]; sprintf(cmd, "AT+CIPSEND=%d", len); esp_send_cmd(cmd, ">", 2000); uart_send_bytes(&huart2, data, len); esp_wait_ack("SEND OK", 5000); }接收云平台数据时,ESP-01S 会返回+IPD,长度:数据。需要解析这个格式,把数据部分取出来,再交给 MQTT 层处理。如果数据里包含二进制,不能用字符串函数找冒号,要按字节解析。先找到+IPD,,读长度,跳过冒号,再读指定长度的数据。
4. MQTT 报文手写与云平台连接
4.1 MQTT 报文结构快速拆解
MQTT 报文分三部分:固定头、可变头、载荷。固定头第一个字节高 4 位是报文类型,低 4 位是标志位。比如 CONNECT 是0x10,CONNACK 是0x20,PUBLISH 是0x30,SUBSCRIBE 是0x80,PINGREQ 是0xC0。固定头后面是剩余长度,表示可变头加载荷一共多少字节。
剩余长度是变长编码,最多四个字节。每个字节低 7 位是数据,最高位表示是否还有后续字节。比如剩余长度 64,编码就是一个字节0x40;剩余长度 200,编码是两个字节0xC8 0x01。计算方法是:先取长度除以 128 的余数,如果商大于 0,最高位置 1,继续;直到商为 0。
CONNECT 报文的可变头包括协议名MQTT、协议级别0x04、连接标志、Keep Alive 时间。载荷包括 Client ID、Will Topic、Will Message、Username、Password。连接标志决定这些字段是否存在。比如设置 Clean Session 位,表示不保存会话状态。
4.2 CONNECT 报文拼装与剩余长度计算
先写一个剩余长度编码函数:
uint8_t mqtt_encode_len(uint8_t *buf, uint32_t len) { uint8_t i = 0; do { uint8_t byte = len % 128; len /= 128; if (len > 0) byte |= 0x80; buf[i++] = byte; } while (len > 0); return i; }然后拼 CONNECT 报文。为了代码清晰,我用一个临时缓冲区,先写固定头,预留剩余长度位置,写完可变头和载荷后再回填剩余长度。注意剩余长度不包括固定头第一个字节和剩余长度本身,只包括后面的字节。
uint16_t mqtt_build_connect(uint8_t *buf, const char *client_id, const char *username, const char *password, uint16_t keepalive) { uint16_t pos = 0; buf[pos++] = 0x10; // CONNECT uint8_t rl_pos = pos++; // 协议名 buf[pos++] = 0x00; buf[pos++] = 0x04; buf[pos++] = 'M'; buf[pos++] = 'Q'; buf[pos++] = 'T'; buf[pos++] = 'T'; buf[pos++] = 0x04; uint8_t flags = 0x02; // Clean Session if (username) flags |= 0x80; if (password) flags |= 0x40; buf[pos++] = flags; buf[pos++] = keepalive >> 8; buf[pos++] = keepalive & 0xFF; // Client ID uint16_t len = strlen(client_id); buf[pos++] = len >> 8; buf[pos++] = len & 0xFF; memcpy(&buf[pos], client_id, len); pos += len; // Username if (username) { len = strlen(username); buf[pos++] = len >> 8; buf[pos++] = len & 0xFF; memcpy(&buf[pos], username, len); pos += len; } // Password if (password) { len = strlen(password); buf[pos++] = len >> 8; buf[pos++] = len & 0xFF; memcpy(&buf[pos], password, len); pos += len; } // 回填剩余长度 uint32_t remain = pos - rl_pos - 1; uint8_t rl[4]; uint8_t rl_len = mqtt_encode_len(rl, remain); memmove(&buf[rl_pos + rl_len], &buf[rl_pos + 1], remain); memcpy(&buf[rl_pos], rl, rl_len); return pos + rl_len - 1; }发送 CONNECT 后,云平台会返回 CONNACK。CONNACK 报文是0x20 0x02 0x00 0x00,最后一位返回码为0x00表示连接成功。如果返回0x01到0x05,分别表示协议版本不对、Client ID 被拒绝、服务不可用、用户名密码错误、未授权。解析时从+IPD数据里取前几个字节判断即可。
4.3 订阅、发布、心跳的实现
订阅报文类型是0x82,固定头后面是剩余长度、包标识符、主题过滤器、QoS。包标识符每发一次订阅要递增,避免重复。主题可以订阅平台下发的命令主题,QoS 一般用 0 或 1。手写时注意主题长度是两个字节。
发布报文类型是0x30,QoS 0 时没有包标识符,直接跟主题长度、主题、载荷。如果 QoS 1,标志位要改成0x32,并加包标识符。大多数传感器上报用 QoS 0 就够了,掉一两条数据可以接受。如果控制指令要求可靠,可以用 QoS 1。
uint16_t mqtt_build_publish(uint8_t *buf, const char *topic, const uint8_t *payload, uint16_t plen) { uint16_t pos = 0; buf[pos++] = 0x30; // PUBLISH, QoS 0 uint16_t topic_len = strlen(topic); uint32_t remain = 2 + topic_len + plen; pos += mqtt_encode_len(&buf[pos], remain); buf[pos++] = topic_len >> 8; buf[pos++] = topic_len & 0xFF; memcpy(&buf[pos], topic, topic_len); pos += topic_len; memcpy(&buf[pos], payload, plen); pos += plen; return pos; }心跳报文PINGREQ最简单,两个字节:0xC0 0x00。云平台一般要求 Keep Alive 时间内至少有一次报文交互,每隔半个 Keep Alive 时间发一次心跳。比如 Keep Alive 设 60 秒,就每 30 秒发一次PINGREQ。如果超过 1.5 倍 Keep Alive 没收到任何报文,平台会断开连接。
心跳最好放在定时器中断或者主循环的时间判断里,不要用HAL_Delay阻塞。主循环里记录last_ping_tick,每次循环判断HAL_GetTick() - last_ping_tick > 30000,满足就发心跳并更新。这样不影响其他任务。
5. 云平台侧配置与联调
5.1 创建产品与设备
以 OneNET 为例,登录平台后创建产品,选择 MQTT 接入协议。产品创建后,平台会生成产品 ID、鉴权信息等。然后在产品下创建设备,设备名称可以自定义,比如stm32_esp01s_01。平台会生成设备 ID、设备密钥,或者让你自己填鉴权信息。
不同版本的 OneNET 接入参数略有差异。老版多协议接入里,MQTT 服务器地址可能是mqtt.heclouds.com,端口6002,Client ID 填设备 ID,Username 填产品 ID,Password 填鉴权 token。新版 OneNET Studio 的接入地址、端口、主题格式又不一样,具体以平台文档为准。如果不想折腾平台鉴权,可以先用公共 MQTT 服务器测试,比如broker.emqx.io,端口1883,不需要用户名密码。等 MQTT 报文跑通后,再把参数换成 OneNET 的。
| 参数项 | 公共 MQTT 测试 | OneNET 示例 | 说明 |
|---|---|---|---|
| 服务器地址 | broker.emqx.io | mqtt.heclouds.com | 以平台文档为准 |
| 端口 | 1883 | 6002 | 明文 MQTT 端口 |
| Client ID | 任意唯一字符串 | 设备 ID | 不能重复 |
| Username | 可空 | 产品 ID | 按平台要求 |
| Password | 可空 | 鉴权 token | 可能需工具生成 |
| 发布主题 | 自定义 | 平台定义 | 注意主题权限 |
创建完设备和参数后,记下这些信息:服务器地址、端口、Client ID、Username、Password、发布主题、订阅主题。把这些填到 STM32 代码的宏定义里,编译烧录。
5.2 参数映射与在线调试
STM32 代码里不要写死太多字符串,最好用宏定义集中管理:
#define MQTT_BROKER "broker.emqx.io" #define MQTT_PORT 1883 #define MQTT_CLIENT_ID "stm32_esp01s_01" #define MQTT_USERNAME "" #define MQTT_PASSWORD "" #define MQTT_PUB_TOPIC "stm32/esp01s/data" #define MQTT_SUB_TOPIC "stm32/esp01s/cmd" #define MQTT_KEEPALIVE 60连接流程按顺序执行:先发AT,再AT+CWMODE=1,再AT+CWJAP,再AT+CIPMUX=0,再AT+CIPSTART,然后发 CONNECT 报文。每步成功打印一条日志,失败就重试。调试串口上能看到类似:
AT -> OK CWMODE -> OK CWJAP -> WIFI CONNECTED CWJAP -> WIFI GOT IP CWJAP -> OK CIPSTART -> CONNECT CIPSTART -> OK MQTT CONNECT -> CONNACK 0x00看到CONNACK 0x00时,说明设备已经连上云平台。如果平台侧显示设备在线,但在线状态一会就掉,通常是心跳没发或者 Keep Alive 设置太短。先检查PINGREQ是否按时间发出。
5.3 数据上报与命令下发
数据上报就是拼 PUBLISH 报文,把传感器数据转成字符串或者 JSON,作为载荷发出去。比如 DHT11 温湿度数据,可以拼成{"temp":25,"humi":60}。注意 JSON 字符串长度要算准,PUBLISH 剩余长度要包含主题长度和载荷长度。
命令下发时,云平台会向订阅主题发 PUBLISH 报文。STM32 收到+IPD数据后,先判断是不是 MQTT PUBLISH。固定头第一个字节高 4 位是0x30,说明是 PUBLISH。然后解析剩余长度、主题长度、主题、载荷。如果主题匹配订阅主题,就把载荷取出来执行。比如收到{"led":1},就点亮 LED。
解析 PUBLISH 时要注意,主题长度和包标识符的位置。QoS 0 没有包标识符,主题长度直接跟在剩余长度后面。QoS 1 会多两个字节的包标识符。如果订阅时用了 QoS 1,解析时就要跳过这两个字节。建议先统一用 QoS 0 跑通,再改 QoS 1。
| 报文类型 | 固定头首字节 | 用途 | 注意点 |
|---|---|---|---|
| CONNECT | 0x10 | 连接平台 | 剩余长度回填容易错 |
| CONNACK | 0x20 | 连接确认 | 返回码 0x00 才算成功 |
| PUBLISH | 0x30 | 上报/下发数据 | QoS 决定有无包标识符 |
| SUBSCRIBE | 0x82 | 订阅主题 | 包标识符要递增 |
| PINGREQ | 0xC0 | 心跳 | 两个字节,别拼错 |
| DISCONNECT | 0xE0 | 断开连接 | 主动断开时发 |
6. 常见问题与排查实录
6.1 ESP-01S 不响应或者返回乱码
最常见的原因是波特率不对。ESP-01S 默认 115200,但有些固件是 9600 或 74880。可以先在 115200 下发AT,没反应就换 9600。如果返回乱码,说明波特率不匹配,多试几个常见值。还有可能是供电不足,模组启动时电压跌落,复位后打印启动信息,看起来像乱码。用独立 3.3V 电源供电,并在电源脚附近加一个 100uF 电解电容和一个 0.1uF 陶瓷电容,能缓解瞬间掉压。
接线错误也会导致不响应。TX 和 RX 必须交叉,GND 必须共地。如果只连了 TX、RX 和 VCC,没连 GND,串口电平没有参考,通信肯定失败。另外检查 EN 脚是否拉高,GPIO0 是否悬空。如果 GPIO0 被拉低,模组进入下载模式,AT 固件不运行。
6.2 TCP 连上但 MQTT 被拒绝
TCP 连接成功只说明网络通了,MQTT 连接失败通常是参数问题。CONNACK 返回码能直接告诉你原因:0x01协议版本不对,检查 CONNECT 报文里协议级别是不是0x04;0x02Client ID 被拒绝,检查 Client ID 是否唯一、是否超过平台限制;0x04用户名密码错误,检查 Username 和 Password;0x05未授权,检查设备是否被禁用或者 token 过期。
OneNET 的鉴权 token 有时效性,过期后需要重新生成。如果设备之前在线,突然连不上,先看 token 是否过期。公共 MQTT 服务器如果不需要用户名密码,CONNECT 标志位不要设置用户名和密码位,否则服务器可能拒绝。
还有一点,MQTT 报文里的字符串长度字段是两个字节,低字节在前,高字节在后。比如 Client ID 长度 13,要写成0x00 0x0D。如果写成0x0D 0x00,平台解析长度会变成一个很大的数,直接拒绝连接。
6.3 数据发不出去或者频繁掉线
数据发不出去,先看AT+CIPSEND是否返回>。如果没有返回,可能是 TCP 连接已经断开,或者模组忙。可以先发AT+CIPSTATUS查看连接状态。如果返回CLOSED,需要重新走AT+CIPSTART和 MQTT CONNECT 流程。
频繁掉线通常是心跳问题。Keep Alive 设 60 秒,但代码里没有按时发PINGREQ,平台在 90 秒左右会断开。检查心跳定时逻辑,确保主循环没有长时间阻塞。如果用了HAL_Delay(1000)这类阻塞延时,心跳可能被推迟。把延时改成非阻塞的时间判断,或者把心跳放在定时器中断里设置标志位。
缓冲区溢出也会导致数据错乱。ESP-01S 返回的+IPD数据可能比较长,如果接收缓冲区只有 128 字节,而云平台下发 200 字节的 JSON,后面的数据就被丢了。把缓冲区设大一点,至少 512 字节。解析时先判断长度,不够就丢弃或者分片处理。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| AT 无响应 | 波特率、接线、供电 | 换波特率,查 TX/RX,单独供电 |
| 返回乱码 | 波特率不匹配、掉电 | 试 9600/74880,加电容 |
| TCP 连接失败 | WiFi 未连、地址端口错 | 发 AT+CIPSTATUS,检查 IP |
| CONNACK 0x04 | 用户名密码错 | 核对三元组,重新生成 token |
| 上报无数据 | 主题无权限、长度算错 | 平台查日志,抓包看报文 |
| 设备频繁离线 | 心跳未发、Keep Alive 短 | 检查 PINGREQ 周期,加大 Keep Alive |
| 收到数据乱码 | 解析越界、QoS 不符 | 按长度解析,统一 QoS 0 |
7. 实战心得与后续扩展
7.1 长时间运行的几个处理
设备联网不能只跑几分钟,要连续跑几天才算过关。我一般会加三个处理:第一,看门狗。STM32 的独立看门狗或者窗口看门狗,在主循环里喂狗。如果程序跑飞,看门狗复位后重新联网。第二,断线重连。MQTT 连接断开后,状态机回到 TCP 连接之前,重新走一遍流程。重连不要太频繁,每次失败后延时 5 秒到 10 秒。第三,日志分级。调试阶段打印所有 AT 交互,正式运行时只打印关键状态,减少串口占用。
ESP-01S 长时间运行偶尔会死机,表现为不回复 AT。这种情况可以在代码里加一个“AT 探活”,每隔几分钟发一次AT,连续三次没响应就控制 ESP-01S 的复位脚,或者用继电器断电重启。转接板上如果有 RST 脚,可以接 STM32 的一个 GPIO,低电平复位。注意复位后要重新初始化 WiFi 和 MQTT。
7.2 后续可以扩展的方向
跑通基础连接后,可以往几个方向扩展。第一,接传感器。DHT11、DS18B20、光敏电阻、土壤湿度传感器都可以,把数据拼成 JSON 上报。第二,接执行器。继电器、LED、蜂鸣器,通过订阅命令主题实现远程控制。第三,加 OLED 显示。本地显示网络状态、IP 地址、温湿度,调试时不用一直看电脑。第四,用 FreeRTOS。把网络任务、传感器任务、显示任务分开,心跳和重连放在独立任务里,主循环更清晰。
如果产品要量产,建议把 ESP-01S 换成支持 MQTT AT 固件的模组,或者换成 ESP32,直接跑 MQTT 库。但作为学习过程,手打一遍 MQTT 报文非常有价值。以后看任何物联网平台的接入文档,都能快速理解那些参数到底对应协议里的哪个字段。
7.3 个人调试体会
我在实际调试时,最大的时间不是花在写代码上,而是花在确认 ESP-01S 到底有没有收到命令。后来我养成了一个习惯:所有发给 ESP-01S 的 AT 指令和收到的原始数据,都通过调试串口打印出来,二进制数据用 HEX 格式显示。这样一眼就能看出是没发出去,还是发出去了但响应被解析错。
另一个体会是,不要一上来就接云平台。先用公共 MQTT 服务器把 CONNECT、SUBSCRIBE、PUBLISH 跑通,确认 MQTT 报文拼装没问题。然后再换 OneNET,把 Client ID、Username、Password 换成平台参数。这样出问题时,你能确定是 MQTT 报文的问题,还是平台鉴权的问题。每次只改一个变量,排查效率会高很多。
最后再分享一个小技巧:剩余长度计算和回填是手写 MQTT 最容易出错的地方。可以在拼完报文后,把整个报文按字节打印出来,对照 MQTT 协议文档手工核对一遍。特别是 CONNECT 报文,剩余长度、协议名长度、Client ID 长度、Username 长度、Password 长度,任何一个长度字段写错,平台都会直接拒绝。核对几次之后,后面再写就顺手了。