文章目录
- [1. 项目背景](#1. 项目背景)
- [2. 硬件与软件环境](#2. 硬件与软件环境)
- [3. 整体架构](#3. 整体架构)
- [4. TFT 界面设计](#4. TFT 界面设计)
- [5. LCD 刷新策略:从全屏刷新到局部刷新](#5. LCD 刷新策略:从全屏刷新到局部刷新)
- [6. 时间机制:NTP + 系统 Tick,不使用 RTC](#6. 时间机制:NTP + 系统 Tick,不使用 RTC)
- [7. WiFi 状态判断](#7. WiFi 状态判断)
- [8. 天气接口选择与公网 IP 城市定位](#8. 天气接口选择与公网 IP 城市定位)
- [9. HTTP 客户端实现细节](#9. HTTP 客户端实现细节)
- [10. 轻量 JSON 解析](#10. 轻量 JSON 解析)
- [11. 调试日志设计](#11. 调试日志设计)
- [12. MSH 调试命令](#12. MSH 调试命令)
- [13. 构建方法](#13. 构建方法)
- [14. 关键问题与解决过程](#14. 关键问题与解决过程)
-
- [14.1 屏幕只显示网格,没有天气内容](#14.1 屏幕只显示网格,没有天气内容)
- [14.2 WiFi 已连接,但界面显示 WIFI WAIT](#14.2 WiFi 已连接,但界面显示 WIFI WAIT)
- [14.3 海外天气服务不可用](#14.3 海外天气服务不可用)
- [14.4 全屏刷新闪烁](#14.4 全屏刷新闪烁)
- [14.5 时间跳分钟延迟](#14.5 时间跳分钟延迟)
- [15. 可改进方向](#15. 可改进方向)
- [16. 总结](#16. 总结)
1. 项目背景
本文记录一个运行在 GD32VW553H-EVAL 开发板上的天气时钟程序设计过程。系统基于 RT-Thread,使用 ILI9341 SPI TFT 屏幕显示时间、城市、天气图标、温度、湿度和风速,并通过 WiFi 访问公网天气接口获取实时数据。

这个项目看起来是一个"小 UI Demo",但对嵌入式系统设计人员来说,里面涉及的关键点比较典型:
- RT-Thread 线程模型与主循环任务调度
- WiFi 联网状态判断
- NTP 时间同步
- HTTP 客户端实现
- JSON 字段轻量解析
- TFT 屏幕绘制与局部刷新
- 网络阻塞对 UI 实时性的影响
- 串口日志驱动的问题定位
当前工程路径为:
text
D:\rt-thread\bsp\gd32\risc-v\gd32vw553h-eval
主要代码文件:
text
applications/main.c
applications/weather_clock.c
board/port/ili9341/
2. 硬件与软件环境
硬件平台:
- MCU:GD32VW553H
- 开发板:GD32VW553H-EVAL
- 屏幕:ILI9341 SPI TFT
- 屏幕分辨率:240 x 320
- 网络:板载 WiFi
软件环境:
- RT-Thread 5.3.0
- lwIP 网络协议栈
- netutils NTP 组件
- SCons 构建系统
- RISC-V GCC 工具链
程序启动入口在 applications/main.c 中:
c
extern int weather_clock_start(void);
int main(void)
{
rt_kprintf("Hello GD32VW553H\n");
weather_clock_start();
/* LED heartbeat */
while (1)
{
rt_pin_write(LED1_PIN, PIN_HIGH);
rt_thread_mdelay(500);
rt_pin_write(LED1_PIN, PIN_LOW);
rt_thread_mdelay(500);
}
}
天气时钟本身运行在独立线程中,因此不会阻塞主线程里的 LED 心跳。
3. 整体架构
天气时钟线程由 weather_clock_start() 创建:
c
tid = rt_thread_create("weather",
weather_clock_thread,
RT_NULL,
WC_THREAD_STACK_SIZE,
WC_THREAD_PRIORITY,
WC_THREAD_TICK);
核心参数如下:
c
#define WC_THREAD_STACK_SIZE 8192
#define WC_THREAD_PRIORITY 25
#define WC_THREAD_TICK 20
#define WC_WEATHER_REFRESH_SEC (15 * 60)
#define WC_WEATHER_RETRY_SEC (2 * 60)
#define WC_NTP_REFRESH_SEC (6 * 60 * 60)
#define WC_NTP_RETRY_SEC (2 * 60)
#define WC_NET_SETTLE_SEC 3
#define WC_UI_LOOP_DELAY_MS 200
#define WC_NET_SAFE_WINDOW_SEC 35
系统任务可以分为五层:
- LCD 层:负责 ILI9341 初始化、像素、矩形、文字、图标绘制。
- UI 层:负责天气时钟界面布局、局部刷新、渲染缓存。
- 时间层:通过 NTP 获取 UTC 时间,再用 RT-Thread tick 推进本地显示时间。
- 网络层:通过 WiFi + lwIP socket 访问 HTTP 接口。
- 数据层:解析天气接口返回的 JSON 字段,更新显示模型。
整体流程如下:
text
系统启动
|
+--> 初始化 ILI9341
|
+--> 配置 WiFi 自动重连
|
+--> 首次全屏绘制 UI
|
+--> 循环任务,每 200ms 执行一次
|
+--> 检查 WiFi 状态
+--> 检查分钟是否变化,必要时局部刷新时间
+--> 到期后执行 NTP 同步
+--> 到期后获取天气数据
+--> 数据变化后局部刷新天气区域
4. TFT 界面设计
屏幕分辨率为 240 x 320,因此 UI 需要比较紧凑。当前界面采用竖屏布局:
- 顶部状态栏:城市名称、WiFi、电池样式图标
- 中部:七段数码管风格时钟
- 日期行:星期、日期、月份
- 天气卡片:天气图标、温度、天气状态
- 底部指标:湿度、风速
颜色采用 RGB565:
c
#define RGB565(r, g, b) \
(rt_uint16_t)((((r) & 0xF8) << 8) | (((g) & 0xFC) << 3) | ((b) >> 3))
#define C_BG RGB565(3, 8, 18)
#define C_PANEL RGB565(10, 25, 39)
#define C_CYAN RGB565(0, 216, 255)
#define C_WHITE RGB565(228, 248, 255)
#define C_AMBER RGB565(255, 184, 48)
字体没有依赖外部字库,而是使用 5x7 点阵字体。这样做的好处是:
- RAM/Flash 占用可控
- 绘制逻辑简单
- 便于缩放
- 适合英文城市名和数字显示
天气图标也没有使用图片资源,而是通过基础图元绘制:
- 晴天:圆形太阳 + 射线
- 多云:多个圆形叠加 + 矩形云底
- 雨天:云 + 雨线
- 雪、雾:映射到对应图标风格
这种做法适合小型 MCU,因为不需要额外图片解码,也不需要占用大量 Flash 保存位图。
5. LCD 刷新策略:从全屏刷新到局部刷新
早期版本每次刷新都会执行:
c
ili9341_fill(C_BG);
这会导致整个屏幕先被清空,再重新绘制所有元素。SPI TFT 的刷新带宽有限,因此会出现明显闪烁。
后续改为局部刷新。程序增加了渲染缓存:
c
struct wc_render_cache
{
rt_bool_t valid;
rt_bool_t wifi_ready;
rt_bool_t time_valid;
rt_bool_t weather_valid;
int hour;
int minute;
int day;
int month;
int weekday;
char city[24];
char condition[18];
int temp_c;
int humidity;
int wind_kmh;
enum wc_weather_icon icon;
};
刷新时先判断哪些数据发生变化:
c
status_dirty = full_refresh ||
g_render_cache.wifi_ready != wifi_ready ||
strcmp(g_render_cache.city, g_weather.city) != 0;
clock_dirty = full_refresh ||
g_render_cache.time_valid != g_time_valid ||
g_render_cache.hour != tm_buf.tm_hour ||
g_render_cache.minute != tm_buf.tm_min;
weather_dirty = full_refresh ||
g_render_cache.weather_valid != g_weather.valid ||
g_render_cache.icon != g_weather.icon ||
g_render_cache.temp_c != g_weather.temp_c ||
strcmp(g_render_cache.condition, g_weather.condition) != 0;
只有对应区域变化才重画:
c
if (clock_dirty)
{
wc_draw_background_region(30, 52, 180, 58);
wc_draw_clock(&tm_buf);
}
if (weather_dirty)
{
wc_draw_weather_block(&g_weather);
}
if (metrics_dirty)
{
wc_draw_metrics(&g_weather);
}
局部清屏不能简单填充纯背景色,因为原界面有网格背景。为避免擦除后出现纯色块,增加了 wc_draw_background_region(),只恢复指定区域内的背景色和网格线。
这一点在 SPI 屏幕上很关键:减少像素写入量比提高 CPU 绘图速度更有效。
6. 时间机制:NTP + 系统 Tick,不使用 RTC
当前程序没有使用硬件 RTC。时间来源是 NTP:
c
utc = ntp_get_time(RT_NULL);
NTP 成功后保存两个基准值:
c
g_utc_base = utc;
g_utc_base_tick = rt_tick_get();
g_time_valid = RT_TRUE;
后续显示时间时,用当前 tick 推算:
c
now = g_utc_base +
(rt_tick_get() - g_utc_base_tick) / RT_TICK_PER_SECOND;
这种方式不依赖 RTC,适合联网设备。但需要注意:
- 断网重启后,时间会先显示默认值,直到 NTP 成功。
- tick 长时间运行会有累计误差,因此需要定期 NTP 校时。
- 当前 NTP 周期为 6 小时。
早期版本的主循环每次会阻塞等待 WiFi 1 秒,再延时 1 秒:
c
wifi_ready = wc_wait_wifi_ready(1000);
rt_thread_mdelay(1000);
这会导致分钟跳变最多延迟接近 2 秒。后来将 UI 循环周期改为 200ms,并且每轮优先刷新时间:
c
#define WC_UI_LOOP_DELAY_MS 200
同时增加网络安全窗口:
c
#define WC_NET_SAFE_WINDOW_SEC 35
当当前秒数已经超过 35 秒时,普通 NTP/天气请求会延后执行,避免 HTTP/NTP 阻塞跨分钟刷新。手动刷新不受这个限制。
7. WiFi 状态判断
实际调试中发现,开发板拿到 IP 后,rt_wlan_is_ready() 可能仍然返回 0。因此不能只依赖 RT-WLAN 的 ready 状态。
最终使用 netdev_default 结合 IP 地址判断网络是否可用:
c
static rt_bool_t wc_network_ready(void)
{
struct netdev *netdev = netdev_default;
rt_bool_t wlan_connected = rt_wlan_is_connected();
if (netdev &&
netdev_is_up(netdev) &&
netdev_is_link_up(netdev) &&
!ip_addr_isany(&netdev->ip_addr))
{
return RT_TRUE;
}
if (wlan_connected && netdev && !ip_addr_isany(&netdev->ip_addr))
return RT_TRUE;
return RT_FALSE;
}
串口日志会打印网络状态:
text
weather_clock: wifi state wlan_connected=1 wlan_ready=0 netdev=wl1 flags=0x01af ip=192.168.0.106
这个日志非常有价值,因为它说明 WLAN 管理状态和 netdev/IP 状态并不总是同步。
8. 天气接口选择与公网 IP 城市定位
最初尝试过多个天气服务,但部分服务在开发板上存在访问失败、连接被关闭或接收异常的问题。最终使用 K780 的 HTTP 接口:
c
#define WC_HTTP_HOST "api.k780.com"
为了避免固定显示上海,当前流程改为先获取公网 IP,再按 IP 查询天气:
text
1. GET /?app=ip.local&...
2. 解析公网 IP
3. GET /?app=weather.today&cityIp=<公网IP>&...
4. 如果失败,尝试 GET /?app=weather.today&weaid=<公网IP>&...
5. 如果仍失败,回退到固定城市
相关宏:
c
#define WC_IP_PATH "/?app=ip.local&" WC_API_AUTH
#define WC_WEATHER_PATH_PREFIX "/?app=weather.today&cityIp="
#define WC_WEATHER_WEAID_PREFIX "/?app=weather.today&weaid="
#define WC_WEATHER_PATH_SUFFIX "&" WC_API_AUTH
公网 IP 获取函数:
c
static rt_err_t wc_fetch_public_ip(char *rx,
size_t rx_size,
char *ip,
size_t ip_size)
{
ret = wc_http_get(WC_IP_PATH, rx, rx_size);
if (ret != RT_EOK)
return ret;
body = wc_http_body(rx);
if (!wc_json_copy_value(body, "\"ip\"", ip, ip_size))
return -RT_ERROR;
return RT_EOK;
}
天气获取函数会根据公网 IP 拼接 path:
c
rt_snprintf(path, sizeof(path), "%s%s%s",
WC_WEATHER_PATH_PREFIX,
public_ip,
WC_WEATHER_PATH_SUFFIX);
城市名称不再写死为 SHANGHAI,而是解析天气接口返回的 cityno 字段:
c
wc_json_copy_value(body, "\"cityno\"", cityno, sizeof(cityno));
if (cityno[0])
wc_upper_copy(weather->city, sizeof(weather->city), cityno);
例如接口返回:
json
"cityno":"shanghai"
屏幕显示:
text
SHANGHAI
这样既满足英文显示要求,又能根据公网 IP 自动切换城市。
9. HTTP 客户端实现细节
为了减少依赖,HTTP 客户端直接基于 BSD socket 实现:
c
host = gethostbyname(WC_HTTP_HOST);
sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, (struct sockaddr *)&addr, sizeof(addr));
send(sock, request, strlen(request), 0);
recv(sock, rx + total, rx_size - 1 - total, MSG_DONTWAIT);
请求使用 HTTP/1.0 和 Connection: close:
c
rt_snprintf(request, sizeof(request),
"GET %s HTTP/1.0\r\n"
"Host: %s\r\n"
"User-Agent: Mozilla/5.0\r\n"
"Accept: */*\r\n"
"Connection: close\r\n\r\n",
path, WC_HTTP_HOST);
这里有几个嵌入式网络开发中的经验点:
- 使用 HTTP/1.0 可以简化连接生命周期。
Connection: close方便判断服务端发送完成。- 接收缓冲区固定为 1024 字节,避免动态扩容。
- 非阻塞
recv(..., MSG_DONTWAIT)配合 tick 超时,避免线程永久卡死。 - 每次请求结束后
closesocket(sock),不复用连接。
接收超时逻辑:
c
deadline = start + rt_tick_from_millisecond(8000);
while (total < (int)rx_size - 1)
{
len = recv(sock, rx + total, rx_size - 1 - total, MSG_DONTWAIT);
if (len > 0)
{
total += len;
continue;
}
if (len == 0)
break;
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
{
if (rt_tick_get() >= deadline)
break;
rt_thread_mdelay(50);
continue;
}
break;
}
这个实现不是通用 HTTP 库,但对当前天气接口足够,代码体积也比较小。
10. 轻量 JSON 解析
MCU 上不一定适合引入完整 JSON 库,尤其当前 RAM 已经比较紧张。因此程序使用简单字段扫描:
c
static rt_bool_t wc_json_copy_value(const char *json,
const char *key,
char *out,
size_t out_size)
{
p = strstr(json, key);
p = strchr(p, ':');
...
}
解析的字段包括:
temp_curr:当前温度humidity:湿度winp:风力等级weather:中文天气描述cityno:英文/拼音城市编号
风力等级转换为估算风速:
c
weather->wind_kmh = wc_parse_first_int(wind_scale, 1) * 6;
天气状态根据中文描述映射到英文显示和图标:
text
晴 -> SUNNY
多云 -> CLOUDY
阴 -> OVERCAST
雨 -> RAIN
雪 -> SNOW
雾/霾 -> FOG
如果项目后续需要更复杂的数据结构,建议换成小型 JSON 解析器;但对于固定接口、固定字段,当前方式更省资源。
11. 调试日志设计
本项目在调试过程中大量依赖串口日志。统一宏如下:
c
#define WC_LOG(fmt, ...) \
rt_kprintf("weather_clock: " fmt "\r\n", ##__VA_ARGS__)
典型启动日志:
text
weather_clock: thread start lcd=240x320
weather_clock: lcd init ok
weather_clock: wifi autoreconnect enable
weather_clock: wifi connect ssid=...
weather_clock: wifi prepare done wlan_connected=1 wlan_ready=0 netdev=wl1 flags=0x01af ip=192.168.0.106
天气请求日志:
text
weather_clock: ip fetch start
weather_clock: http dns host=api.k780.com path=/?app=ip.local&...
weather_clock: http connect ...
weather_clock: http send ok bytes=...
weather_clock: http recv bytes=...
weather_clock: ip parse ok ip=...
weather_clock: weather query by ip=...
weather_clock: parse ok city=SHANGHAI cityno=shanghai cond=CLOUDY temp=17 hum=47 wind=6
渲染日志:
text
weather_clock: render start full=0 wifi=1 time_valid=1 weather_valid=1 city=SHANGHAI ...
weather_clock: render clock done
weather_clock: render metrics done
weather_clock: render done
调试建议:
- 网络问题优先看 DNS、connect、send、recv 四个阶段。
- 屏幕问题优先看 LCD test 命令是否正常。
- 时间问题优先看 NTP 是否成功,以及 UI 循环是否被 HTTP 阻塞。
- WiFi 问题不要只看
rt_wlan_is_ready(),同时看 netdev IP。
12. MSH 调试命令
程序导出了两个 MSH 命令:
c
MSH_CMD_EXPORT(weather_clock_refresh,
refresh weather clock screen and network data);
MSH_CMD_EXPORT(weather_clock_lcd_test,
test weather clock LCD drawing paths);
使用方式:
text
msh />weather_clock_refresh
msh />weather_clock_lcd_test
weather_clock_refresh 用于强制刷新网络数据和屏幕,适合调试天气接口。
weather_clock_lcd_test 用于验证 TFT 基础绘制路径。如果 LCD test 正常,而天气界面异常,说明问题更可能在 UI 绘制坐标、刷新策略或数据状态,而不是底层屏幕驱动。
13. 构建方法
在 BSP 目录下执行:
powershell
scons -j4
成功后会生成:
text
rtthread.elf
rtthread.bin
当前构建时 RAM 使用率较高:
text
ram: 288 KB / 288 KB = 100%
这意味着后续继续加功能时需要谨慎:
- 减少线程栈
- 避免大数组放在 BSS
- 避免引入大型 JSON/HTTP/TLS 库
- 图片资源尽量不用大位图
- 尽量复用网络接收缓冲区
如果要访问 HTTPS 天气接口,还需要 TLS,会显著增加 RAM 和 Flash 压力。当前选择 HTTP 接口,也是出于资源约束考虑。
14. 关键问题与解决过程
14.1 屏幕只显示网格,没有天气内容
原因是早期 UI 渲染和网络状态之间缺少足够日志,不容易判断是 LCD 没画、数据没来,还是线程阻塞。
解决方法:
- 增加 LCD 初始化日志
- 增加每个 UI 模块绘制完成日志
- 增加天气数据状态日志
14.2 WiFi 已连接,但界面显示 WIFI WAIT
现象:
text
WiFi connected successfully
weather_clock: wifi prepare done wlan_connected=1 wlan_ready=0 netdev=wl1 flags=0x01af ip=192.168.0.106
rt_wlan_is_ready() 为 0,但 IP 已经获取。因此改用 netdev + IP 地址判断网络状态。
14.3 海外天气服务不可用
访问部分海外服务时出现:
text
http recv bytes=0
weather fetch failed
后来切换到国内可访问性更好的接口,并增加 body preview 日志,确认返回内容。
14.4 全屏刷新闪烁
原因是每次刷新都清空整屏。
解决方法:
- 首次全屏绘制
- 后续使用渲染缓存判断变化
- 只刷新变化区域
- 局部清屏时恢复背景网格
14.5 时间跳分钟延迟
原因是 UI 循环被 WiFi 等待、HTTP/NTP 请求阻塞。
解决方法:
- 主循环改为 200ms
- 不再每轮阻塞等待 WiFi
- 每轮先刷新时间,再执行网络任务
- 接近下一分钟时延后普通网络请求
15. 可改进方向
当前版本已经能实现天气时钟的核心功能,但还可以继续优化:
- 使用硬件 RTC 保持离线时间。
- 将 NTP/HTTP 放到独立网络线程,UI 线程只负责显示。
- 给天气数据增加更新时间显示。
- 增加网络失败状态图标。
- 将 WiFi SSID/密码改为 menuconfig 或外部配置。
- 使用更稳健的 JSON 解析器,但要评估 RAM。
- 增加屏幕双缓冲或行缓冲,进一步减少闪烁。
- 使用 DMA SPI 提高 LCD 刷新效率。
16. 总结
这个天气时钟项目的核心不在于界面复杂度,而在于嵌入式系统中多个子系统的协同:
- LCD 刷新带宽有限,需要局部刷新。
- 网络请求可能阻塞,需要避免影响 UI 实时性。
- WLAN ready 状态不一定可靠,需要结合 netdev 判断。
- 没有 RTC 时,可以用 NTP + tick 推进时间。
- RAM 紧张时,应避免大库和大缓冲区。
- 串口日志是定位网络、显示、调度问题的关键手段。
对于资源受限的 MCU 应用,功能实现只是第一步。真正决定体验的是任务调度、异常处理、刷新策略和日志可观测性。这个项目正好覆盖了这些嵌入式应用开发中非常常见、也非常实用的问题。