这是我前面的手表项目,在我的智能手表项目中,天气显示和时间同步是两个非常核心的功能。本文将详细复盘我是如何通过 STM32 驱动 ESP8266 模块,连接 Wi-Fi 后通过 TCP 协议访问心知天气服务器 获取当地天气与温度,并连接阿里云 NTP 服务器实现精准的时间对齐的。
这套逻辑经过了无数次 Debug 和优化,彻底解决了单片机内存溢出、死机、时间解析错乱等问题,包含了我对 C 语言指针、内存管理以及 FreeRTOS 任务调度的深度总结。
一、 整体流程与通信架构
整个天气与时间模块的通信底层,依赖于一套严格的 "AT 指令收发与单缓冲" 机制。
1. 为什么只用一个全局大缓冲?
在最初的设计中,我尝试用 malloc 动态分配缓存,或者定义多个数组来存放时间与天气数据。但在资源极其有限的单片机(如 STM32F103)中,强行分配大量内存极易导致 Heap 溢出,引发底层的 HardFault 硬件死机。 最终,我采用了一个全局共享大缓冲 uint8_t UART_Rx_Buffer[1024];。
2. 收发底层逻辑
-
发送前清空: 每发送一次 AT 指令前,必须用
memset清空这个数组,防止上一次的残留数据(前朝遗毒)干扰这一次的解析。 -
死等返回值: 发送一次 AT 指令后,必须通过循环一直读取串口接收区,直到读到预期的返回值(如
OK、ERROR或ready)才允许发送下一次 AT 指令。 -
特殊数据截获: 一般的 AT 交互返回的都是
OK,但当遇到获取天气数据 和获取时间 的指令时,ESP8266 返回的是一大串包含 JSON 和 NTP 时间戳的冗长字符串(在 C 语言看来就像乱码)。此时我们要特殊处理:先把UART_Rx_Buffer中的有用数据解析完、存进变量后,再发送下一条(比如+++退出透传或关闭连接的指令),否则缓存就会被下一次通信无情清空。
二、 乱码淘金:如何精准解析我们需要的数据?
API 返回的数据是一大堆乱码,而有用的数据就夹杂在其中。如何在不使用庞大且吃内存的 cJSON 库的情况下,高效提取城市、天气和时间?这就需要用到 strstr 定位 + 指针偏移 + sscanf 安全提取 的绝技。
1. 解析城市名称
乱码中城市前面的数据通常是 "name":"城市"。 我们可以通过 strstr(UART_Rx_Buffer, "\"name\":\"") 找到这个字符串的首地址。因为 "name":" 恰好占据了 8 个字符,所以我们将找到的地址 +8,指针就精准地指向了城市名字(UTF-8编码或拼音)的开头。
⚠️ 致命踩坑点:sscanf 的防爆栈装甲 如果我们直接用 %s 读取,一旦服务器返回异常的长字符串,瞬间就会撑爆单片机的数组导致死机。因此,我使用了带有长度限制和终止符的正则表达式写法:"%19[^\"]"。 它的意思是:最多只读取 19 个字符,且一旦遇到双引号 " 就立刻停止!
p = strstr((char *)UART_Rx_Buffer, "\"name\":\"");
if (p != NULL)
{
// p+8 完美跳过标签,%19[^\"] 确保绝对不会越界死机
sscanf(p + 8, "%19[^\"]", weather_city);
// 拿着解析出来的天气数据去字典里对比...
}
2. 查字典映射显示
拿出来的城市数据可能是 UTF-8 编码,我们怎么让 OLED 知道该画哪个中文呢? 我创建了一个极其好用的结构体字典 。以城市为例,结构体包含三个元素:UTF-8字符、城市中文名、获取该城市天气的完整 GET 请求指令。
typedef struct
{
char utf8_code[15]; // 服务器返回的匹配码
char name[10]; // 对应的中文名
uint8_t *AT; // 专属的 AT 请求指令
} City;
static const City my_City[] = {
{{0xE8, 0xB4, 0xB5, 0xE9, 0x98, 0xB3, 0x00}, "贵阳", "GET /v3/weather/...location=guiyang...\r\n\r\n"},
// ...北京、遵义等
};
提取出字符后,用一个 for 循环遍历这个结构体数组,对比到相同的 utf8_code,就记录下它的索引号(比如 city_i)。后续交给 OLED 显示时,直接调用 my_City[city_i].name 即可,极大降低了系统的内存和运算负担。
三、 多城市天气切换
基于上述的结构体设计,多城市切换变得异常简单。 由于我们将长达 130 字节的 GET 请求 HTTP 报文直接作为 const 字符串绑在了结构体的第三个变量 AT 上(这部分数据直接存在单片机的 Flash 中,0 占用 RAM 运行内存!),在发送获取天气的指令时,我们不再写死城市,而是动态调用:
// L 是当前选择的城市索引,通过按键改变
uint8_t *AT = (uint8_t *)my_City[L].AT;
ESP8266_SendAt(AT, strlen((char *)AT), UART_Rx_Buffer, &UART_Rx_Len);
在天气模块中,用户通过按键修改 L 的值,就能动态改变发送出去的 AT 指令,完美实现切换城市查看天气的功能!
四、 自动时间对齐机制与防御装甲
智能手表的灵魂在于时间准确。我们通过阿里云 NTP 服务器对齐时间,这里的设计也是坑点满满,最终我总结出了一套坚如磐石的对时逻辑。
1. 开机"死缠烂打"对时法
在 FreeRTOS 的 GUI_Task 任务启动之初,第一件事就是获取时间对齐。 但是,ESP8266 连上路由器后再去公网同步阿里云时间,是存在物理网络延迟的(大约需要 2~4 秒)。如果只等个固定时间就去查,很容易查错。 因此,我采用了一个 15秒循环死缠烂打机制:
uint8_t retry = 0;
while(retry < 15)
{
vTaskDelay(pdMS_TO_TICKS(1000)); // 每秒问一次
ESP8266_GetTime(); // 内部调用解析
// 如果拿到真时间了,立刻跳出循环!
if (strstr((char *)UART_Rx_Buffer, "1970") == NULL && strstr((char *)UART_Rx_Buffer, "+CIPSNTPTIME:") != NULL) {
break;
}
retry++;
}
2. "1970" 防御装甲与 "20:15" 幽灵 Bug
-
1970 防御装甲: 当 ESP8266 没同步好时间时,去查它,它会默认返回
1970 年(时间戳 0 点)。如果不加防备,单片机的 RTC 时间就会被强行覆盖成 1970 年 8 点。所以我在Parse_Time()函数里加了一把锁:if (strstr(UART_Rx_Buffer, "1970") != NULL) return;,看到 1970 直接扔进垃圾桶,保护硬件 RTC 的原本时间不被污染。 -
拔除 CubeMX 的幽灵时间: 以前按复位键时间总会变成
20:15,后来发现这是 STM32CubeMX 自动生成的MX_RTC_Init()里强行写入的电脑编译时间。必须把底层这段HAL_RTC_SetTime注释掉,才能保证按复位键时,RTC 的真实走时不受影响。
3. 一小时静默对时
在 GUI_Task 的死循环中,我利用消息队列超时机制做了一个定时器。如果持续 1 个小时没有任何按键按下,系统会在后台悄悄唤醒 Wi-Fi,用上面的循环法再去对一次时间,确保手表始终分秒不差。
五、 功耗管理:用完即焚
由于智能手表对功耗要求极其苛刻,而 ESP8266 在工作时电流极大,所以我们在逻辑上严格执行**"用完即焚"**策略。
-
无论是开机获取完时间。
-
还是每小时自动对时结束。
-
或者是用户退出天气模块。
都会立刻调用 WiFi_PowerOff() 发送 AT+CWMODE=0 指令,彻底关闭 ESP8266 的射频模块,最大化延长手表的续航时间。