简介:STM32 HAL库驱动ESP8266的完整工程源码包,面向嵌入式物联网开发者,解决STM32通过UART与ESP8266进行AT指令交互、实现Wi-Fi透传的关键问题。资源共77个文件,以C语言源文件(21个)和头文件(48个)为主,包含HAL库驱动、外设初始化及主程序逻辑,同时提供IAR/Keil工程文件(.ioc、.uvprojx)与烧录hex文件,压缩包约549KB,可直接导入工程对照学习。已有2520人学习下载。代码从串口配置、AT指令封装到透传数据收发均有清晰分层,辅以工程裁剪脚本,便于二次开发和移植。对于入门STM32+ESP8266透传开发、希望了解HAL库工程结构并快速实现联网通信的读者,具备实用参考价值。
1. STM32 HAL 库操作 ESP8266,先想清楚你写的其实是串口程序
很多人在 ESP8266 上栽跟头,不是不懂 TCP/IP,而是没转过弯来:STM32 和 ESP8266 之间没有 SPI 接口连接、没有“网络通信芯片”一说,它们之间就是一根 UART 线。你写的 HAL 库代码,本质上是在做“通过串口向一个带 WiFi 功能的协处理器发字符串、收字符串”的活,而 ESP8266 自己完成 TCP、UDP、DHCP、DNS 这些栈。想通这一点,整个项目的复杂度和排查范围就立刻缩小了。
依赖 HAL 库的 USART 驱动,接上 ESP8266 的 TX/RX,通过 AT 指令集完成联网、建立 TCP 连接、收发数据,这一套组合在智能台灯、鱼缸控制器、WS2812 灯带这类低成本物联网场景里依然是最常见的选型。对 STM32 开发者来说,难点往往不在 HAL 库 API 本身,而在三件事:串口数据怎么可靠地收下来、AT 指令的应答怎么解析、掉线之后怎么自动恢复。本文按这个路线,把从 CubeMX 配置到 AT 报文解析的完整思路过一遍。
2. ESP8266 的 AT 指令模型,和 HAL 库串口中断的设计选择
2.1 AT 固件的三件事:指令、应答、数据的格式约定
ESP8266 出厂固件运行的是一套 Hayes 风格的 AT 指令系统。所有指令以 ASCII 文本行构成,以\r\n作为一行结束。一条指令发出后,模块返回的应答分成三类:立即应答行(如OK、ERROR)、异步上报行(如+IPD,4:test、WIFI DISCONNECT)、以及进入透传模式后的裸数据流。这三种返回形态混杂在同一根 RX 线上,所以 STM32 侧接收逻辑不能只是“收到一帧就丢给用户”,得有明确的协议解析。
以最常见的联网时序为例:
AT # 返回 OK AT+CWMODE=1 # 返回 OK,设置 Station 模式 AT+CWJAP="ssid","password" # 返回 OK 或 +CWJAP:ERROR AT+CIFSR # 返回本机 IP AT+CIPSTART="TCP","192.168.1.10",8080 # 返回 CONNECT / OK AT+CIPSEND=<len> # 返回 >,随后发送指定字节的数据这个过程中,AT+CWJAP是一条慢指令,运气不好时 DHCP 得跑十几秒才返回;AT+CIPSEND则要求你在模块打出>提示符后再发数据。如果你不管时序、一口气全塞进去,模块会直接把后半段当成指令解析,然后回你一个ERROR。
2.2 HAL 库接收方案:中断、DMA、空闲中断怎么选
HAL 库给了HAL_UART_Receive、HAL_UART_Receive_IT、HAL_UART_Receive_DMA三条路径。对 ESP8266 这种“应答不固定、长度不可预知”的设备,轮询绝对不要用,会卡死主循环;中断方式配合单字节缓存可行,但 CPU 开销高;常规工程我都用 DMA 循环接收,再用串口的空闲中断(IDLE)判断一帧结束。
| 方案 | 接收长度 | CPU 占用 | 帧边界判断 | 适合场景 |
|---|---|---|---|---|
| 轮询 | 任意 | 高 | 手动 | 调试临时用 |
| 单字节中断 | 任意 | 高 | 手动拼帧 | 数据量小、实时性要求低 |
| DMA 循环 + IDLE 中断 | 任意 | 低 | 硬件自动判定 | 推荐,AT 应答和 IPD 数据都适用 |
| 双缓冲 DMA | 任意 | 低 | 硬件判定 + 均匀拷贝 | 高频收发,比如连续 TCP 上行 |
HAL 库最新版本在stm32f1xx_hal_uart.h里处理空闲中断的方式比较繁琐:先使能UART_IT_IDLE,然后在中断回调里读__HAL_UART_GET_FLAG(&huart, UART_FLAG_IDLE),先置位再读取数据寄存器才能清掉标志。下面是 CubeMX 生成工程里最常见的一段扩展:
uint8_t esp_rx_buf[512]; // DMA 环形缓冲区 uint16_t esp_rx_last_pos; // 上一次处理到的位置 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { uint16_t pos = 0; if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); pos = sizeof(esp_rx_buf) - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); } // pos 到 esp_rx_last_pos 之间的数据就是完整一帧 esp_frame_process(&esp_rx_buf[esp_rx_last_pos], pos - esp_rx_last_pos); esp_rx_last_pos = pos; } }esp_rx_last_pos用来记录上次处理到哪,因为 DMA 循环缓冲区的写指针会绕回开头。当空闲中断触发时,__HAL_DMA_GET_COUNTER返回的是还剩多少字节没写,用缓冲区长度减掉它就是当前写位置。这里有一个隐藏约束:一帧数据不能超过缓冲区长度,否则 DMA 覆盖后就没有完整帧了。所以对 AT 应答这种小帧,512 字节足够;但如果做透传,建议把缓冲提到 2048,并在应用层做分包。
2.3 复位和 GPIO 控制是很多人忽略的一环
ESP8266 的RST引脚低电平复位,CH_PD(也叫 EN)引脚必须拉高才能工作。CubeMX 里把两个引脚配置为推挽输出,驱动代码里加一个软复位函数:
void esp_hard_reset(void) { HAL_GPIO_WritePin(ESP_RST_GPIO_Port, ESP_RST_Pin, GPIO_PIN_RESET); HAL_Delay(50); // 持续拉低 50ms,确保是完整复位 HAL_GPIO_WritePin(ESP_RST_GPIO_Port, ESP_RST_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(ESP_EN_GPIO_Port, ESP_EN_Pin, GPIO_PIN_SET); HAL_Delay(100); // 等待模块启动,此时串口会输出 boot 日志 }模块上电初期会输出一段 115200 波特率的乱码启动日志,有些固件还会自动执行一次重启,所以正确做法是:先等 1 秒,发AT+RST,再等 2 秒,之后才开始跑 AT 握手流程。否则第一条AT撞在启动日志里会被淹没。
3. 用 CubeMX 生成 HAL 库工程,实现 ESP8266 最小联网命令
3.1 引脚分配和串口参数的实际组态
STM32 和 ESP8266 之间只需要 TX、RX、GND,再加上 RST 和 EN。注意 RX 和 TX 必须交叉连接:STM32 的 TX 接 ESP8266 的 RX,STM32 的 RX 接 ESP8266 的 TX。CubeMX 里我用两个串口:USART1 作为调试日志输出(115200,PA9/PA10),USART2 接 ESP8266(115200,PA2/PA3),并开启 USART2 的 DMA 接收。
| 配置项 | USART2 参数 | 原因 |
|---|---|---|
| Baud Rate | 115200 | ESP8266 默认 AT 固件波特率,兼容性最好 |
| Word Length | 8 Bits | 标准 AT 固件帧格式 |
| Parity | None | 无校验,减少解析复杂度 |
| Stop Bits | 1 | 默认 |
| DMA RX Mode | Circular | 循环接收才能配合空闲中断取整帧 |
| DMA RX Data Width | Byte | 单字节宽度,和缓冲区类型一致 |
串口参数里最容易踩坑的是波特率。ESP8266 出厂固件一般是 115200,但有些商家烧录了 9600 的固件。上电后用 USB 转 TTL 直接看模块主动输出的 boot 日志就能判断波特率,日志能看懂就是对的,乱码就是错的。代码里把波特率做成宏,换模块时只改一处。
另外 GPIO 的两个细节:不要让 EN 引脚悬空,模块可能会随机复位;STM32 的 3.3V 如果由板载稳压器提供,注意 ESP8266 射频发射瞬间电流可以冲到 300mA,很多开发板的 LDO 撑不住,现象是模块连上 WiFi 后不定时重启。我一般单独给 ESP8266 配一个 AMS1117-3.3,输入 5V。
3.2 最小指令执行函数和应答等待逻辑
有了初始化代码,第一个要跑通的函数是“发送一条 AT 指令并等待 OK”。最直接的做法是发送后轮询接收环形缓冲区:
uint8_t esp_send_cmd_wait_ok(char *cmd, uint32_t timeout_ms) { uint8_t resp[128]; uint16_t resp_len = 0; uint32_t tick = HAL_GetTick(); esp_uart_send(cmd, strlen(cmd)); // 统一在外部拼好 \r\n memset(resp, 0, sizeof(resp)); while (HAL_GetTick() - tick < timeout_ms) { uint16_t len = esp_frame_read(resp + resp_len, sizeof(resp) - resp_len - 1); if (len > 0) { resp_len += len; if (strstr((char*)resp, "OK") != NULL) return RESP_OK; if (strstr((char*)resp, "ERROR") != NULL) return RESP_ERROR; } } return RESP_TIMEOUT; }esp_frame_read从第 2 章那个环形缓冲区里取走数据,取完后更新esp_rx_last_pos。代码里的超时非常重要:AT+CIPSTART在 DNS 解析失败时会拖几秒,你设 500ms 超时就白白误判了。我一般根据指令类型给不同超时:普通配置指令 1000ms,TCP 连接 5000ms,JAP 联网 15000ms。
3.3 跑通联网流程的最小代码
下面这段代码可以在复位后直接跑,依次完成:测试模块在线、设为 Station 模式、连接 WiFi、查询 IP、建 TCP 连接、发数据。为了阅读方便省略了错误处理,实际工程每一步失败都要做重试或错误上报。
void esp_demo_link_and_send(void) { char buf[128]; // 1. 先测模块是否在线 esp_send_cmd_wait_ok("AT\r\n", 1000); // 2. 软件复位,清掉之前残留的连接状态 esp_send_cmd_wait_ok("AT+RST\r\n", 3000); HAL_Delay(2000); // 等待模块重启完成 // 3. 模式:1=Station,2=AP,3=双模 esp_send_cmd_wait_ok("AT+CWMODE=1\r\n", 1000); // 4. 连接 WiFi:这个指令耗时最长,要等 DHCP sprintf(buf, "AT+CWJAP=\"%s\",\"%s\"\r\n", WIFI_SSID, WIFI_PASS); esp_send_cmd_wait_ok(buf, 15000); // 5. 建立 TCP 连接到服务器 sprintf(buf, "AT+CIPSTART=\"TCP\",\"%s\",%d\r\n", SERVER_IP, SERVER_PORT); esp_send_cmd_wait_ok(buf, 5000); // 6. 发送数据:先发 CIPSEND,等到 > 后发正文 esp_send_cmd_wait_ok("AT+CIPSEND=5\r\n", 1000); esp_uart_send((uint8_t*)"hello", 5); esp_send_cmd_wait_ok("AT+CIPCLOSE\r\n", 1000); }这段代码里最值得说的细节是第 6 步的通知模式。AT+CIPSEND=5返回的不是OK而是>,esp_send_cmd_wait_ok内部会因找不到OK而超时——所以这里不能用等待 OK 的函数,要新写一个esp_wait_prompt('>', 1000)。很多初学者在这个指令上卡住,就是没分清应答形态。
AT+CWJAP也一样,它慢在 DHCP 阶段,返回结果是WIFI CONNECTED、WIFI GOT IP和OK三行连在一起。strstr扫OK的时候没问题,但如果网络环境里有其他OK出现(比如模块状态上报),就会误判。严谨的做法是等WIFI GOT IP或检测完整的行尾OK\r\n。
4. 接收不定长数据:HAL 库空闲中断与 AT+IPD 报文解析
4.1 把 HAL 库的接收事件变成一帧一回调
CubeMX 生成的 HAL 库函数里,DMA 循环接收需要配合空闲中断才能拿到“一帧结束”的边界。上一章给了 CubeMX 开启 DMA 后的接口,这里补一个能直接用的完整初始化片段。要注意清空闲标志的顺序:必须先读 SR 再读 DR,否则标志清不掉,会导致频繁进入中断。
void esp_uart_init_dma_idle(UART_HandleTypeDef *huart) { // 使能空闲中断 __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); // 启动 DMA 循环接收,固定收满整个缓冲区后自动从头开始 HAL_UART_Receive_DMA(huart, esp_rx_buf, sizeof(esp_rx_buf)); }空闲中断触发后,从 DMA 计数寄存器拿到当前写位置,就能把数据从环形缓冲区里完整拷贝出来。把这段逻辑封装好,HAL 库的复杂度就被隔离了,上层代码永远面对“完整的、已拼好的帧”。
需要特别注意 DMA 半满和全满中断。如果开了HAL_UART_Receive_DMA但没处理 DMA 完成回调,数据在绕回时没有影响;但如果你在中断里调用HAL_UART_Receive_DMA重新启动,反而会破坏环形缓冲区的连续性。所以这里刻意不调用任何 HAL 接收函数,只在启动时调用一次。
4.2 解析 +IPD 报文,按长度字段准确切片
ESP8266 在非透传模式下收到 TCP 数据,上报格式是:
+IPD,<len>:<payload><len>是 payload 的字节数,payload 里可能含任意二进制内容,包括\r\n和OK这些字符串。所以接收解析绝不能按行处理,必须先用strstr定位+IPD,,再提取长度字段,按长度切出 payload:
void esp_frame_process(uint8_t *frame, uint16_t len) { uint8_t *p = frame; uint8_t *end = frame + len; char len_str[8]; uint16_t payload_len; while (p < end) { uint8_t *ipd = my_strstr(p, end - p, "+IPD,"); if (ipd == NULL) break; // 跳过 +IPD, ipd += 5; // 解析长度数字,直到冒号 uint8_t *colon = my_strchr(ipd, end - (ipd), ':'); if (colon == NULL) break; uint16_t digits = colon - ipd; if (digits > sizeof(len_str) - 1) break; // 长度字段异常,保护缓冲 memcpy(len_str, ipd, digits); len_str[digits] = '\0'; payload_len = (uint16_t)atoi((char*)len_str); // 数据区是冒号后的 payload_len 个字节 uint8_t *payload = colon + 1; if (payload + payload_len > end) break; // 数据还没收完整,等下一帧 user_tcp_data_callback(payload, payload_len); p = payload + payload_len; } }代码里的my_strstr和my_strchr是我在嵌入式里常用的两个函数,作用是在指定内存范围里做字符串搜索而不越界,避免调用标准库strstr时读到非字符串区域。两个都要求用户手工维护长度。
特别注意payload + payload_len > end这个判断。TCP 分包是网络常态,一个+IPD报文可能被拆成两次串口接收到达。这时不要丢弃,要保留半包数据,等方法里的流程走到p继续时,把已处理的数据拷贝到帧头,等待剩余部分拼上来。最简单的做法是维护一个长度计数:
typedef struct { uint8_t buf[2048]; uint16_t count; uint16_t expected_len; uint8_t in_ipd; } esp_ipd_parser_t;解析器进入等待状态后,每一帧到达都先填充buf,直到count == expected_len再触发用户回调。这个设计能同时处理粘包和半包,是 TCP over UART 场景最实用的方案。
4.3 透传模式的收发和退出方法
TCP 长连接场景下,逐帧AT+CIPSEND的开销太大:每次都要发指令、等提示符、再发数据,实际吞吐率低,而且 AT 指令和业务数据混在同一根串口线上,解析更容易出错。常见做法是进入透传模式:
// 进入透传模式,完成后所有串口数据直接走 TCP esp_send_cmd_wait_ok("AT+CIPMODE=1\r\n", 1000); esp_send_cmd_wait_ok("AT+CIPSEND\r\n", 2000); // 返回 > 后进入透传退出透传固定用+++,注意它没有回车换行,也不能带其他字符干扰,发送后等待 1 秒再继续发 AT 指令。这个时序如果不满足,模块不会退出透传,导致后续指令被当成 TCP 数据发给服务器。透传模式下接收侧不再有+IPD头,所有串口数据都是裸业务流,解析逻辑更简单;代价是失去了报文的长度边界,如果 TCP 对端发送的数据是变长的,你还需要在应用层自己定义协议边界(比如固定头 + 长度字段)。
5. 掉线重连和 AT 指令的易错点排查
5.1 ESP8266 模块常见不稳定现象根因
连上 WiFi 后频繁复位,这是供电问题,不是代码问题。检查 ESP8266 的 3.3V 电源能否持续供给 300mA 以上,验证手段是看重启瞬间串口日志,如果在发射状态下复位,日志会突然截断。模块和 STM32 的 GND 必须共地,而且两根线尽量短粗,避免地弹导致逻辑电平漂移。
串口收不到回复,一个常见原因是模块没跑在 115200。用 USB 转 TTL 单独接模块,手动输入AT回车,能返回 OK 就说明波特率正确;乱码则说明固件被烧过其他波特率。另一种情况是 RX/TX 接反了,这在调试时最容易忽视。
AT 指令和固件版本密切相关。老版本固件用AT+CWJAP设置 WiFi 参数,新版本分成AT+CWJAP_DEF(参数保存到 flash)和AT+CWJAP_CUR(临时生效)。拿到模块先执行AT+GMR查看固件版本,再决定用哪条指令。如果你发现AT+CWJAP总是返回ERROR,而模块单独用串口工具发同样指令正常,多半是 STM32 这边发送的数据不干净,最常见的是少了行尾\r\n或混入了\n。
5.2 掉线检测与指数退避重连策略
ESP8266 掉线时,串口会异步上报WIFI DISCONNECT或CLOSED。这类异步消息不响应任何请求,只能被动接收。主循环里也可以主动查询连接状态,两个手段配合才能做到及时感知:
void esp_keepalive_poll(void) { // 每 30 秒向服务器发一个空数据包保持链路 if (HAL_GetTick() - last_keepalive > 30000) { esp_send_cmd_custom("AT+CIPSTATUS\r\n", 1000); last_keepalive = HAL_GetTick(); } } void esp_link_lost_handler(void) { static uint8_t retry_count = 0; uint32_t delay_ms = 1 << (retry_count > 5 ? 5 : retry_count); // 指数退避,封顶 32 秒 esp_hard_reset(); // 先复位模块,保证状态干净 HAL_Delay(delay_ms * 1000); esp_link_to_server(); // 重新走完整的联网流程 retry_count++; }重连成功后把retry_count清零。指数退避是为了避免网络抖动时多台设备同时重连,导致 AP 过载,而且 WiFi 模块反复快速重启容易让路由器把它踢进黑名单。
另外有个细节:重连时不要只执行AT+CWJAP重连 WiFi,我的做法是直接硬件复位整个模块再跑一遍完整流程。因为 WiFi 掉线往往伴随着 TCP 连接状态残留,只要模块不复位,AT+CIPSTART可能返回ALREADY CONNECTED的错误状态。硬复位虽然慢,但最干净、最可靠。
5.3 调优技巧:把接收缓冲和数据解析拆到不同执行层级
串口空闲中断里做的时间开销要严格控制。像esp_frame_process这种涉及strstr和atoi的解析,我一般不直接放在中断服务函数里,而是把原始数据拷到应用层缓冲区,置一个标志位,由主循环或 RTOS 任务处理。HAL 库回调里只做一次memcpy和变量赋值,整体耗时控制在几微秒以内。这样才能保证串口不丢字节,后续解析即使慢一点,数据还在缓冲区里躺着。
缓冲区大小按照最坏情况算:波特率 115200 时串口速率约 11.5KB/s,如果业务方一次上报 1KB 数据,主循环响应周期 5ms,那么缓冲至少要有 11.5 × 5 ≈ 58 字节余量。考虑到 TCP 可能一次拼多个包到达,我惯用 2048 字节的接收环,配合半包解析器,基本能覆盖常见物联网关的负载。发数据前先检查环内的空间,不足时等主循环消费完再继续,避免覆盖未处理的数据。
本文还有配套的精品资源,点击获取