仪表盘指针和时间显示
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,随后在主循环中依次执行:
needle_angle++:步进值加 1;needle_angle = needle_angle % 250:保留除以 250 的余数;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,方向为顺时针。因此:
| 传入值 | 实际旋转角度 |
|---|---|
10 | 1° |
900 | 90° |
2500 | 250° |
3600 | 360° |
本仪表盘从刻度 0 转到刻度 10 的有效表盘弧线约为 250°,所以 2500 表示“指针转到刻度 10 所在位置”,不是“速度为 2500”,也不是完整转一圈。指针能否围绕正确位置旋转,还取决于 SquareLine 生成的 lv_img_set_pivot();角度正确但旋转中心错误时,图片会像绕圈摆动,而不是像真实表针一样绕轴转动。
修改完成后,构建输出应先确认 0 Error(s),再执行下载。编译成功只代表固件已经生成,不代表新固件已经写入单片机;必须看到擦除、编程和校验成功的信息,复位后显示的新效果才来自本次代码。
1.3 指针速度的调整与效果
本节依次尝试了三种写法,目标都是覆盖约 0~2500 的同一段角度,只是每轮前进的步长不同:
| 版本 | 取余范围 | 传入角度 | 每步实际角度 | 理想扫过时间 |
|---|---|---|---|---|
| 初始版本 | % 2500 | needle_angle | 0.1° | 2500 × 5 ms ≈ 12.5 s |
| 中间版本 | % 500 | needle_angle * 5 | 0.5° | 500 × 5 ms ≈ 2.5 s |
| 最终版本 | % 250 | needle_angle * 10 | 1° | 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 OK | ST-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(图片对象, 角度值)
两个参数的含义是:
| 参数 | 本节实参 | 含义 |
|---|---|---|
obj | ui_ImageNeedle | 要旋转的 LVGL 图片对象 |
angle | needle_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 选项 |
|---|---|---|---|
| HSE | 8 MHz | CPU、总线和高速外设的系统时钟基础 | High Speed Clock:Crystal/Ceramic Resonator |
| LSE | 32.768 kHz | RTC 的低功耗、较稳定时钟 | 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:
| 时钟源 | 含义 | 本节显示频率 | 特点 |
|---|---|---|---|
| LSI | Low-Speed Internal,内部低速 RC | 约 40 kHz | 不需要外部晶振,但频率误差和温漂通常更大 |
| LSE | Low-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,并设置了下面的初值:
| 字段 | 配置值 | 含义/范围 |
|---|---|---|
| Hours | 14 | 小时,0~23 |
| Minutes | 22 | 分钟,0~59 |
| Seconds | 16 | 秒,0~59 |
| Week Day | Monday | CubeMX 默认/示例星期字段 |
| Month | March | 3 月 |
| Date | 22 | 22 日 |
| Year | 25 | HAL 保存的两位年份,范围 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 会完成以下工作:
- 生成
rtc.c、rtc.h; - 定义全局句柄
RTC_HandleTypeDef hrtc; - 生成
MX_RTC_Init(); - 在
main()的外设初始化区插入MX_RTC_Init()调用; - 在系统时钟配置中启用 LSE,并选择它作为 RTC 时钟源。
回到 Keil 后使用 Reload 载入外部工具修改过的工程文件,再先编译一次。未编译时 “Go To Definition” 不能跳转,通常是浏览信息尚未生成或索引未刷新,不代表函数不存在。
CubeMX 重新生成代码时,只有规定的 USER CODE BEGIN/END 区域承诺用于保留自定义代码。把业务逻辑写到自动生成区外,下一次 Generate Code 可能被覆盖。
3.5 RTC 功能实现与调试
MX_RTC_Init() 的生成代码大致分为三层:
hrtc.Instance = RTC等语句指定 RTC 外设和预分频/输出配置;HAL_RTC_Init(&hrtc)初始化底层 RTC;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 在代码中的实际应用
以时间读取为例,三个实参分别是:
| 实参 | 作用 |
|---|---|
&hrtc | RTC 句柄地址,告诉 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 | ␠4 | 14 |
%02u | 04 | 14 |
所以原笔记“确保每个时间数字占两个字符”只说对了一半:它确实规定最小宽度为 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
但本节代码仍有三个明确局限:
- 每约 5 ms 重复读取和刷新,频率远高于一秒变化一次的 RTC;
- 每次复位都写固定初值,纽扣电池保存的运行时间可能被覆盖;
- 指针只是演示动画,尚未绑定真实车速或 CAN 数据。
这三个问题是后续课程的输入条件,不应为了让本节代码看起来“完整”而提前补写解决方案。
3.10 本节完整运行流程
从上电到屏幕更新,可按下面顺序理解:
HAL_Init()建立 HAL 基础环境;SystemClock_Config()配置系统时钟和 RTC 的 LSE 时钟来源;MX_RTC_Init()初始化 RTC,并写入本节配置的初始时间、日期;ui_init()创建ui_ImageNeedle、ui_LabelTime、ui_LabelDate等 LVGL 对象;- 主循环调用
lv_timer_handler()服务 LVGL; needle_angle自增、取余并换算为 0.1°单位,推动指针;- HAL 读取 RTC 时间和日期;
sprintf()把数值结构化为人可读字符串;lv_label_set_text()更新两个 Label;- 后续
lv_timer_handler()执行到期的重绘任务,显示驱动把变化区域送到 LCD。
这条流程也解释了为什么“改了 Label 文本”与“LCD 立刻出现像素变化”不是完全同一时刻:业务接口先改变对象状态,实际绘制和刷屏由 LVGL Handler 调度完成。