ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32+语音识别+ESP8266:桌面天气助手开发全流程详解

2026/9/15 14:54:20 拓冰建站 浏览量
STM32+语音识别+ESP8266:桌面天气助手开发全流程详解 简介面向嵌入式学习者的STM32智能桌面天气预报系统完整工程适用于课程设计、毕业设计及工作项目复现。系统围绕STM32微控制器实现天气显示、语音搜索天气与简单对话功能涵盖传感器数据读取、屏幕驱动、语音识别模块接入、天气API数据获取与解析等环节技术栈涉及STM32 HAL/LL库、C/C编程、嵌入式外设配置与人机交互设计。资源共199个文件压缩包约3.4MB。工程以82个C源码和82个头文件为主辅以读写说明、链接脚本、Keil工程文件uvproj/uvoptx及PDF文档便于在Keil uVision或STM32CubeIDE中直接打开编译。内容预览显示包含GbkToUtf_8、ff、tasks等关键模块可帮助理解文件系统与中文编码转换处理流程。目前已有244人学习下载源码经助教测试可复现并附有README.md等说明文档对快速上手与二次开发很有帮助。1. 桌面天气预报系统为什么选 STM32语音识别模块桌上放一个小盒子2.4 寸屏常显日期和温度。你说「你好小智今天北京天气怎么样」它应一声把「北京今天晴12 度」显示出来再播报你接着问「明天呢」它知道还在聊北京。这不是带屏音箱而是一块 STM32 主控、一个离线语音识别模块、一个 ESP8266 和一个 LCD 组成的桌面设备。选 STM32 不是因为算力而是外设刚好够两个串口分别接语音模块和 WiFiI2C 接屏幕定时器做对话超时。语音识别在本地完成只有查天气才联网断电重启配置不丢。整个项目的关键是把词条、API 字段、屏幕刷新和一句上下文对话串成一条有效链路。下面按选型、联网、实现、联调展开代码和参数可直接照着改。2. 语音识别模块选型与 STM32 串口对接词条、唤醒词和指令帧写代码之前先把「语音」这条链路定下来。STM32 本身跑不了大词表识别模型项目里的语音识别通常落在三种方案上选型决定了后面所有代码的结构。2.1 三条语音识别路线怎么选离线模块、直连云、双芯片第一条是离线语音识别模块SU-03T、LD3320、CI1102 这类。模块自带麦克风和功放在配套工具里烧录词条和唤醒词识别到之后通过串口把指令名发出来。对上位 STM32 来说它就是「一个串口输入源」代码最简单不依赖外网响应在一两百毫秒。第二条是 STM32 自己采样音频交给云端常见做法是 STM32 用 I2S 接麦克风分包上传到讯飞或百度语音识别接口拿回文字再走意图解析。这条路识别率最高也能覆盖「没烧录过的说法」但要在 STM32 上维护音频缓存、HTTP 分块上传和超时重传代码量和不确定性明显偏高断网时整条链路直接哑掉。第三条是双芯片方案用 ESP32 跑 ESP-IDF 接讯飞语音识别或者用带 NPU 的 K210 做本地识别STM32 只负责控制、显示和动作。性能上最从容代价是板子从单 MCU 变成一主一从两边协议要自己定义复杂度几乎翻倍。路线识别自由度是否依赖网络响应延迟开发量适用场景离线模块SU-03T/LD3320固定词条否100~300ms低桌面天气、语音台灯、闹钟STM32 采集音频直连云端任意说法是1~3s高需要开放对话的原型ESP32/K210 双芯片词条加少量自由可选中高边缘计算演示、竞赛项目桌面天气预报的意图就三类问天气、问时间、打招呼。用离线模块是多数 STM32 项目的选择下面讲的接线、词条和状态机都按这条路来。如果你已经在 ESP32 上跑通过讯飞接入再把控制权交给 STM32那属于第三条路思路可以借鉴但工程结构完全不同。2.2 语音模块与 STM32 的接线表、串口参数和共地问题以 SU-03T 这类带功放的模块为例它需要 5V 供电STM32 这边用 USART1 接收识别结果。接线不是简单的 TX 接 RX 就完事第一容易踩的是共地第二是模块上电瞬间 RX 引脚的电平抖动会被 STM32 当成数据。语音模块引脚STM32 引脚说明VCC5V模块功放需要 5V不要接 3.3VGNDGND必须和 STM32 共地否则串口乱码TXPA10USART1_RX识别结果从这里进 STM32RXPA9USART1_TX可选用于向模块发起播报SPK / SPK-喇叭模块直接驱动 1W 以内的扬声器提示模块供电接 5V 时如果 STM32 底板是 3.3V 逻辑先确认模块 RX 是否兼容 3.3V 电平不确定就串一个 1k 电阻别直接用 5V 逻辑去怼 STM32 引脚。串口参数默认是 9600、8、N、1具体以模块文档为准。接好线的第一步不是写代码而是用 USB 转 TTL 接模块的 TX在 PC 串口助手上把模块原始输出看清楚唤醒之后它到底发的是「指令名」还是「词条编号」结尾有没有回车换行。这一步决定了你在 STM32 端按行解析还是按帧解析。PA9/PA10 在 CubeMX 里要把 GPIO 复用成 USART1 的 AF 模式只开串口不配引脚中断永远进不来。2.3 词条到指令帧识别结果怎么变成代码里的结构体离线模块能识别的不是任意句子而是你在工具里烧录的「词条」。SU-03T 的做法是每条词条绑定一个自定义指令名例如词条「北京天气」绑定指令名 city_beijing识别后串口发出 city_beijingSTM32 端不用碰拼音和语音只处理 ASCII 指令名。词条设计有几个约束总量有限一般几十到两百条城市不能穷举烧录常用城市加「本地天气」兜底词条即可同一模块里唤醒词、天气词条、对话词条共用一份存储词条之间首字差异要大避免安静环境下误触发。我一般把词条分成三类命名天气指令带 weather 前缀城市指令带 city 前缀闲聊指令带 greet 前缀解析时一眼能看出意图。typedef enum { DAY_TODAY, DAY_TOMORROW, DAY_DAYAFTER } WeatherDay; typedef struct { char cmd[24]; /* 语音模块发来的指令名 */ char city[16]; /* 匹配到的城市空串表示沿用上次 */ WeatherDay day; /* 今天/明天/后天 */ int is_weather; /* 1 表示天气意图 */ int is_greet; /* 1 表示闲聊意图 */ } VoiceCommand; VoiceCommand g_voice; /* 保存上一次识别结果 */ void parse_voice_line(const char *line) { memset(g_voice, 0, sizeof(g_voice)); if (strstr(line, weather_today)) g_voice.day DAY_TODAY, g_voice.is_weather 1; else if (strstr(line, weather_tomorrow)) g_voice.day DAY_TOMORROW, g_voice.is_weather 1; else if (strstr(line, weather_dayafter)) g_voice.day DAY_DAYAFTER, g_voice.is_weather 1; if (strstr(line, city_beijing)) strcpy(g_voice.city, beijing); else if (strstr(line, city_shanghai)) strcpy(g_voice.city, shanghai); if (strstr(line, greet_hello)) g_voice.is_greet 1; }用 strstr 而不是 strcmp是因为语音模块偶尔带协议头或前后缀包含匹配更抗干扰city 字段单独解析这样「北京今天天气」「今天北京天气」两种说法虽然词条不同拆出来的城市和日期是同一个结构体。g_voice 是整个对话功能的关键后面「明天呢」这类省略主语的指令就是靠它把上次的城市沿用下来。最后提醒一句词条改了指令名模块必须重新烧录STM32 端代码也要同步这是联调时最常出现的「两边不一致」。3. STM32 联网获取天气ESP8266 AT 指令、天气 API 与 JSON 解析语音链路只解决「听懂」天气数据还要走网络。STM32 本身没有以太网桌面设备最常见的联网方式是外挂 ESP8266 或 ESP32 做透传。ESP8266 通过 AT 指令工作STM32 第二个串口USART2负责跟它对话。整个过程分三块建立 TCP 连接、发 HTTP 请求、解析返回的 JSON。3.1 ESP8266 透传天气请求AT 指令序列与 HTTPS 端口先把 ESP8266 用 USB 转 TTL 接到 PC 上单独调试命令序列在串口助手里逐条验证再交给 STM32 控制。下面序列是常见做法把 SSID、密码和 Key 换成你自己的ATGMR # 看固件版本确认是否支持 SSL ATCWMODE1 # Station 模式 ATCWJAPmy_wifi,12345678 # 连路由器返回 OK 后 WIFI GOT IP ATCIPSTARTSSL,api.seniverse.com,443 ATCIPSEND112 # 声明后面要发 112 字节 GET /v3/weather/now.json?keyYOUR_KEYlocationbeijing HTTP/1.1 Host: api.seniverse.com Connection: close逐条说明CWMODE 设成 1 表示只做 Station不开启热点CWJAP 是阻塞式的连不上会一直重试STM32 端要加整体超时CIPSTART 的协议名写成 SSL 时走 TLS 握手比 TCP 慢一二百毫秒如果模块固件太老不认识 SSL就退回 80 端口明文 HTTPCIPSEND 后面那个数字必须和实际发送字节数一致上面这条请求算下来正好 112 字节多一帧少一帧模块都会一直等待。老版本 AT 的 CIPSEND 是一个参数新版本多一个 link ID以 ATGMR 看到的固件版本为准。响应以 IPD,长度: 开头后面跟一整段 HTTP 响应HEAD 和 BODY 之间隔一个空行我在 STM32 端不严格要求长度解析到空行之后的内容再找 JSON。还有一个容易忽略的点免费天气 API 的调用频率限制比想象中严格桌面设备不要做成秒级轮询。正确做法是语音触发才请求或者每 30 分钟后台刷新一次失败就退避到 5 分钟后重试。请求完成必须 ATCIPCLOSE 关闭连接否则 ESP8266 的连接数到上限后后续请求全部超时。3.2 天气 API 返回的 JSON 结构字段名先肉眼对齐拿到响应后先别写解析代码把整段原文打到串口助手或者 PC 端看一遍。不同天气 API 字段名差异很大心知天气的 now.json 返回结构大致如下{results:[{ location:{name:北京,id:beijing}, now:{text:晴,code:0,temperature:12,feels_like:10,humidity:30}, last_update:2025-05-01T08:00:0008:00 }]}这里 temperature 是字符串不是数字text 是中文编码是 UTF-8。如果你用的是和风天气字段则是 now.temp、now.text单位也有差异所以对接时第一件事是确认你申请的那家 API 的字段名而不是直接抄别人的解析代码。做个字段对照表钉在旁边比反复翻文档省事含义心知天气 now.json和风天气说明天气现象results[0].now.textnow.text中文描述温度results[0].now.temperaturenow.temp字符串单位 ℃湿度results[0].now.humiditynow.humidity百分比更新时间results[0].last_updateupdate用于粗校时3.3 STM32 上轻量 JSON 解析strstr/strchr 逐字段取值STM32 内存有限几个字段没必要引入完整 JSON 解析器。cJSON 在大多数 F103 上也能跑如果你已经在用加进去没问题我一般只在字段少于六个且结构固定时用 strstr 定位加 strchr 截串省掉动态内存分配/* 从 JSON 里取出 key:value 的 value找不到返回 NULL */ static char *json_get_str(const char *json, const char *key, char *out, int out_len) { char pat[32]; const char *p, *q; int len; snprintf(pat, sizeof(pat), \%s\:\, key); p strstr(json, pat); if (!p) return NULL; p strlen(pat); q strchr(p, ); if (!q) return NULL; len (int)(q - p); if (len out_len) len out_len - 1; memcpy(out, p, len); out[len] \0; return out; }调用时注意三点。第一out_len 要给足中文按 UTF-8 是三个字节一个字「晴」在缓冲区里占 3 字节再加结尾 0缓冲区按 16 字节起第二解析结果要校验temperature 可能返回空串或 null先把字符串转成整数再判断范围别直接把空值上屏第三strstr 全串搜索不关心嵌套层级但返回里如果出现「晴」和「多云转晴」两个字段顺序取值时要注意取到的是第一次出现的位置。拿到 text、temperature、humidity 三个字段天气显示的数据就够了。4. 简单对话功能的状态机实现串口缓冲、定时器超时与屏幕刷新有了语音指令和天气数据剩下的工作是把它们组织成「对话」。表面上对话是用户说一句设备答一句但工程上要回答两个问题省略主语的「明天呢」怎么处理识别结果乱序到达时怎么不重复请求。这两个问题用状态机解决。4.1 对话状态机设计IDLE、WAIT_DAY、FETCHING 怎么流转我把对话分成四个状态空闲 IDLE、等补充时间 WAIT_DAY、请求中 FETCHING、回复中 SPEAKING。收到天气指令时如果带城市直接进 WAIT_DAY如果没带城市但上次上下文里有同样可以发请求。收到「明天呢」这类只带时间的指令后判断当前是否处于天气话题中是就沿用城市不是就播报「请先说城市」。请求发出后进 FETCHING这个状态里所有新语音指令先缓存避免在等待网络响应时重复触发。typedef enum { S_IDLE, S_WAIT_DAY, S_FETCHING, S_SPEAKING } DialogState; typedef struct { char city[16]; } WeatherCtx; DialogState g_state S_IDLE; WeatherCtx g_ctx; void dialog_on_voice(const VoiceCommand *v) { if (v-is_greet) { play_prompt(prompt_hello); /* 触发语音模块播报 */ return; } if (!v-is_weather) { play_prompt(prompt_hint); /* 我还没学会这个 */ return; } if (g_state S_FETCHING) return; /* 上一次还没返回丢弃 */ if (v-city[0]) strcpy(g_ctx.city, v-city); else if (!g_ctx.city[0]) { play_prompt(prompt_ask_city); /* 明天呢先补城市 */ g_state S_WAIT_DAY; return; } weather_request(g_ctx.city, v-day); /* 发 HTTP 请求 */ g_state S_FETCHING; }状态机的价值在边界情况上。比如用户先说「明天呢」再说「北京」按直觉应该丢弃前者但这里 S_WAIT_DAY 把缺城市的状态挂起等下一次语音补齐再发请求又比如请求发出后 5 秒内语音模块又识别出「上海天气」g_state S_FETCHING 直接舍弃防止 ESP8266 串口被并发请求打乱。每个状态都要配一个定时器超时恢复成 IDLE定时器在这里是状态机的看门狗不只是延时工具。4.2 不定长串口行的接收逐字节中断加帧结束判断语音模块发出的指令名长短不一结尾通常带 \r\n。接收端最省事的做法是逐字节中断攒到换行符才算一帧uint8_t rx_byte; char line_rx[128]; uint16_t line_len; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart ! huart1) return; if (rx_byte \n || line_len sizeof(line_rx) - 1) { line_rx[line_len] \0; parse_voice_line(line_rx); /* 2.3 节的解析函数 */ line_len 0; } else if (rx_byte ! \r) { line_rx[line_len] (char)rx_byte; } HAL_UART_Receive_IT(huart1, rx_byte, 1); }初始化时先调用一次 HAL_UART_Receive_IT(huart1, rx_byte, 1) 启动接收之后每收到一字节都会回到这个回调。用逐字节而不是 DMA 定长接收是因为语音模块一帧只有十几个字节且到达时间不确定定长 DMA 反而要处理半帧和粘帧逐字节的 CPU 开销在这个频率下可以忽略。回调里只存数据不要在里面发 HTTP 请求或者调用 HAL_Delay这在 Keil5 调试时是最常见的死机来源。解析和状态机放在主循环里执行用标志位通知主循环「来了一行」。4.2.1 标准库和 HAL 库在接收上的差异上面代码是 HAL 库写法。如果你按 stm32 标准库新建工程对应的回调是 USART1_IRQHandler 里判断 RXNE 位再读 DR 寄存器逻辑完全一样只是把 HAL_UART_Receive_IT 换成手动开中断。两种库都行关键是整个项目只用一种不要在 HAL 工程里手写寄存器操作否则排查中断优先级时会多一层心智负担。另外串口中断优先级建议比定时器低帧结束判断用定时器轮询而不是在中断里做计时。4.3 显示布局与 RTC 校时把天气和对话状态放上屏屏幕我用 SSD1306 的 1.3 寸 OLEDI2C 接口 400kHz配取模字库显示中文。布局固定四行第一行日期时间第二行城市加天气现象第三行温度湿度第四行提示语「说你好小智」。对话状态的变化要能在屏幕上看到请求中显示「查询中」失败显示「网络不可用」用户才知道设备不是在装死。时间来源有两种做法。一是不加外部 RTC 芯片开机后向天气 API 发一次请求用返回的 last_update 字段里的日期部分做粗校时误差在分钟级桌面设备够用二是加 DS3231走 I2C 和 OLED 共用总线掉电后时间不丢。对毕设来说第一种省一个芯片但要注意 last_update 是带时区的服务器时间解析时按固定时区处理不要用本地 1970 秒数去减。屏幕刷新放在主循环里用标志位控制每 500ms 刷一次足够I2C 400kHz 下 1.3 寸屏全刷一帧也就几毫秒不会卡住语音回调。5. 语音搜索天气的联调排错调试串口分层定位与离线兜底5.1 用调试串口把链路切成五段语音加网络加显示的项目出问题最难的是定位。我的做法是留第三个串口做调试口USART3115200在五个节点打日志语音行收到、城市匹配、HTTP 请求发出、响应长度、JSON 字段解析。不管是开发阶段还是交付后维护串口调试口都是这个系统唯一可靠的眼睛。日志宏直接打时间戳#define LOG(fmt, ...) \ printf([%lu] fmt \r\n, HAL_GetTick(), ##__VA_ARGS__)打印点埋好之后大多数问题一眼就能看出来。下面这张表是我在类似项目里出现频率最高的五类现象和对应排查方法现象可能原因排查方法唤醒后串口没数据波特率不对或词条未烧录PC 串口助手直连模块 TX有语音数据但没反应指令名和代码不一致打印原始行对照工具里的指令名请求发出一直超时WiFi 未连接或 SSL 失败ATCIPSTATUS 查链路状态屏幕显示乱码UTF-8 与取模字库编码不一致先打印 JSON 原文确认编码运行几分钟后卡死回调里做了耗时操作或内存越界查 strcpy 长度关掉优化再测5.2 无网兜底和语音播报联动桌面设备断网是常态不能断网就白屏。我把上次成功的天气数据连同时间戳缓存在 STM32 内部 Flash 的固定页里每次开机先读出来上屏联网成功后覆盖。请求失败连续三次就切到缓存模式屏幕显示「离线数据 上次更新时间」语音模块播报「网络开小差了这是上次的天气」。缓存数据结构简单写入前先擦除整页注意 Flash 擦写次数别在主循环里频繁写。5.3 提高唤醒触发率的三个硬件细节最后说三个直接影响唤醒率的细节。麦克风要避开屏幕排线和喇叭开孔朝人设备放桌面边缘位置语音模块的 RX 悬空时容易被干扰我给 RX 加一个 10k 下拉电阻到地避免上电瞬间的电平抖动被当成数据模块和喇叭共用一个电源时播报瞬间的电流毛刺会造成误唤醒常见做法是在 VCC 与 GND 之间并一个 100uF 电解电容加 0.1uF 瓷片电容这个组合能压掉大部分爆音。这三个细节在桌面设备上比代码更影响体验。本文还有配套的精品资源点击获取