【兆易创新GD32VW553开发板试用】天气时钟设计与调试实战

文章目录

  • [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

系统任务可以分为五层:

  1. LCD 层:负责 ILI9341 初始化、像素、矩形、文字、图标绘制。
  2. UI 层:负责天气时钟界面布局、局部刷新、渲染缓存。
  3. 时间层:通过 NTP 获取 UTC 时间,再用 RT-Thread tick 推进本地显示时间。
  4. 网络层:通过 WiFi + lwIP socket 访问 HTTP 接口。
  5. 数据层:解析天气接口返回的 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);

这里有几个嵌入式网络开发中的经验点:

  1. 使用 HTTP/1.0 可以简化连接生命周期。
  2. Connection: close 方便判断服务端发送完成。
  3. 接收缓冲区固定为 1024 字节,避免动态扩容。
  4. 非阻塞 recv(..., MSG_DONTWAIT) 配合 tick 超时,避免线程永久卡死。
  5. 每次请求结束后 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. 可改进方向

当前版本已经能实现天气时钟的核心功能,但还可以继续优化:

  1. 使用硬件 RTC 保持离线时间。
  2. 将 NTP/HTTP 放到独立网络线程,UI 线程只负责显示。
  3. 给天气数据增加更新时间显示。
  4. 增加网络失败状态图标。
  5. 将 WiFi SSID/密码改为 menuconfig 或外部配置。
  6. 使用更稳健的 JSON 解析器,但要评估 RAM。
  7. 增加屏幕双缓冲或行缓冲,进一步减少闪烁。
  8. 使用 DMA SPI 提高 LCD 刷新效率。

16. 总结

这个天气时钟项目的核心不在于界面复杂度,而在于嵌入式系统中多个子系统的协同:

  • LCD 刷新带宽有限,需要局部刷新。
  • 网络请求可能阻塞,需要避免影响 UI 实时性。
  • WLAN ready 状态不一定可靠,需要结合 netdev 判断。
  • 没有 RTC 时,可以用 NTP + tick 推进时间。
  • RAM 紧张时,应避免大库和大缓冲区。
  • 串口日志是定位网络、显示、调度问题的关键手段。

对于资源受限的 MCU 应用,功能实现只是第一步。真正决定体验的是任务调度、异常处理、刷新策略和日志可观测性。这个项目正好覆盖了这些嵌入式应用开发中非常常见、也非常实用的问题。

复制代码
相关推荐
多看多敲多思考1 小时前
华润微CS32ME10 MCU使用教程(2)---CS32ME10之UART串口模块使用
stm32·单片机·嵌入式硬件·mcu
国科安芯2 小时前
核电站仪控与监测系统中抗辐射 MCU 芯片应用研究
单片机·嵌入式硬件·macos·无人机·cocos2d·核电站
黑白园2 小时前
STM32系统时钟由72M修改为36M验证示例
stm32·单片机·嵌入式硬件
LCG元3 小时前
基于ARM7的LCD设计与实现:S3C4510B通用IO口控制液晶模块
stm32·单片机·嵌入式硬件
山后太阳3 小时前
Keil5(MDK-ARM)完整下载安装教程+入门教程:从零搭建STM32开发环境
arm开发·stm32·嵌入式硬件
The_superstar64 小时前
衡山派学习之串口
单片机·嵌入式硬件·串口·衡山派
Ww.xh4 小时前
STM32按键去抖动软件实现详解
stm32·单片机·嵌入式硬件
ghie90904 小时前
基于STM32的CAN通信完整例程(HAL库实现)
stm32·单片机·嵌入式硬件
lzj_pxxw4 小时前
W25Q64存储芯片 软件设计刚需常识
stm32·单片机·嵌入式硬件·mcu·学习