STM32语音天气站实战:语音识别、编码转换与多任务调度的嵌入式设计
简介这是一套基于STM32的智能桌面天气预报系统完整工程面向嵌入式开发学习者、毕业设计及课程设计使用者。系统除展示实时天气外还集成语音识别模块支持语音查询天气和简单对话覆盖STM32外设驱动、传感器数据读取、屏幕显示、语音交互及天气API接入等关键环节。压缩包共199个文件大小3.4MB以82个C源码文件与82个头文件为主另有工程配置文件、文本说明、README及PDF文档便于快速浏览项目结构与启动开发。已有244人浏览学习。源码经助教老师测试无误可直接导入Keil或STM32CubeIDE编译运行工程内包含GBK/UTF-8转换、文件系统等辅助模块对于理解嵌入式系统设计、语音识别应用和天气数据解析均有参考价值。整体资料结构清晰适合希望从零复现智能天气终端、提升STM32实战能力的开发者。1. 把天气桌牌变成会听会说的终端STM32语音天气站的拆解思路你桌上放一台实时显示天气的小屏幕这并不稀奇稀奇的是你可以直接对着它问一句“明天会下雨吗”它不光能听懂还能用语音回你。这个基于STM32的智能桌面天气预报系统做的就是这件事。它不像手机天气App那样靠手指点而是把「语音识别—天气数据获取—中文显示与播报」这条链路全部塞进一块单片机里。拆开这个项目你会发现它真正值得学的地方不在于“显示天气”而在于三个硬骨头一是如何在资源有限的STM32上跑通语音识别并做关键词匹配二是如何把从网络API拿到的UTF-8编码天气数据转成GBK再送去液晶屏渲染三是如何用FATFS管理SD卡里的字库与音频资源时不让系统卡死。这套代码对正在做毕业设计或嵌入式课程设计的人尤其有价值源码里能同时看到编码转换、文件系统和状态机三种实战技巧而不是零散的寄存器点灯。2. 语音模块怎么选LD3320/SU-03T与STM32的资源权衡2.1 从项目源码倒推语音识别方案的取舍拿到项目压缩包后我建议先别急着看main函数先看文件清单里的两类文件utf8togbk.c和GbkToUtf_8.c以及tasks.c。前者说明系统里同时存在UTF-8和GBK两种编码的数据需要做双向转换后者说明软件结构不是裸奔的轮询而是有一个任务调度的骨架。这两个线索合在一起基本能判断出系统的数据流是「语音识别模块给出中文文本 → 文本转成UTF-8 → 拼接HTTP请求 → 天气API返回UTF-8的JSON → 转成GBK → 送LCD显示或TTS播报」。这是非常典型的离线语音模块加联网MCU架构。在这个架构下语音识别部分最常见的选择有三类LD3320离线识别芯片、SU-03T语音模块、或者ESP32跑在线语音API。我先给一个选型对比这在毕设答辩时经常被问到方案接STM32方式是否需联网自定义词条成本区间适合场景LD3322SPI/UART否需烧录词条中固定命令词不要求对话SU-03TUART否官网/IDE配置低成品化桌面设备ESP32在线识别UART/SPI是任意文本中需要自由口语对话这个项目源码里带了tasks.c从任务调度的写法看采用的应该是UART透传型语音模块——因为LD3320需要占用较多GPIO和SPI时序而SU-03T这类模块只需要一个串口解析模块返回的特定报文即可。对于桌面天气站这种需要“简单对话”的场景用SU-03T这类自带中文对话理解的模块是更务实的做法。2.2 UART驱动语音模块协议帧与缓冲区设计无论用哪种语音模块落到STM32侧的核心工作都是串口数据解析。我以SU-03T为例它上电后会通过UART发送识别结果格式一般是帧头加ASCII字符。STM32这端要做的第一步是环型缓冲区接收而不是每收一个字节就处理一次——那样在主循环处理LCD刷屏时极易丢字节。这段代码展示最核心的串口接收与环形队列写入逻辑// ringbuf.c —— 环形缓冲区UART中断写入主循环取出 #define RING_SIZE 256 typedef struct { uint8_t buf[RING_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ringbuf_t; ringbuf_t voice_rx; // 在USART2_IRQHandler中断里调用 void Voice_UART_ISR(uint8_t byte) { uint16_t next (voice_rx.head 1) % RING_SIZE; if (next ! voice_rx.tail) { // 未被读空时写入 voice_rx.buf[voice_rx.head] byte; voice_rx.head next; } } // 主循环/任务中提取完整帧返回-1表示还没凑够一帧 int Voice_ParseFrame(uint8_t *out, uint16_t max_len) { static uint8_t frame[32]; static uint8_t idx 0; int result -1; while (voice_rx.head ! voice_rx.tail) { uint8_t b voice_rx.buf[voice_rx.tail]; voice_rx.tail (voice_rx.tail 1) % RING_SIZE; if (b 0xAA) { // 模块自定义帧头 idx 0; } if (idx sizeof(frame)) { frame[idx] b; } // 假设帧尾是0x55且最短帧8字节完整收一帧后拷贝出去 if (b 0x55 idx 8 idx sizeof(frame)) { memcpy(out, frame, idx); result (int)idx; idx 0; break; } } return result; }这里有个很关键的预设帧头和帧尾每个项目的自定义语音模块可能都不同。很多同学直接把网上Demo的0xAA/0x55硬编码进来换了语音模块就死活解析不出数据——正确做法是先拿USB转TTL工具接在语音模块的UART上用串口助手看它识别成功时到底输出什么字节序列再改帧头判断。环形队列的深度256个字节对于桌面天气站是够的但如果你的tasks.c里还跑了FATFS写日志和TTS播报任务建议调到512并开启DMA接收。2.3 关键词表与命令映射从文本到动作语音模块识别出文本之后STM32要做的不是直接拿字符串去和API比较而是先做一次关键词匹配把口语表达映射成内部命令码。比如“今天天气怎么样”“今天啥天气”“今天冷不冷”这三句话都应该映射到同一个CMD_TODAY_WEATHER。项目里这类映射一般放在一张静态表里下面是我建议的写法// cmd_map.c —— 关键词到命令码映射 typedef struct { const char *keyword; uint8_t cmd; } keyword_map_t; static const keyword_map_t voice_cmd_map[] { {今天天气, CMD_TODAY_WEATHER}, {明天天气, CMD_TOMORROW_WEATHER}, {后天天气, CMD_DAYAFTER_WEATHER}, {会下雨吗, CMD_QUERY_RAIN}, {多少度, CMD_QUERY_TEMP}, {好热, CMD_FEELING_HOT}, {好冷, CMD_FEELING_COLD}, }; uint8_t Voice_GetCmd(const char *text) { uint8_t i; for (i 0; i sizeof(voice_cmd_map)/sizeof(voice_cmd_map[0]); i) { // strstr做包含匹配而不是strcmp做全等匹配 if (strstr(text, voice_cmd_map[i].keyword) ! NULL) { return voice_cmd_map[i].cmd; } } return CMD_UNKNOWN; }注意这里用的是strstr包含匹配。因为语音识别出来的文本往往带语气词比如“今天天气怎么样啊”全等匹配会全部漏掉而包含匹配只要识别文本里含有“今天天气”就命中。代价是误触率会高一点比如用户说“不要今天天气”也会命中但在桌面天气站这种封闭场景下误触影响很小。这个映射表在项目里可以直接替换成你自己的唤醒词和命令词它是整套语音交互最核心的配置点。参数CMD_UNKNOWN建议单独定义分支用于TTS回复“抱歉我没听懂”这样人机交互才有闭环。3. 天气数据链路ESP8266取数、cJSON解析与GBK/UTF-8双编码转换3.1 让STM32联网ESP8266 AT指令是最稳的做法桌面天气站要显示天气必然要联网。STM32本身没有网络协议栈项目里最常见的是挂一块ESP8266模组跑AT固件用串口指令让ESP8266去连WiFi、发HTTP请求。为什么不用STM32直接跑 lwIP因为天气站这类设备只需要周期性地拉一次数据用AT指令的代码路径短、出问题好排查而要让STM32跑以太网或WiFi协议栈需要外部PHY芯片或SDIO接口WiFi模组成本和代码复杂度都上去了。tasks.c里如果调度的是「每30分钟拉一次天气」这种低频任务AT指令方案完全撑得住。先看最基础的TCP连接与HTTP请求// esp8266.c —— 通过AT指令获取天气API数据 // 前提ESP8266已通过ATCWJAP连上路由器 uint8_t ESP8266_GetWeather(const char *city_code, char *response_buf, uint16_t buf_size) { char at_cmd[128]; // 1. 连接TCP服务器心知天气API域名端口80 sprintf(at_cmd, ATCIPSTART\TCP\,\api.seniverse.com\,80\r\n); ESP8266_SendCmd(at_cmd, OK, 3000); delay_ms(200); // 2. 拼HTTP GET请求 // 注意这里需要把中文城市名先转成UTF-8的URL编码 sprintf(at_cmd, GET /v3/weather/now.json?keyYOUR_API_KEYlocation%slanguagezh-Hans HTTP/1.1\r\n Host: api.seniverse.com\r\n Connection: close\r\n \r\n, city_code); // 3. 告诉ESP8266要发送的数据长度然后发数据 char len_buf[16]; sprintf(len_buf, ATCIPSEND%d\r\n, strlen(at_cmd)); ESP8266_SendCmd(len_buf, , 2000); ESP8266_SendRaw(at_cmd, strlen(at_cmd)); // 4. 等待并收取响应数据 if (ESP8266_WaitResponse(IPD, 5000) 0) { return ESP8266_ExtractPayload(response_buf, buf_size); } return 0; }这串代码里最容易踩坑的是ATCIPSEND后面的长度必须和实际发送字节数完全一致多一个少一个都会导致ESP8266一直等数据或直接返回ERROR。我调试时习惯先把at_cmd的字符串打印到串口助手数清楚长度再核对strlen计算结果。另外要注意的是HTTP响应里会带一大坨响应头ESP8266_ExtractPayload要用\r\n\r\n定位到响应体起点再往后的内容才是JSON。如果天气API配置的是HTTPS而不是HTTP上面这套AT指令就不适用了——ESP8266的AT固件虽然支持SSL但握手耗时会到2秒以上且需要提前烧录CA证书到flash小型项目不值得折腾直接选HTTP的API或者用ESP32走TLS是更好的选择。3.2 在单片机里解析JSONcJSON裁剪与内存释放策略从HTTP响应里拿到JSON字符串之后下一步就是解析。STM32F103系列只有20KB到64KB的RAMJSON库的选择直接影响稳定性。项目里最常用的是cJSON因为它以ANSI C写成可以很方便地裁剪源文件塞进工程。但有一点必须注意cJSON_Parse底层调用malloc如果工程没有单独配置堆大小解析一次大JSON直接就把堆干爆了。正确做法是给FreeRTOS的heap或裸机的C库堆分配合适空间并且做到用完即释放。下面是一段示例// weather_parse.c —— 使用cJSON解析心知天气返回的数据 #include cJSON.h typedef struct { char city[32]; char weather[32]; int temp; // 当前温度取整 int humidity; // 相对湿度 } weather_t; static weather_t g_weather; uint8_t Weather_ParseJson(const char *json_str) { uint8_t ok 0; // 每次解析前先用指针接收确保后续只释放一次 cJSON *root cJSON_Parse(json_str); if (root NULL) { // 解析失败打印错误位置cJSON_GetErrorPtr() return 0; } // 心知天气返回格式: // {results:[{now:{temperature:29,humidity:63}, // location:{name:北京}}]} cJSON *results cJSON_GetObjectItem(root, results); cJSON *first (results ! NULL) ? results-child : NULL; if (first ! NULL) { cJSON *now cJSON_GetObjectItem(first, now); cJSON *location cJSON_GetObjectItem(first, location); cJSON *temp cJSON_GetObjectItem(now, temperature); cJSON *humidity cJSON_GetObjectItem(now, humidity); cJSON *city_name cJSON_GetObjectItem(location, name); strncpy(g_weather.city, city_name-valuestring, sizeof(g_weather.city)-1); strncpy(g_weather.weather, cJSON_GetObjectItem(now, text)-valuestring, sizeof(g_weather.weather)-1); g_weather.temp atoi(temp-valuestring); g_weather.humidity atoi(humidity-valuestring); ok 1; } cJSON_Delete(root); // 这一点很关键 return ok; }这里的坑有两个。第一个是cJSON_Delete(root)必须放在所有数据拷贝完之后一旦删掉root所有的cJSON*指针都失效了所以代码里先把字符串和数值拷到g_weather结构体里再释放。第二个是temp-valuestring是字符串不是整数字段名是temperature一定要先看API文档确认字段名再写解析代码否则API一改字段名解析出来全是NULL然后atoi(NULL)会直接进HardFault。在Keil里调试时可以在cJSON_Parse失败的地方打断点看cJSON_GetErrorPtr()指向的字节能快速定位是秒数还是响应头残留导致的解析失败。3.3 GBK与UTF-8互转为什么天气站里绕不开编码转换压缩包里的cc936.c、GbkToUtf_8.c、utf8togbk.c这三个文件是这套工程里最容易被忽略却最致命的部分。大致场景是这样的STM32通过ESP8266从心知天气这类API拿到的JSON是UTF-8编码的而很多中文字库屏、尤其是以W25Q64外部Flash存中文字模的方案字库索引是用GBK编码做的。这就意味着如果你直接把“北京”的UTF-8字节丢给字库芯片去找点阵永远找不到对应的字模。项目中一般会有一张码表文件完成双向转换下面这段是从码表查找定位的标准思路// utf8togbk.c —— UTF-8转GBK返回转换后的字符串长度 // unicode_table[] 是预生成的 unicode-gbk 映射表 uint16_t Utf8ToGbk(const uint8_t *utf8, uint8_t *gbk_out, uint16_t max_out_len) { uint16_t out_len 0; uint8_t i 0; while (utf8[i] ! 0 out_len max_out_len - 1) { uint16_t unicode 0; uint8_t len 0; // 2字节UTF-8: 110xxxxx 10xxxxxx if ((utf8[i] 0xE0) 0xC0) { unicode ((utf8[i] 0x1F) 6) | (utf8[i1] 0x3F); len 2; } // 3字节UTF-8: 1110xxxx 10xxxxxx 10xxxxxx else if ((utf8[i] 0xF0) 0xE0) { unicode ((utf8[i] 0x0F) 12) | ((utf8[i1] 0x3F) 6) | (utf8[i2] 0x3F); len 3; } else { // ASCII直接拷贝 gbk_out[out_len] utf8[i]; continue; } uint16_t gbk UnicodeToGbk(unicode); // 查表 if (gbk ! 0) { gbk_out[out_len] (gbk 8) 0xFF; // 高位在前 gbk_out[out_len] gbk 0xFF; } i len; } gbk_out[out_len] 0; return out_len; }这段代码回答了“为什么需要转换”的本质问题UTF-8的“北京”在内存里可能是E5 8C 97 E4 BA AC而GBK字库索引需要的是B1 B1 BE A9两者没有数学换算关系只能通过码表逐一映射。原理清楚之后cc936.c和GbkToUtf_8.c的角色也就明白了cc936.c实际上是微软代码页936的Unicode映射表它定义了UCS-2到GBK的完整对应关系是这套系统中文件体量最大的一个源文件而GbkToUtf_8.c是在往反方向做转换服务于TTS播报模块——很多离线语音合成模块只接受UTF-8文本从LED屏上取的GBK文案得先转回UTF-8再发给语音模块。实际使用中要注意的是码表本身占用Flash空间不小大约几百KB在STM32F103C8T6这种64KB Flash的芯片上放不下所以这个项目大概率是基于STM32F407或F103ZE这类有512KB Flash以上的型号做的。如果你的板子Flash不够可以把码表放到W25Q64外部Flash里需要转换时再分页加载到RAM——这会牺牲一点速度但能换来宝贵的Flash容量。3.4 显示中文从字库文件到GBK点阵索引FATFS文件系统在本项目中的核心作用是管理SD卡上的字库文件如HZ16.bin。上一步我们把API返回的天气文本转成了GBK编码下一步就要用这个GBK码去字库文件里定位点阵数据。ff.c这个文件证明工程已经移植了FATFS下面是读取字库点阵的核心逻辑// font.c —— 从SD卡字库文件读取16x16中文点阵 FIL font_file; FATFS fs; // 字库文件在SD卡的路径 #define FONT16_PATH 0:/HZ16.bin uint8_t HZ16_GetBitmap(uint16_t gbk_code, uint8_t bitmap[32]) { uint32_t offset; uint16_t area; // 区码 uint16_t position; // 位码 UINT br; // GBK高位是区码低位是位码按16点阵计算偏移 area ((gbk_code 8) 0xFF) - 0xA1; position (gbk_code 0xFF) - 0xA1; offset ((uint32_t)area * 94 position) * 32; if (f_lseek(font_file, offset) ! FR_OK) return 0; if (f_read(font_file, bitmap, 32, br) ! FR_OK) return 0; return (br 32) ? 1 : 0; }这里有一个非常隐蔽的坑GBK编码在字库文件中的偏移不是直接用(编码-0x8140)算的而是要先拆成区码和位码再按区号×94位号乘上每个字模的字节数。如果直接用编码-0xA1A1整体当作索引碰到含有0xFF等扩展字符的编码时偏移会跑到文件尾部去读出来的全是乱码或者干脆是0xFF。另外每次从FATFS读字模都做一次f_open会极度拖慢LCD刷屏速度因为FATFS底层对SD卡的挂载和目录解析开销很大。常见做法是上电时打开一次字库文件把文件指针保持住后续读取时只做f_lseek和f_read这样一次汉字显示的时间能从几毫秒降到几百微秒。f_lseek有个隐藏性能细节如果代码没有定义FF_USE_FASTSEEKFATFS会从文件头部开始顺序跳转指定偏移每次打开文件后第一次跳转尤其慢。所以如果你要在任务里频繁读不同字模务必在10ms周期任务里预先把CLUSTER信息缓存好或者在FATFS配置头ffconf.h中把FF_USE_FASTSEEK设为1并给每个文件句柄分配单独的工作区。否则你的天气刷新一次要读二三十个字模屏幕还会闪。4. 语音交互状态机从关键词匹配到多轮简单对话4.1 为什么UI和语音状态要单独拆一个tasks.c这个项目里tasks.c的价值不是把代码按函数拆分那么简单。STM32做语音天气站天然有三个不同频率的任务按键和语音识别扫描是10~20ms级别、LCD刷新是100ms级别、天气API轮询是30分钟级别。如果全部堆在main函数的while(1)里用delay_ms卡时间那么用户在语音识别期间按下按键按键的GPIO中断会被阻塞十几毫秒——看起来没什么但实际使用中表现为“语音识别成功后偶尔丢键”。tasks.c做的是“时间片分发”我建议的裸机时间片轮转结构如下// tasks.c —— 三个不同频率的任务调度主体 void Tasks_Init(void) { Voice_Init(); Lcd_Init(); ESP8266_Init(); } void Tasks_Loop(void) { static uint32_t tick_10ms 0; static uint32_t tick_100ms 0; static uint32_t tick_30min 0; uint32_t now HAL_GetTick(); // 10ms级轮询语音模块UART接收 if (now - tick_10ms 10) { tick_10ms now; Voice_TaskPoll(); } // 100ms级温度/湿度/信息刷新UI含星期和日期更新 if (now - tick_100ms 100) { tick_100ms now; Ui_TaskRefresh(); } // 30min级天气API拉取与解析 if (now - tick_30min 30 * 60 * 1000UL) { tick_30min now; Weather_TaskFetch(); } }任务分解之后的收益很明显语音模块的UART接收是几毫秒级别的短操作LCD刷新占据较长的SPI传输过程用时间片切分后两者互不阻塞。注意tick_30min的初始值一定要设为-30*60*1000这样系统上电后第一次Tasks_Loop就会立即执行一次天气获取而不是傻等30分钟——这个细节很多同学会漏掉上电半天没天气数据还以为是WiFi没连上。4.2 简单对话命令码串起来的有限状态机“简单对话功能”放到具体实现里其实就是一张状态迁移表。用户在语音唤醒后说“天气”系统回答“当前温度29度”用户再说“明天”系统回答“明天多云”——每次对话都不会持续超过三四轮所以我们不需要维护大段的上下文只需要一个int8_t变量记录上一次的意图。这个思路跟大语言模型完全不同它本质是一个有限状态机FSM。枚举定义和状态机核心// dialog.c —— 简单对话状态机 typedef enum { DIALOG_IDLE 0, // 空闲等待用户说唤醒词 DIALOG_WAIT_CITY, // 用户在问天气但没说城市等待补问 DIALOG_SHOW_WEATHER, // 正在展示天气等待用户追问 DIALOG_QUERY_RAIN, // 正在回答是否下雨 } dialog_state_t; static dialog_state_t dialog_state DIALOG_IDLE; static char dialog_city[16] 北京; // 默认城市可被语音修改 // 每收到一条语音识别结果就调用一次 void Dialog_ProcessCmd(uint8_t cmd, const char *text) { switch (dialog_state) { case DIALOG_IDLE: if (cmd CMD_DAYAFTER_WEATHER) { dialog_state DIALOG_SHOW_WEATHER; TTS_Play(后天天气已更新); } else if (cmd CMD_QUERY_RAIN) { dialog_state DIALOG_QUERY_RAIN; // 通过API返回值查询降水概率 Weather_ReportRainProb(); } break; case DIALOG_WAIT_CITY: // 用户上一轮没说城市本轮语音文本若包含城市名则提取 if (Voice_ExtractCity(text, dialog_city, sizeof(dialog_city))) { Weather_QueryByCity(dialog_city); dialog_state DIALOG_SHOW_WEATHER; } else { TTS_Play(请再说一遍城市名); } break; case DIALOG_SHOW_WEATHER: // 追问温度或湿度 if (cmd CMD_QUERY_TEMP) { TTS_Play(当前温度); TTS_PlayInt(g_weather.temp); TTS_Play(度); } dialog_state DIALOG_IDLE; break; default: dialog_state DIALOG_IDLE; break; } }这个状态机的设计目标是“不引入额外数据结构”。它只用了一个switch和一个状态变量却能让用户连续追问两三轮。实际跑起来你会发现问题往往出在TTS_PlayInt上——把整型温度转成语音字符串时要小心负数零下五度会被读成“负5度”而不是“零下5度”这就需要拼接“零下”前缀。此外状态机还有个好处是容易测试你可以写一个模拟输入序列比如喂CMD_QUERY_TEMP、文本“北京”打印出每次状态迁移日志全程不用接硬件就能验证逻辑正确性。这个技巧对毕设调试很有价值因为语音模块本身有环境干扰直接在硬件上反复喊话耗时间且容易误判不如先用模拟数据把状态机跑通。真实项目里还要增加一个超时机制进入DIALOG_WAIT_CITY后若5秒内没有新的语音输入自动回到DIALOG_IDLE避免系统一直卡在等待状态导致其他任务异常。4.3 TTS播报与时间戳别让语音和天气抢同一个串口项目文件夹里有一个tasks.c我已经讲了它是任务调度但还有一个隐藏点很多TTS模块和语音识别模块共用同一路UART比如Su-03T既能识别又能播报但播报的时候UART发送引脚还在接收引脚旁边。如果你在TTS_Play播报途中用户又说话触发识别两边的串口数据就会互踩。解决办法是把TTS的播报文本先全部塞进一个环形队列由专门的任务去逐个发送语音识别则只是在接收中断里做环形队列写入不参与发送。这种“读写分离”的设计代码量不大但能避免掉很多偶发性的死机问题。5. 字库缓存与局部刷屏让天气站顺滑显示的三步调优5.1 热点字缓存别每次都翻SD卡前面提过f_lseek加f_read虽然有优化但天气站每次刷新UI时城市名、温度、星期这些内容会反复出现。“北京”“多云”“25度”这些字符在一个刷新周期内可能被画两三次。如果不加缓存一次全屏刷新要读几十次SD卡。实测在STM32F103主频72MHz、SPI读取W25Q64 FATFS查询目录项的开销下全屏刷新要耗时接近2秒肉眼可见的卡顿。我常用的缓存策略是进程内维护一张LRU表记录最近使用的字模。比如温度、城市名这些常驻字段其字模在每次天气更新之间根本不需要重新读取// font_cache.c —— 热点字模LRU缓存 #define CACHE_SLOTS 128 typedef struct { uint16_t gbk_code; uint8_t bitmap[32]; uint8_t valid; } font_cache_t; static font_cache_t cache[CACHE_SLOTS]; static uint8_t cache_age[CACHE_SLOTS]; // 简易LRU年龄 uint8_t Font_GetBitmapCached(uint16_t gbk_code, uint8_t *out) { uint8_t i; uint8_t oldest 0; for (i 0; i CACHE_SLOTS; i) { if (cache[i].valid cache[i].gbk_code gbk_code) { memcpy(out, cache[i].bitmap, 32); cache_age[i] 0; // 命中则年龄清零 return 1; } if (cache_age[i] cache_age[oldest]) { oldest i; } } // 未命中从SD卡读取并把最久未用的槽位替换掉 if (HZ16_GetBitmap(gbk_code, cache[oldest].bitmap)) { cache[oldest].gbk_code gbk_code; cache[oldest].valid 1; cache_age[oldest] 0; memcpy(out, cache[oldest].bitmap, 32); return 1; } return 0; }这个缓存的命中率不稳定因为“温度”会从25度变成26度每次数值变化都产生新字模。但城市名这个字段基本不变缓存命中率很高。128个槽位占4KB RAM对大部分STM32型号没有压力。需要注意的坑是调用Font_GetBitmapCached前必须先把gbk_code计算正确否则缓存会把错误的编码和字模绑定在一起下次命中时直接输出错误的点阵——这种逻辑错误比读不到字模更难排查因为画面只是显示成另一个字看起来“像正常数据”。5.2 局部脏矩形刷新只重画变化区域天气站屏幕不大通常是2.8寸到4.3寸TFT分辨率320x240但整屏刷新清屏再重画所有文字即使加了缓存也还是有空闲期的闪烁感。更合理的做法是脏矩形刷新设置一个dirty_flag当且仅当天气数据发生变化时才重画天气相关的文字区域平时只是更新时分秒。下面是一段示意代码展示“温度变了才重画温度区域”的核心思路// ui_refresh.c —— 脏矩形方式的天气站界面刷新 #define WEATHER_TEMP_LCD_X 80 #define WEATHER_TEMP_LCD_Y 95 #define WEATHER_TEXT_LCD_X 80 #define WEATHER_TEXT_LCD_Y 130 static int8_t last_temp -128; // 初始化为非法值保证上电刷第一帧 void Ui_RefreshWeather(void) { // 温度变化才刷新温度值 if (g_weather.temp ! last_temp) { // 用背景色先覆盖旧值区域避免新旧数字重叠 Lcd_FillRect(WEATHER_TEMP_LCD_X, WEATHER_TEMP_LCD_Y, WEATHER_TEMP_LCD_X 60, WEATHER_TEMP_LCD_Y 24, LCD_COLOR_BG); // 在原来的位置重新画“xx度” char temp_text[16]; sprintf(temp_text, %d℃, g_weather.temp); Lcd_DrawString(WEATHER_TEMP_LCD_X, WEATHER_TEMP_LCD_Y, temp_text, LCD_COLOR_FG, FONT16); last_temp g_weather.temp; } }这段代码的合理性在于没有频繁整屏清屏。但如果你的LCD控制器是ILI9341这类且底层Lcd_FillRect没有做窗口裁剪优化直接用矩形填充覆盖时会出现“残影”——因为汉字字模的外框是16x16点阵而数字“25℃”的显示宽度不是16的整数倍脏矩形范围留少了会盖不住旧字符的边缘。调试办法是把背景色刷成明显的红色肉眼直接看残留区域在哪调整矩形坐标把脏区多留4~8个像素。5.3 上电时序与FATFS挂载失败的自恢复最后收一个具体技巧这项目上电时如果SD卡没插好FATFS的f_mount会返回FR_NOT_READY如果你直接在main函数里卡死等待整个天气站就永久黑屏。正确做法是给FATFS挂载加一个重试机制并且把字库文件打开失败作为一个“运行告警”输出到LCD状态栏而不是直接死循环。// fatfs_check.c —— 带重试的SD卡挂载与字库加载 uint8_t Fatfs_InitWithRetry(uint8_t max_retry) { FRESULT res; uint8_t attempt 0; do { res f_mount(fs, 0:, 1); // 立即挂载 if (res FR_OK) { // 尝试打开字库文件打不开也属于失败 res f_open(font_file, FONT16_PATH, FA_READ); if (res FR_OK) { return 1; } f_mount(NULL, 0:, 0); } attempt; HAL_Delay(500); // 给SD卡上电稳定时间 } while (attempt max_retry); // 最终失败在LCD上显示错误码而不是黑屏死机 Lcd_ShowString(0, 0, SD card fail, LCD_COLOR_RED); return 0; }这段代码解决的最典型现场问题是用户插拔TF卡或劣质卡读卡器在MCU上电瞬间还没ready系统第一次f_mount失败后没有重试就直接显示黑屏整个产品看起来像烧录了坏固件。max_retry设为5比较合理间隔500毫秒总重试时间2.5秒不拖慢开机进度。设计上还要注意如果第一次挂载失败第二次挂载前一定要先执行f_mount(NULL, 0:, 0)做一次卸载否则FATFS内部的文件系统状态机可能残留错误标志第二次f_mount返回FR_MOUNTED但实际上不可用。查问题时用FRESULT的返回值做状态提示正确做法是把返回值映射成字符串打出来而不是只看到一串数字分不清是FR_NOT_ENABLED还是FR_NO_FILESYSTEM——这两个错误处理路径完全不同前者是驱动层没挂对后者是卡没格式化。本文还有配套的精品资源点击获取