1. 项目缘起:从零打造你的桌面智能天气时钟
最近在整理工作室,发现手头闲置了好几块STM32的开发板和ESP8266模块,放着吃灰总觉得可惜。正好书桌上缺一个既好看又实用的电子时钟,市面上卖的要么功能单一,要么价格不菲。我就琢磨着,能不能自己动手做一个?要求也不高:能自动从网上获取准确时间,不用我隔三差五去调;还能显示实时天气,出门前看一眼就知道要不要带伞。这不就是典型的“智能天气时钟”嘛!
说干就干。核心思路就是用STM32做主控,它性能稳定、外设丰富,驱动个屏幕、处理点逻辑绰绰有余。联网的任务就交给ESP8266,这玩意儿简直就是为物联网而生的,价格便宜,AT指令用起来也简单。两者通过串口“对话”,STM32发指令,ESP8266负责联网抓取时间和天气数据,再把结果回传给STM32,最后显示到TFT屏幕上。听起来是不是挺清晰的?但实际做的时候,我也踩了不少坑,比如一开始用网上找的代码,运行几小时就死机,天气更新也不正常。经过一番折腾和优化,终于做出了一个稳定运行、功能完善的小作品。这篇文章,我就把自己从硬件连接到软件调试的全过程,包括那些“踩坑”经验,毫无保留地分享给你。即使你是刚接触STM32和ESP8266的新手,跟着步骤走,也能把它做出来。
2. 硬件准备与连接:给芯片们“牵线搭桥”
工欲善其事,必先利其器。我们先来清点一下需要的家伙事儿,并确保它们能正确通信。
2.1 硬件清单(我的配置)
我手头有的,也是比较通用的配置,你可以根据实际情况调整:
- 主控芯片:STM32F103ZET6(战舰开发板)。选它的原因是RAM比较大,有64KB,处理JSON数据缓存时会比较从容。如果你用其他型号,比如F103C8T6(最小系统板),务必注意RAM大小,建议不少于20KB。
- 网络模块:ESP-01S(ESP8266)。这是最常用的型号,注意要买刷好AT固件的版本,省去自己刷固件的麻烦。
- 显示设备:4.3寸TFT LCD彩屏(带触摸)。显示效果好,信息展示面积大。当然,1.3寸OLED、2.4寸TFT屏也都可以,驱动代码稍作修改即可。
- 调试与供电:
- J-Link或ST-Link下载器:用于程序烧录和调试。
- USB转TTL串口模块:用于调试ESP8266,以及查看系统打印的日志。
- 路由器:提供一个可用的WiFi网络。
- 杜邦线:若干。
2.2 核心电路连接图解
连接是关键,接错了轻则没反应,重则烧芯片。STM32和ESP8266主要通过串口通信,同时需要供电。
| STM32F103ZET6引脚 | ESP8266模块引脚 | 功能说明 |
|---|---|---|
| PB11 (USART3_TX) | RX | STM32发送指令给ESP8266 |
| PB10 (USART3_RX) | TX | STM32接收ESP8266的回复 |
| 3.3V | VCC | 重要:必须接3.3V!接5V大概率烧模块 |
| GND | GND | 共地,确保电平基准一致 |
注意:很多ESP8266模块的IO口电平是3.3V,且不耐5V。STM32的IO口虽然可以配置,但供电电压一定要匹配。我吃过亏,曾经不小心接到5V上,模块瞬间发烫,然后就再也连不上了。
2.3 给ESP8266做个“体检”
在集成到系统前,最好先用USB转TTL模块单独测试一下ESP8266,确保它是好的,并且固件正常。连接方式:USB转TTL的TX接ESP8266的RX,RX接TX,3.3V和GND分别接好。
打开串口调试助手(比如XCOM,波特率115200),给模块上电。你会看到一串启动日志。然后发送指令AT,如果模块回复OK,说明基础通信和AT固件没问题。再试一下AT+CWMODE=1设置成STA模式,回复OK就说明模块状态良好。这一步排查可以避免后续很多莫名其妙的通信问题。
3. 软件框架设计:让代码有条不紊地运行
硬件连好了,接下来就是软件的灵魂。我们不能让代码乱成一团,需要设计一个清晰、稳定的运行逻辑。原始文章中的代码容易死机,问题往往出在内存管理和状态机设计上。
3.1 主程序状态机(心跳核心)
这是整个系统稳定不死的关键。我采用一个基于定时器的超级循环,配合状态标志位,让每个任务在合适的时间执行,避免阻塞。
// 定义主要任务状态 typedef enum { SYS_INIT, // 系统初始化 WIFI_CONNECTING, // WiFi连接中 GET_NET_TIME, // 获取网络时间 GET_WEATHER, // 获取天气 NORMAL_DISPLAY, // 正常显示 ERROR_HANDLE // 错误处理 } SystemState_t; SystemState_t g_sys_state = SYS_INIT; uint32_t g_last_update_weather_tick = 0; uint32_t g_last_update_time_tick = 0; int main(void) { // 硬件初始化:时钟、串口、SPI、屏幕、RTC等 Hardware_Init(); // 初始化ESP8266,发送AT测试指令 ESP8266_Init(); while(1) { switch(g_sys_state) { case SYS_INIT: if(ESP8266_Check_OK()) { g_sys_state = WIFI_CONNECTING; } break; case WIFI_CONNECTING: if(WIFI_Connect("你的WiFi名", "你的密码")) { printf("WiFi连接成功!\r\n"); g_sys_state = GET_NET_TIME; } else { // 连接失败,延时后重试 delay_ms(3000); } break; case GET_NET_TIME: if(Get_Network_Time()) { printf("网络时间获取并设置RTC成功!\r\n"); g_sys_state = GET_WEATHER; } break; case GET_WEATHER: if(Get_Current_Weather()) { printf("天气信息获取成功!\r\n"); g_sys_state = NORMAL_DISPLAY; } break; case NORMAL_DISPLAY: // 1. 刷新屏幕显示(时间、天气、温度等) Display_Refresh(); // 2. 每隔1小时,同步一次网络时间(校准RTC漂移) if(HAL_GetTick() - g_last_update_time_tick > 3600000) { g_last_update_time_tick = HAL_GetTick(); g_sys_state = GET_NET_TIME; } // 3. 每隔30分钟,更新一次天气 if(HAL_GetTick() - g_last_update_weather_tick > 1800000) { g_last_update_weather_tick = HAL_GetTick(); g_sys_state = GET_WEATHER; } break; case ERROR_HANDLE: // 处理通信超时等错误,尝试恢复 Error_Recovery_Process(); break; } // 其他后台任务,如按键扫描 Key_Scan(); delay_ms(10); // 防止CPU跑飞 } }这个结构的好处是逻辑清晰,哪个环节出问题一目了然。并且,在NORMAL_DISPLAY状态下,系统只是周期性检查是否需要更新,大部分时间都在低负载运行,非常稳定。
3.2 内存管理:告别堆栈溢出
原始代码死机的一个可能原因是直接在解析函数里malloc,而USART3_RX_BUF又是一个全局大数组,容易造成堆栈冲突。我的改进方案是:
- 使用独立的缓存区:为网络接收分配一个固定的缓存区,与解析逻辑分离。
- 谨慎使用动态内存:在资源紧张的嵌入式系统,我倾向于使用静态数组或内存池。如果必须用
malloc,一定要配对使用free,并检查返回值。 - 优化接收机制:采用“长度+内容”或判断结束符的方式,精准控制接收,防止数据覆盖。
// 定义通信缓冲区 #define COMM_BUF_SIZE 1024 uint8_t g_esp8266_rx_buf[COMM_BUF_SIZE]; uint16_t g_rx_len = 0; // 在串口中断中接收数据 void USART3_IRQHandler(void) { if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART3); if(g_rx_len < COMM_BUF_SIZE - 1) { g_esp8266_rx_buf[g_rx_len++] = ch; // 简单判断是否收到结束符(根据API返回调整) if(ch == '\n' || g_rx_len >= COMM_BUF_SIZE - 1) { g_esp8266_rx_buf[g_rx_len] = '\0'; // 添加字符串结束符 g_data_ready_flag = 1; // 设置数据就绪标志 } } } }4. ESP8266 AT指令实战:打通网络任督二脉
一切就绪,现在开始指挥ESP8266干活。AT指令就是我们对它发号施令的语言。顺序很重要,必须等上一条指令返回成功,才能发下一条。
4.1 获取网络时间(NTP校时)
网上有很多免费的NTP服务器,但有些地址会变。我找到一个比较稳定的方法:通过访问一个返回标准HTTP头的时间服务器,从Date字段里解析时间。原始文章用的cgi.im.qq.com就是个例子。
发送的指令序列如下(注意,每个指令后需要换行\r\n,等待回复OK或WIFI GOT IP):
AT AT+CWMODE=1 AT+RST AT+CWJAP="你的WiFi名","你的密码" AT+CIPSTART="TCP","cgi.im.qq.com",80 AT+CIPMODE=1 AT+CIPSEND发送完AT+CIPSEND后,模块会回复一个单独的>,这时紧接着发送(注意后面有两个换行):
GET http://cgi.im.qq.com然后,ESP8266会收到一串HTTP响应头。我们需要从中找到类似Date: Fri, 03 May 2024 08:30:15 GMT的行。解析这个字符串,把“Fri, 03 May 2024 08:30:15 GMT”提取出来。注意,这是格林威治时间,我们需要把它转换成北京时间(GMT+8)。
解析函数的核心思路就是字符串查找和格式转换。比如,找到“May”就知道是5月,找到“08:30:15”就拆分成时、分、秒,然后小时加8(注意超过24要进位到日期)。最后,用解析出的年月日时分秒去设置STM32内部的RTC(实时时钟)。这样,一次校时完成后,即使断网,RTC也能依靠晶振继续走时,精度足够日常使用。
4.2 获取实时天气数据
天气数据我推荐使用心知天气的免费API,个人开发足够用。你需要先去它的官网注册一个账号,获取一个API Key。
连接和发送指令的流程和获取时间类似,只是目标服务器和请求内容变了:
AT+CIPSTART="TCP","api.seniverse.com",80 AT+CIPMODE=1 AT+CIPSEND在收到>后,发送GET请求(将YOUR_API_KEY和YOUR_LOCATION替换成你的):
GET /v3/weather/now.json?key=YOUR_API_KEY&location=YOUR_LOCATION&language=zh-Hans&unit=c HTTP/1.1 Host: api.seniverse.com Connection: close这次,ESP8266会收到一个JSON格式的响应,这才是我们想要的“宝藏”。原始文章里用字符串匹配的方式解析,虽然直接,但比较脆弱,一旦JSON格式微调就容易出错。
5. 数据解析进阶:使用cJSON优雅地处理天气JSON
面对复杂的JSON数据,手动解析就像用螺丝刀砍树,费力不讨好。引入cJSON这个轻量级的开源库,能让解析工作变得轻松又可靠。
5.1 移植cJSON到STM32
首先去GitHub下载cJSON的源码(就一个cJSON.c和一个cJSON.h)。把它添加到你的Keil工程中。由于cJSON用了标准库函数如malloc、free,你需要确保你的工程有可用的内存管理实现(比如使用stdlib.h或者自己实现)。对于STM32,通常需要重写malloc和free,或者使用编译器自带的微库(Microlib),并在魔术棒设置里勾选“Use MicroLIB”。
5.2 解析实战代码示例
假设我们收到了完整的JSON响应,存储在weather_json_string中。
#include "cJSON.h" void Parse_Weather_Data(char* json_string) { cJSON* root = cJSON_Parse(json_string); if(root == NULL) { printf("JSON解析失败!可能是数据不完整或格式错误。\r\n"); const char* error_ptr = cJSON_GetErrorPtr(); if(error_ptr != NULL) { printf("错误位置:%s\r\n", error_ptr); } return; } // 1. 定位到results数组的第一个元素(通常就是我们查询的城市) cJSON* results = cJSON_GetObjectItem(root, "results"); if(cJSON_IsArray(results) && cJSON_GetArraySize(results) > 0) { cJSON* city_data = cJSON_GetArrayItem(results, 0); // 2. 解析城市信息 cJSON* location = cJSON_GetObjectItem(city_data, "location"); char* city_name = cJSON_GetObjectItem(location, "name")->valuestring; printf("城市:%s\r\n", city_name); // 3. 解析当前天气 cJSON* now = cJSON_GetObjectItem(city_data, "now"); char* weather_text = cJSON_GetObjectItem(now, "text")->valuestring; char* temperature = cJSON_GetObjectItem(now, "temperature")->valuestring; char* humidity = cJSON_GetObjectItem(now, "humidity")->valuestring; // 湿度 char* wind_direction = cJSON_GetObjectItem(now, "wind_direction")->valuestring; // 风向 char* wind_scale = cJSON_GetObjectItem(now, "wind_scale")->valuestring; // 风力等级 printf("天气:%s,温度:%s°C,湿度:%s%%,风向:%s,风力:%s级\r\n", weather_text, temperature, humidity, wind_direction, wind_scale); // 4. 解析更新时间 char* last_update = cJSON_GetObjectItem(city_data, "last_update")->valuestring; printf("更新时间:%s\r\n", last_update); // 5. 将解析出的数据,传递给显示函数 Display_Weather_Info(city_name, weather_text, temperature, humidity, wind_direction, wind_scale); } else { printf("未找到有效的天气数据。\r\n"); } // 务必释放cJSON对象树,防止内存泄漏! cJSON_Delete(root); }使用cJSON后,代码的可读性和健壮性大大提升。你不再需要担心JSON字段顺序变化,或者因为手动计算下标出错。更重要的是,当你想增加解析“体感温度”、“能见度”等新字段时,只需增加一两行代码即可。
6. 显示与界面优化:让信息一目了然
数据都有了,怎么漂亮地展示在屏幕上就是最后一步了。我用的是TFT LCD,支持图形和文字。
6.1 驱动与基础绘图
首先确保你的屏幕驱动(比如ILI9341、ST7789等)已经调通,有基本的画点、画线、显示字符和字符串的函数。通常卖家会提供示例代码。在此基础上,我们可以规划显示界面。
6.2 界面布局设计
我设计了一个简单的双屏界面,通过按键切换:
- 屏幕一(主时钟界面):
- 顶部大字体显示当前时间(如
14:30:05)。 - 中间显示日期、星期(如
2024-05-03 星期五)。 - 底部显示农历(可选,需要算法支持)和室内温湿度(如果接了DHT11传感器)。
- 顶部大字体显示当前时间(如
- 屏幕二(天气信息界面):
- 顶部显示城市名称和天气图标(例如,晴天画个太阳,雨天画个云朵和雨滴)。
- 中间用大字体显示温度和天气状况文字。
- 底部显示详细参数:湿度、风向风力、更新时间。
6.3 绘制天气图标
为了让界面更生动,可以绘制简单的图标。例如,判断weather_text是“晴”,就在对应位置画一个填充的黄色圆形和几条射线;是“雨”,就画几道蓝色的斜线。你可以用取模软件把简单的图标转换成数组,直接显示。我的做法是,在代码里根据天气状况调用不同的绘图函数。
void Draw_Weather_Icon(uint16_t x, uint16_t y, char* weather) { if(strstr(weather, "晴")) { // 画太阳 LCD_DrawFillCircle(x, y, 10, YELLOW); for(int i=0; i<8; i++) { int dx = 15 * cos(i * 3.14159 / 4); int dy = 15 * sin(i * 3.14159 / 4); LCD_DrawLine(x, y, x+dx, y+dy, YELLOW); } } else if(strstr(weather, "雨")) { // 画云和雨 LCD_DrawFillCircle(x-8, y, 6, LIGHT_GREY); LCD_DrawFillCircle(x+2, y, 8, LIGHT_GREY); LCD_DrawFillRect(x-10, y, 20, 10, LIGHT_GREY); for(int i=0; i<3; i++) { LCD_DrawLine(x-5+5*i, y+10, x-5+5*i, y+18, BLUE); } } // ... 其他天气状况 }7. 避坑指南与稳定性提升
做到这里,基本功能应该都有了。但要想让它7x24小时稳定运行,还需要注意以下几点,这都是我实测踩过的坑。
7.1 指令交互的严格同步
ESP8266的AT指令必须等待上一条的明确回复后才能发送下一条。绝对不能一股脑发出去。我写了一个发送指令并等待回复的函数,带超时检测。
uint8_t ESP8266_Send_Cmd_Wait_Resp(const char* cmd, const char* resp, uint32_t timeout) { USART3_SendString(cmd); // 发送指令 USART3_SendString("\r\n"); uint32_t start_tick = HAL_GetTick(); g_rx_len = 0; memset(g_esp8266_rx_buf, 0, COMM_BUF_SIZE); while((HAL_GetTick() - start_tick) < timeout) { if(g_data_ready_flag) { g_data_ready_flag = 0; if(strstr((char*)g_esp8266_rx_buf, resp) != NULL) { return 1; // 成功 } // 如果收到其他回复(如ERROR),也可以在这里判断 if(strstr((char*)g_esp8266_rx_buf, "ERROR") != NULL) { return 0; // 失败 } // 清空缓存,继续等待 g_rx_len = 0; } } printf("指令 %s 超时,未收到预期回复: %s\r\n", cmd, resp); return 0; // 超时失败 }7.2 网络异常处理与重连机制
网络环境是不稳定的。路由器可能重启,WiFi可能暂时断开。我们的程序必须有自我恢复能力。
- 心跳检测:在
NORMAL_DISPLAY状态下,可以每隔一段时间(比如5分钟)发送一个简单的AT指令(如AT)检查ESP8266是否在线。 - TCP连接检查:在发送天气/时间请求前,检查
AT+CIPSTATUS,如果连接断开,则重新执行AT+CIPSTART。 - 分级重试:连接WiFi失败,不要无限重试。可以设置一个重试计数器,连续失败3次后,进入ERROR状态,闪烁LED报警,等待一段时间(如1分钟)或按键触发后再重试。
7.3 电源与抗干扰
- 电源滤波:ESP8266在发射WiFi信号时瞬时电流较大(可达200mA以上)。如果直接用开发板的3.3V引脚,可能会引起电压跌落,导致STM32复位。最好给ESP8266的VCC单独加一个100uF的电解电容并联一个0.1uF的瓷片电容,进行退耦。
- 串口电平:确保STM32和ESP8266的串口TX/RX交叉连接,且共地良好。长距离连接时,可以考虑降低波特率到9600以提高稳定性。
- 复位电路:如果ESP8266偶尔死机无响应,可以尝试用STM32的一个GPIO控制其
RST引脚,在程序超时时对其进行硬件复位。
最后,把所有这些代码模块整合起来,编译、下载到STM32。上电后,你应该能看到屏幕先初始化,然后连接WiFi,获取时间并显示,接着更新天气信息。一个属于你自己的、独一无二的智能天气时钟就诞生了。看着它稳定运行,实时显示着准确的信息,那种成就感,是买任何成品都无法替代的。这个项目虽然不大,但涵盖了嵌入式开发从硬件到软件、从通信到协议解析、从驱动到应用层设计的多个环节,是一个非常棒的练手项目。希望我的这些经验,能帮你少走弯路,顺利做出自己的作品。