仪表盘指针和时间显示

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

224.3.23 仪表盘指针和时间显示

uint32_t needle_angle = 0; // 定义指针角度步进变量并清零;后续用它生成传给 LVGL 的旋转角度值
RTC_TimeTypeDef struct_rtc_time; // 定义 RTC 时间结构体,用来接收读取到的时、分、秒
RTC_DateTypeDef struct_rtc_date; // 定义 RTC 日期结构体,用来接收读取到的年、月、日和星期
uint8_t time_buff[15] = {0}; // 定义 15 字节时间字符串缓冲区,并把全部字节初始化为 0
uint8_t date_buff[15] = {0}; // 定义 15 字节日期字符串缓冲区,并把全部字节初始化为 0

needle_angle++; // 每执行一轮主循环便把步进值加 1
needle_angle = needle_angle % 250; // 对 250 取余,使步进值在 0~249 之间循环
lv_img_set_angle(ui_ImageNeedle, needle_angle * 10); // 把步进值放大 10 倍后设置指针图片角度;LVGL 8 的角度单位为 0.1°

HAL_RTC_GetTime(&hrtc, &struct_rtc_time, RTC_FORMAT_BIN); // 以二进制数值格式读取 RTC 时间,并写入时间结构体
sprintf(time_buff, "%2u:%2u:%2u", struct_rtc_time.Hours, struct_rtc_time.Minutes, struct_rtc_time.Seconds); // 把时、分、秒格式化到时间缓冲区
lv_label_set_text(ui_LabelTime, time_buff); // 把格式化后的时间字符串显示到时间 Label

HAL_RTC_GetDate(&hrtc, &struct_rtc_date, RTC_FORMAT_BIN); // 以二进制数值格式读取 RTC 日期,并写入日期结构体
sprintf(date_buff, "20%2u-%2u-%2u", struct_rtc_date.Year, struct_rtc_date.Month, struct_rtc_date.Date); // 拼接“20”和两位年份,并格式化月、日
lv_label_set_text(ui_LabelDate, date_buff); // 把格式化后的日期字符串显示到日期 Label

上述代码按本节视频最后一次成功烧写前的画面原样整理,只增加了逐行注释。它记录的是添加到 main.c 用户代码区中的本节业务代码;上一节已经存在的图标变色逻辑没有重复抄录,CubeMX 自动生成的 RTC 初始化函数和 HAL/LVGL 库函数实现也没有收入。配套目录中的 24Lvgl_Dashboard 后来已经带有第 225 节加入的 HAL_GetTick() 节拍和 RTC 备份寄存器判断,因此不能把该目录当前状态整段当成本节结束时的源码。

视频中的 time_buff、date_buff 类型确实是 uint8_t[15],调用处也确实没有强制类型转换,所以这里没有擅自改成后续工程中的写法。其类型告警和更稳妥的写法在 3.9 节单独解释。

一、更改指针

1.1 更改指针的函数与变量定义

SquareLine Studio 生成的 ui_Screen1.c 中,仪表指针是图片对象 ui_ImageNeedle。创建图片后,生成代码使用下面两个关键接口:

  • lv_img_set_pivot(ui_ImageNeedle, 10, -30):设置旋转中心;指针会围绕这个点转动。
  • lv_img_set_angle(ui_ImageNeedle, 2500):设置图片的初始旋转角度。

本节没有继续修改 SquareLine 生成文件中的常量,而是在 main.c 中再次调用 lv_img_set_angle(),用运行时变量覆盖图片当前角度。这种安排更适合业务逻辑:以后重新从 SquareLine 导出界面时,生成文件可能被覆盖,而 main.c 的用户代码区会保留。

课堂新增 uint32_t needle_angle = 0,随后在主循环中依次执行:

  1. needle_angle++:步进值加 1;
  2. needle_angle = needle_angle % 250:保留除以 250 的余数;
  3. lv_img_set_angle(ui_ImageNeedle, needle_angle * 10):把 0~249 映射为 0~2490 后送给 LVGL。

这里的 % 是取余运算符,不是除法。原笔记中的“needle_angle = needle_angle / 2500”是识别和理解错误。取余后的序列是:

1 → 2 → … → 249 → 0 → 1 → …

所以变量实际范围是 0~249,不是 0~250;传给 LVGL 的值范围是 0~2490。

1.2 指针角度的调整与编译

LVGL 8.3 的 lv_img_set_angle() 使用 0.1° 作为一个单位,合法旋转范围按文档写作 0~3600,方向为顺时针。因此:

传入值实际旋转角度
101°
90090°
2500250°
3600360°

本仪表盘从刻度 0 转到刻度 10 的有效表盘弧线约为 250°,所以 2500 表示“指针转到刻度 10 所在位置”,不是“速度为 2500”,也不是完整转一圈。指针能否围绕正确位置旋转,还取决于 SquareLine 生成的 lv_img_set_pivot();角度正确但旋转中心错误时,图片会像绕圈摆动,而不是像真实表针一样绕轴转动。

修改完成后,构建输出应先确认 0 Error(s),再执行下载。编译成功只代表固件已经生成,不代表新固件已经写入单片机;必须看到擦除、编程和校验成功的信息,复位后显示的新效果才来自本次代码。

1.3 指针速度的调整与效果

本节依次尝试了三种写法,目标都是覆盖约 0~2500 的同一段角度,只是每轮前进的步长不同:

版本取余范围传入角度每步实际角度理想扫过时间
初始版本% 2500needle_angle0.1°2500 × 5 ms ≈ 12.5 s
中间版本% 500needle_angle * 50.5°500 × 5 ms ≈ 2.5 s
最终版本% 250needle_angle * 101°250 × 5 ms ≈ 1.25 s

这里的 5 ms 来自主循环中原有的 HAL_Delay(5)。因此“提速 10 倍”的本质是把 2500 个小步合并为 250 个大步:角度范围基本不变,每步从 0.1° 增大为 1°。

上表是理想估算。真实一轮还包含 lv_timer_handler()、四个图标状态判断、RTC 读取、字符串格式化和 LCD 刷新等执行时间,所以周期一定略大于 5 ms,扫过时间也会略长。随着业务增加,用“循环次数 × 5 ms”计时会越来越不准;本节末尾老师也明确指出当前代码仍有较大优化空间,优化留到下一节。

1.4 编译错误与 ST-Link 接触问题

视频中两次出现 Keil 卡住,老师怀疑 ST-Link 接触不良,第二次还提出之后更换 ST-Link。原笔记把它概括成“反复编译错误”并不准确:画面中至少一次已经完成构建,问题发生在下载或 IDE 会话阶段。

应把问题分成三个阶段排查:

阶段成功标志失败时优先检查
编译/链接0 Error(s),生成 .axf/固件语法、声明、类型告警、Flash/RAM 是否溢出
连接与下载Erase Done、Programming Done、Verify OKST-Link、USB/SWD 接线、目标板供电、下载算法、复位状态
固件运行复位后出现新界面行为是否下载了最新构建、程序是否进入主循环、外设初始化是否成功

任务管理器强制结束 Keil 只是应用卡死后的恢复手段,不是 ST-Link 故障的根治方法。重新打开工程后应先确认源码已保存,再重新构建和下载;不需要把已经保存的代码重新手写一遍。

1.5 重新打开工程与指针转动效果

最终烧写后,指针从刻度 0 逐步转到刻度 10 附近,计数回绕后立即回到 0,再开始下一轮。它不是物理上连续绕 360° 的旋转,而是下面这个循环动画:

0 附近 → 沿表盘增加到 10 附近 → 立即跳回 0 → 再次增加

最终代码在每轮主循环都设置一次图片角度。即使 RTC 和 Label 更新也在同一循环中执行,指针仍主要受 5 ms 主循环节拍控制。本节只验证了视觉效果,没有建立“车速值到指针角度”的真实换算;后续接入 CAN 数据时,才会由实际数据决定指针位置。

1.6 例题1:指针转动与时间调整

控制指针的核心接口只有一个:

lv_img_set_angle(图片对象, 角度值)

两个参数的含义是:

参数本节实参含义
objui_ImageNeedle要旋转的 LVGL 图片对象
angleneedle_angle * 10以 0.1° 为单位的顺时针旋转量

“只调用一个接口”不等于只写一行逻辑:还需要生成角度值、限制范围,并保证对象已经由 ui_init() 创建。若在 ui_init() 之前访问 ui_ImageNeedle,对象指针尚未准备好,程序可能异常。

1.7 例题2:RTC 纽扣电池的使用与时间修改

开发板上的纽扣电池通常连接到 MCU 的 VBAT 备份电源引脚。STM32F103 的主电源 VDD 断开后,只要 VBAT 仍有合适电压,备份域中的 RTC 和 LSE 振荡器仍可继续工作。这就是“整块开发板断电,但时钟仍继续走”的硬件基础。

要特别区分两个条件:

  • 硬件不断时:纽扣电池让 RTC 计数器和 LSE 在 VDD 断电后继续运行;
  • 软件不重设:启动代码不能每次复位都重新把 RTC 写成固定的 14:22:16 和 2025-03-22。

本节结束时,CubeMX 生成的 MX_RTC_Init() 仍会在每次启动时调用 HAL_RTC_SetTime() 和 HAL_RTC_SetDate()。因此即使安装了电池,只要 MCU 复位,程序仍可能把正在运行的时间覆盖为配置的初值。视频最后所说的“还有很大优化空间”也包括这个问题;下一节才处理“仅在第一次运行时设置初值”。本节不能提前把下一节的备份寄存器代码补进来。

二、更改时间

2.1 手动更改时间

为了先确认两个 Label 的对象名和显示位置,课堂从 ui_Screen1.c 找到自动生成的静态文本,并临时在 main.c 中调用:

  • 日期对象 ui_LabelDate,测试文本从 2025-1-10 改为 2025-2-11;
  • 时间对象 ui_LabelTime,测试文本从 23:00 改为 23:12。

这一步只是验证 lv_label_set_text() 能够在运行时改 Label,不是最终方案。静态字符串不会自己走时,也无法利用纽扣电池保留时间。本节最终删除了这两个固定文本实参,改为下面的数据链路:

RTC 计数 → HAL 读取结构体 → sprintf 生成字符串 → lv_label_set_text 更新 Label

因此“手动改时间”和“设置 RTC 初始时间”也不是同一件事:

操作改变对象是否真正改变 RTC
lv_label_set_text(ui_LabelTime, "23:12")只改屏幕文字否
CubeMX 生成的 HAL_RTC_SetTime()改 RTC 计数/时间初值是
HAL_RTC_GetTime() 后更新 Label读取 RTC,再改屏幕文字否,只读取

三、配置 RTC

3.1 添加外部晶振

工程原来已经使用外部高速晶振 HSE 作为系统时钟来源,本节又启用了外部低速晶振 LSE。二者不能混为一谈:

名称本节频率典型用途CubeMX 的 RCC 选项
HSE8 MHzCPU、总线和高速外设的系统时钟基础High Speed Clock:Crystal/Ceramic Resonator
LSE32.768 kHzRTC 的低功耗、较稳定时钟Low Speed Clock:Crystal/Ceramic Resonator

32.768 kHz 等于 32768 Hz,也就是 $2^{15}$ Hz。经过 15 次二分频即可得到 1 Hz,所以这种“手表晶振”非常适合产生一秒节拍。晶振接在 MCU 的 OSC32_IN/OSC32_OUT 引脚附近,通常还需要匹配的负载电容;这些元件共同构成振荡回路,不是由软件凭空产生 32.768 kHz。

视频口误说过“内部低速晶体一定要加上”,但实际画面和后续时钟树选择都表明启用的是外部低速晶振 LSE。LSI 才是芯片内部低速 RC 振荡器。

3.2 RTC 时钟源选择

在 RTC 页面勾选:

  • Activate Clock Source:启用 RTC 时钟;
  • Activate Calendar:启用日期/日历配置。

随后进入 Clock Configuration,把 RTC 时钟源从 LSI 切换为 LSE:

时钟源含义本节显示频率特点
LSILow-Speed Internal,内部低速 RC约 40 kHz不需要外部晶振,但频率误差和温漂通常更大
LSELow-Speed External,外部低速晶振32.768 kHz适合 RTC,精度通常更高,且可由备份域维持

字母 I 表示 Internal,E 表示 External。选择 LSE 后,时钟树应明确显示 32.768 kHz 已连接到 RTC,而不是只在 RCC 页面启用晶振却没有真正选作 RTC 时钟。

STM32F1 的 RTC 时钟源位于备份域。参考手册规定:时钟源一旦选定,若要改变必须复位备份域;复位备份域会清除 RTC 和备份寄存器。因此在已有真实用户时间的产品上修改 RTC 时钟配置要格外谨慎,不能把“重新生成并下载代码”当成无损操作。

3.3 RTC 初始化参数设置

课堂在 CubeMX 中把 Data Format 从默认 BCD 改为 Binary,并设置了下面的初值:

字段配置值含义/范围
Hours14小时,0~23
Minutes22分钟,0~59
Seconds16秒,0~59
Week DayMondayCubeMX 默认/示例星期字段
MonthMarch3 月
Date2222 日
Year25HAL 保存的两位年份,范围 0~99

Binary 在这里不是说变量只占“一个二进制位”,而是 HAL API 传入和返回普通数值,例如十进制意义上的 22 直接保存为 0x16。如果使用 BCD,十进制 22 会按十位和个位编码为 0x22。本节读取和设置都使用 RTC_FORMAT_BIN,必须与选择的数值含义保持一致。

Year 不能填写 2025;STM32F1 HAL 头文件将其限制在 0~99。本节用 25 表示年份后两位,显示时再在格式字符串前硬编码 20,于是得到 2025。这种拼接只适合 2000~2099,并不是通用世纪处理。

视频中的日期为 2025-03-22,但 CubeMX 画面保留了 Monday。当前 STM32F1 HAL 的 HAL_RTC_SetDate() 会依据年、月、日重新计算星期,传入的星期字段不会决定屏幕结果;本节界面也没有显示星期,所以该字段不影响示范效果。

3.4 RTC 时钟配置与生成代码

配置完成后点击 Generate Code。CubeMX 会完成以下工作:

  1. 生成 rtc.c、rtc.h;
  2. 定义全局句柄 RTC_HandleTypeDef hrtc;
  3. 生成 MX_RTC_Init();
  4. 在 main() 的外设初始化区插入 MX_RTC_Init() 调用;
  5. 在系统时钟配置中启用 LSE,并选择它作为 RTC 时钟源。

回到 Keil 后使用 Reload 载入外部工具修改过的工程文件,再先编译一次。未编译时 “Go To Definition” 不能跳转,通常是浏览信息尚未生成或索引未刷新,不代表函数不存在。

CubeMX 重新生成代码时,只有规定的 USER CODE BEGIN/END 区域承诺用于保留自定义代码。把业务逻辑写到自动生成区外,下一次 Generate Code 可能被覆盖。

3.5 RTC 功能实现与调试

MX_RTC_Init() 的生成代码大致分为三层:

  1. hrtc.Instance = RTC 等语句指定 RTC 外设和预分频/输出配置;
  2. HAL_RTC_Init(&hrtc) 初始化底层 RTC;
  3. HAL_RTC_SetTime()、HAL_RTC_SetDate() 写入 CubeMX 中填写的初始时间和日期。

本节不需要手写这些初始化语句,也不应把 HAL 驱动函数的内部实现复制进业务笔记。学习时需要理解的是:CubeMX 只替我们生成了调用,真正的硬件状态仍由这些调用改变。

本节最终每次启动都会执行设置时间和日期的代码。因此调试时看到 14:22:16 和 2025-03-22,只能证明初始值写入、读取和显示链路正常,不能证明断电保持已经完善。要验证电池保持,应在不重新设置 RTC 的条件下断开 VDD、保留 VBAT,过一段时间再上电检查时间差。

3.6 RTC 时间与日期设置函数

两个生成函数的职责如下:

函数数据结构本节用途
HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN)RTC_TimeTypeDef写入 14:22:16
HAL_RTC_SetDate(&hrtc, &DateToUpdate, RTC_FORMAT_BIN)RTC_DateTypeDef写入 2025-03-22

SetTime/SetDate 中的 Set 是“设置、写入”,不是读取。生成代码中的 sTime 和 DateToUpdate 是 MX_RTC_Init() 内部的局部结构体,只服务初始化;主循环读取时另外定义了 struct_rtc_time 和 struct_rtc_date。

3.7 RTC 时间与日期读取函数

读取使用:

  • HAL_RTC_GetTime():把时、分、秒写入 RTC_TimeTypeDef;
  • HAL_RTC_GetDate():把年、月、日、星期写入 RTC_DateTypeDef。

原笔记中的 rtc.get time()、get data、set data 都不是本工程的真实函数名。正确命名是 HAL_RTC_GetTime()、HAL_RTC_GetDate()、HAL_RTC_SetTime() 和 HAL_RTC_SetDate();日期英文是 Date,不是 Data。

本工程使用 STM32F1 HAL。它的 RTC 硬件核心是一个计数器,HAL 在软件中把计数转换为时分秒,并用 hrtc.DateToUpdate 维护日期。官方驱动中的 HAL_RTC_GetDate() 还会在内部调用一次 HAL_RTC_GetTime(),以便在累计超过 24 小时时推进日期。这一点与带完整硬件 Calendar/Shadow Register 的其他 STM32 系列实现不完全相同,不能把其他系列的 RTC 细节直接套到 F1。

3.8 RTC 在代码中的实际应用

以时间读取为例,三个实参分别是:

实参作用
&hrtcRTC 句柄地址,告诉 HAL 操作哪个 RTC 实例
&struct_rtc_time输出结构体地址,HAL 通过这个地址写回时、分、秒
RTC_FORMAT_BIN要求结构体成员使用普通二进制数值格式

日期读取完全同理,只是输出结构体换成 struct_rtc_date。& 是取地址运算符;如果函数参数要求结构体指针,却只传结构体值,函数既无法按接口接收,也无法把读取结果写回调用者的变量。

结构体成员使用英文句点访问:

  • struct_rtc_time.Hours、.Minutes、.Seconds;
  • struct_rtc_date.Year、.Month、.Date。

得到数值后,sprintf() 把多个整数和分隔符组合为以 \0 结尾的 C 字符串;lv_label_set_text() 再把字符串设置为 Label 文本。整个过程没有把“RTC 结构体直接显示到屏幕”,中间必须经过数值到字符的格式化。

本节把指针更新、RTC 读取和字符串格式化都直接放在约 5 ms 一轮的主循环中。RTC 的秒值一秒才变化一次,5 ms 内重复读取、重复格式化和重复刷新 Label 绝大多数时候没有产生新内容,浪费 CPU 和显示资源。这是本节故意保留、下一节再优化的问题,不能在本节源码中提前改写。

3.9 RTC 显示优化与问题排查

1. %2u 的真实含义

%u 表示无符号十进制整数,字段宽度 2 表示“最少占 2 个字符”。默认补的是空格,不是数字 0:

格式数值 4 的结果数值 14 的结果
%2u␠414
%02u0414

所以原笔记“确保每个时间数字占两个字符”只说对了一半:它确实规定最小宽度为 2,但单个数字会显示成前导空格。常见电子时钟若希望显示 04:05:09,通常使用 %02u;不过视频最终源码是 %2u,本笔记不能擅自替换。

2. 缓冲区为什么够用

  • 时间最宽为 23:59:59:8 个可见字符,加结尾 \0 共 9 字节;
  • 日期在本节格式下最宽为 2099-12-31:10 个可见字符,加 \0 共 11 字节;
  • 两个数组各有 15 字节,因此在 RTC 字段合法时容量足够。

sprintf() 本身不知道目标数组有多大,格式或数据范围改变后可能越界。更通用的安全方向是 snprintf() 并传入数组容量,但这不是本节展示的源码,所以只作为知识扩展,不替换课堂代码。

3. 画面多出异常字符的真实原因

第一次烧写前,日期 Label 仍使用固定字符串,老师先把它改为 date_buff。随后日期区域显示异常。视频放大检查后发现,日期格式化函数被误写成了:

swprintf(...)

也就是 sprintf 中间多输入了字母 w,并不是格式字符串 "20%2u-%2u-%2u" 内部多了字符。swprintf() 是宽字符格式化函数,参数类型和普通字节数组不匹配;误调用它会造成告警和错误的内存解释。删除 w、恢复为 sprintf(),重新编译烧写后,日期和时间均正常显示。

4. 视频中四个编译告警的来源

课堂把缓冲区定义成 uint8_t[],而标准 sprintf() 的目标参数以及 lv_label_set_text() 的文本参数使用 char */const char *。在该平台上 uint8_t 通常是 unsigned char 的别名,字节尺寸相同,但 C 类型仍与 char * 不完全一致,因此 Keil 会给出不兼容指针类型告警。

从接口语义看,文本缓冲区定义成 char time_buff[15]、char date_buff[15] 更自然;配套工程后续版本则在调用处增加了 (char *) 转换。但按照“本节源码原样保留”的要求,标题下仍记录视频最后画面的 uint8_t[] 和无转换调用,不把后续修改倒灌进来。

5. 运行结果与尚未解决的问题

最终画面显示了持续变化的时间、2025-3-22 日期和循环扫动的指针,证明以下链路已经打通:

LSE → RTC 计数 → HAL 结构体 → sprintf 字符串 → LVGL Label → LCD

但本节代码仍有三个明确局限:

  1. 每约 5 ms 重复读取和刷新,频率远高于一秒变化一次的 RTC;
  2. 每次复位都写固定初值,纽扣电池保存的运行时间可能被覆盖;
  3. 指针只是演示动画,尚未绑定真实车速或 CAN 数据。

这三个问题是后续课程的输入条件,不应为了让本节代码看起来“完整”而提前补写解决方案。

3.10 本节完整运行流程

从上电到屏幕更新,可按下面顺序理解:

  1. HAL_Init() 建立 HAL 基础环境;
  2. SystemClock_Config() 配置系统时钟和 RTC 的 LSE 时钟来源;
  3. MX_RTC_Init() 初始化 RTC,并写入本节配置的初始时间、日期;
  4. ui_init() 创建 ui_ImageNeedle、ui_LabelTime、ui_LabelDate 等 LVGL 对象;
  5. 主循环调用 lv_timer_handler() 服务 LVGL;
  6. needle_angle 自增、取余并换算为 0.1°单位,推动指针;
  7. HAL 读取 RTC 时间和日期;
  8. sprintf() 把数值结构化为人可读字符串;
  9. lv_label_set_text() 更新两个 Label;
  10. 后续 lv_timer_handler() 执行到期的重绘任务,显示驱动把变化区域送到 LCD。

这条流程也解释了为什么“改了 Label 文本”与“LCD 立刻出现像素变化”不是完全同一时刻:业务接口先改变对象状态,实际绘制和刷屏由 LVGL Handler 调度完成。

参考资料