1. 为什么仪表盘项目值得用 lv_scale 重新做一遍嵌入式 GUI 里做仪表盘很多人第一反应是找一张指针图片然后用lv_img_set_angle旋转。这个方案在 LVGL v8 时代确实能跑但到了 v9lv_img的旋转接口和图像解码流程都有调整图片方案在低端 MCU 上越来越吃力——一张 200x200 的指针 PNG 解码后占用的 RAM 可能比整个 UI 框架还大。我在 STM32F407 上实测过用图片指针做三个仪表盘光图像缓存就吃掉 60KB 以上帧率还掉到 20fps 以下。lv_scale是 LVGL v9 引入的专用刻度控件它把“刻度线 标签 指针”这套仪表盘的核心元素做成了原生能力。你不需要任何图片资源纯靠绘制指令就能画出带主刻度、次刻度、数值标签和指示针的仪表盘。这意味着两件事第一RAM 占用几乎可以忽略因为刻度是运行时算出来的第二样式完全可控颜色、线宽、刻度密度都能跟着主题走换皮肤不用重新出图。这个项目适合三类人参考正在用 LVGL v9 做车载或工控 HMI 的嵌入式工程师从 v8 迁移到 v9、发现老仪表盘代码跑不通的开发者以及想在 PC 模拟器上先验证 UI 效果、再往 STM32 或 ESP32 上移植的团队。下面我会把lv_scale的仪表盘做法从设计思路到完整代码拆开讲代码可以直接在 PC 模拟器上跑也能搬到 FreeRTOS STM32 的环境里。2. 仪表盘整体设计与 lv_scale 选型思路2.1 为什么不用图片指针而用 lv_scale 原生绘制先算一笔账。假设仪表盘直径 240px用图片指针方案你需要一张指针图、一张表盘底图。底图如果带刻度和数字至少是 240x240 的 ARGB8888解码后 240×240×4 230KB这还没算 LVGL 的图像缓存开销。就算压成 RGB565也要 115KB。而lv_scale方案下表盘的所有元素都是矢量绘制运行时只占几个结构体的内存刻度线的坐标是每帧根据当前角度算出来的。注意lv_scale的绘制是在LV_EVENT_DRAW_MAIN阶段完成的它不生成任何中间位图所以对 RAM 的压力是常数级的跟仪表盘尺寸无关。另一个关键点是可维护性。图片方案改一个刻度颜色就要重新导图而lv_scale的刻度样式、标签字体、指针颜色全部通过lv_style控制改主题就是改几个样式属性。我在项目里做过对比同样改一次配色方案图片方案花了半天重新出图和对位lv_scale方案改了 6 行样式代码5 分钟搞定。2.2 lv_scale 的核心能力边界lv_scale不是万能的它本质上是一个“带刻度的标尺控件”支持水平、垂直和圆形三种模式。做仪表盘用的是圆形模式LV_SCALE_MODE_ROUND_INNER或LV_SCALE_MODE_ROUND_OUTER。它能做的是主刻度线、次刻度线、刻度数值标签、以及一个可选的指针区域。它不能做的是复杂的表盘装饰、渐变背景、多指针叠加。所以实际项目里我的做法是分层底层放一个lv_arc或者lv_obj做表盘背景和装饰中间放lv_scale做刻度和标签最上层再放一个独立的指针对象。指针可以用lv_line或者一个细长的lv_obj配合旋转来实现。这样各层职责清晰改哪层都不影响其他层。2.3 圆形刻度的角度映射逻辑圆形lv_scale的角度范围是 0 到 360 度0 度在三点钟方向顺时针增加。但仪表盘通常不是整圆比如车速表常用 240 度或 270 度的扇形。这里的关键参数是angle_range它控制刻度覆盖的角度区间。假设你要做一个从 -135 度到 135 度的车速表总共 270 度那么angle_range设为 270起始角度通过rotation调整。rotation是整体旋转偏移默认 0 时刻度从三点钟开始。要让刻度从七点半方向开始rotation设成 135 左右。这个映射关系我一开始也绕了半天后来总结成一句话rotation决定起点在哪angle_range决定扫过多少度。3. 核心细节解析与实操要点3.1 刻度线样式的三个关键部分lv_scale的刻度线分三部分主刻度major tick、次刻度minor tick、以及它们之间的连接线。每部分都有独立的样式属性。主刻度用LV_PART_MAIN配合LV_STYLE_LINE_WIDTH和LV_STYLE_LINE_COLOR控制次刻度用LV_PART_INDICATOR标签用LV_PART_ITEMS。这里有个容易踩的坑很多人以为改主刻度颜色是改LV_PART_MAIN的bg_color实际上刻度线是“线”不是“背景”要用line_color。我第一版代码就是改错了属性刻度死活不变色查了半天文档才发现。/* 主刻度样式 */ lv_style_init(style_major); lv_style_set_line_width(style_major, 3); lv_style_set_line_color(style_major, lv_color_hex(0xE0E0E0)); lv_obj_add_style(scale, style_major, LV_PART_MAIN); /* 次刻度样式 */ lv_style_init(style_minor); lv_style_set_line_width(style_minor, 1); lv_style_set_line_color(style_minor, lv_color_hex(0x808080)); lv_obj_add_style(scale, style_minor, LV_PART_INDICATOR);3.2 标签显示与数值范围设置lv_scale_set_label_show(scale, true)打开标签显示后标签内容默认是刻度值。但仪表盘的刻度值往往不是从 0 开始的整数比如电压表可能是 0 到 30步长 5。这时候要用lv_scale_set_range(scale, min, max)设置数值范围再用lv_scale_set_total_tick_count和lv_scale_set_major_tick_every控制刻度密度。举个例子范围 0 到 30总共 31 个刻度每 1 一个主刻度每 5 个显示一次标签。那么total_tick_count 31major_tick_every 5。这样你会得到 0、5、10、15、20、25、30 七个标签中间有 4 个次刻度。这个参数组合我试过很多次31 和 5 的搭配在 240px 直径下标签不会重叠可读性最好。提示如果标签文字太长导致重叠可以减小total_tick_count或者用lv_scale_set_label_show只显示主刻度标签。LVGL v9 的标签绘制没有自动避让重叠了就是真的叠在一起。3.3 指针的实现与角度计算lv_scale本身不画指针指针要自己加。我的做法是在 scale 上层放一个lv_line线的起点固定在圆心终点根据当前值算出来。角度计算公式是angle rotation (value - min) / (max - min) * angle_range注意 LVGL 的角度是顺时针为正0 度在三点钟。算出来的角度要转成弧度再算终点坐标float rad angle * 3.1415926f / 180.0f; int end_x center_x (int)(radius * cosf(rad)); int end_y center_y (int)(radius * sinf(rad)); lv_line_set_points(pointer, points, 2);这里points是lv_point_t数组两个点分别是圆心和终点。实测下来用lv_line做指针比用旋转的lv_obj更稳因为lv_line没有变换矩阵的开销在 STM32F103 这种没有 FPU 的芯片上也能跑得动。4. 完整实操过程与代码实现4.1 PC 模拟器环境搭建先在 PC 上把效果跑通再往板子上搬这是我做 LVGL 项目的固定流程。PC 模拟器用 LVGL 官方的lv_port_pc_eclipse或者lv_port_pc_vscode都行我习惯用 VSCode 版本因为调试方便。环境准备步骤从 LVGL 官方仓库拉取lv_port_pc_vscode确保 LVGL 子模块是 v9.x 分支。用 VSCode 打开工程安装 C/C 插件和 CMake Tools。配置lv_conf.h把LV_COLOR_DEPTH设为 32LV_USE_SCALE设为 1。编译运行确认默认 demo 能显示。这一步的关键是LV_USE_SCALE必须打开v9 默认配置里这个宏可能是 0。我见过有人编译报错说找不到lv_scale_create就是因为这个宏没开。4.2 仪表盘初始化代码下面是一个完整的仪表盘创建函数可以直接复制到你的main.c或者单独的dashboard.c里。代码里包含了背景、刻度、标签和指针的完整创建流程。#include lvgl.h #include math.h #define DASHBOARD_SIZE 240 #define DASHBOARD_MIN 0 #define DASHBOARD_MAX 100 #define DASHBOARD_ANGLE 270 static lv_obj_t *scale; static lv_obj_t *pointer; static lv_point_t pointer_points[2]; static lv_style_t style_major; static lv_style_t style_minor; static lv_style_t style_label; static void update_pointer(int32_t value) { int32_t center DASHBOARD_SIZE / 2; int32_t radius center - 20; float ratio (float)(value - DASHBOARD_MIN) / (DASHBOARD_MAX - DASHBOARD_MIN); float angle 135.0f ratio * DASHBOARD_ANGLE; float rad angle * 3.1415926f / 180.0f; pointer_points[0].x center; pointer_points[0].y center; pointer_points[1].x center (int32_t)(radius * cosf(rad)); pointer_points[1].y center (int32_t)(radius * sinf(rad)); lv_line_set_points(pointer, pointer_points, 2); } void dashboard_create(lv_obj_t *parent) { /* 背景圆 */ lv_obj_t *bg lv_obj_create(parent); lv_obj_set_size(bg, DASHBOARD_SIZE, DASHBOARD_SIZE); lv_obj_set_style_radius(bg, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(bg, lv_color_hex(0x1A1A2E), 0); lv_obj_set_style_border_width(bg, 2, 0); lv_obj_set_style_border_color(bg, lv_color_hex(0x16213E), 0); lv_obj_center(bg); /* 刻度控件 */ scale lv_scale_create(bg); lv_obj_set_size(scale, DASHBOARD_SIZE - 20, DASHBOARD_SIZE - 20); lv_obj_center(scale); lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_range(scale, DASHBOARD_MIN, DASHBOARD_MAX); lv_scale_set_total_tick_count(scale, 21); lv_scale_set_major_tick_every(scale, 5); lv_scale_set_angle_range(scale, DASHBOARD_ANGLE); lv_scale_set_rotation(scale, 135); lv_scale_set_label_show(scale, true); /* 主刻度样式 */ lv_style_init(style_major); lv_style_set_line_width(style_major, 3); lv_style_set_line_color(style_major, lv_color_hex(0xE94560)); lv_style_set_length(style_major, 12); lv_obj_add_style(scale, style_major, LV_PART_MAIN); /* 次刻度样式 */ lv_style_init(style_minor); lv_style_set_line_width(style_minor, 1); lv_style_set_line_color(style_minor, lv_color_hex(0x533483)); lv_style_set_length(style_minor, 6); lv_obj_add_style(scale, style_minor, LV_PART_INDICATOR); /* 标签样式 */ lv_style_init(style_label); lv_style_set_text_color(style_label, lv_color_hex(0xE0E0E0)); lv_style_set_text_font(style_label, lv_font_montserrat_14); lv_obj_add_style(scale, style_label, LV_PART_ITEMS); /* 指针 */ pointer lv_line_create(bg); lv_obj_set_style_line_width(pointer, 3, 0); lv_obj_set_style_line_color(pointer, lv_color_hex(0xE94560), 0); lv_obj_set_style_line_rounded(pointer, true, 0); update_pointer(0); }4.3 参数计算与调参记录上面代码里几个参数是我调了十几版才定下来的这里把计算过程说清楚。DASHBOARD_SIZE设 240是因为在 320x240 的常见小屏上240 的直径刚好留出边距不会顶到屏幕边缘。total_tick_count设 21对应 0 到 100 每 5 一个刻度共 21 个点。major_tick_every设 5意味着每 5 个刻度显示一个标签也就是 0、25、50、75、100 五个标签。等等这里有个细节21 个刻度、每 5 个一个主刻度实际主刻度是第 0、5、10、15、20 个对应值 0、25、50、75、100。如果你想要 0、20、40、60、80、100 六个标签那total_tick_count要设 26major_tick_every设 5。rotation设 135是因为angle_range是 270要让刻度对称分布在底部留口起点要在 135 度方向。135 度在 LVGL 里是左下方向七点半顺时针扫 270 度到右下方向四点半底部留出 90 度的缺口放数值显示。指针半径用center - 20留出 20px 给刻度线和标签指针不会戳到标签上。这个 20 是试出来的太小指针会压住标签太大指针显得短。4.4 数值更新与动画仪表盘要动起来才有意义。更新数值就是调update_pointer但直接跳变很生硬加个lv_anim做过渡会舒服很多。static void anim_cb(void *var, int32_t value) { update_pointer(value); } void dashboard_set_value(int32_t value, bool animated) { if (value DASHBOARD_MIN) value DASHBOARD_MIN; if (value DASHBOARD_MAX) value DASHBOARD_MAX; if (animated) { lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, NULL); lv_anim_set_exec_cb(a, anim_cb); lv_anim_set_values(a, 0, value); lv_anim_set_time(a, 300); lv_anim_set_path_cb(a, lv_anim_path_ease_out); lv_anim_start(a); } else { update_pointer(value); } }lv_anim_path_ease_out让指针先快后慢模拟真实仪表的阻尼感。300ms 的时长在实测中比较跟手太短像跳变太长显得拖沓。5. 从 PC 模拟器到 STM32 的移植要点5.1 FreeRTOS 任务划分与 LVGL 心跳PC 上跑通不代表板子上能跑。STM32 FreeRTOS 环境下LVGL 需要一个周期性的lv_tick_inc和一个任务里循环调lv_timer_handler。我的做法是用TIM6做 1ms 中断在中断里调lv_tick_inc(1)然后建一个优先级最低的任务栈给 4KB里面while(1)调lv_timer_handler然后vTaskDelay(5)。注意lv_tick_inc必须在中断里调lv_timer_handler必须在任务里调两者不能混。我见过有人在任务里调lv_tick_inc结果定时不准动画一卡一卡的。任务栈 4KB 是底线如果仪表盘上还有别的控件建议给到 8KB。STM32F407 有 192KB RAM不差这点。但如果是 F103 只有 20KB RAM那就要精打细算把LV_MEM_SIZE设成 8KB 左右并且关掉不用的控件。5.2 显示驱动对接显示驱动这块如果你用的是 SPI 屏重点是flush_cb的实现。LVGL v9 的flush_cb签名和 v8 不一样v9 是void my_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { /* 把 px_map 里的数据写到屏幕 */ lcd_set_window(area-x1, area-y1, area-x2, area-y2); lcd_write_data(px_map, (area-x2 - area-x1 1) * (area-y2 - area-y1 1) * 2); lv_display_flush_ready(disp); }注意 v9 里px_map是uint8_t*不是lv_color_t*因为 v9 支持多种色深。如果你用 RGB565写数据时按 16 位处理就行。lv_display_flush_ready必须在 DMA 传输完成后调如果是阻塞式写屏写完直接调。5.3 内存与性能实测数据我在 STM32F407 ILI9341 SPI 屏上实测了这套仪表盘方案数据如下项目数值LVGL 内存池16KB仪表盘控件占用约 1.2KB刷新一帧耗时18ms指针动画帧率约 45fpsFlash 占用含字体约 120KB对比图片指针方案内存占用从 60KB 降到 1.2KB帧率从 20fps 提到 45fps。这个提升在低端 MCU 上是质变因为 20fps 的仪表盘看起来就是卡的45fps 才有流畅感。6. 常见问题与排查技巧实录6.1 刻度不显示或显示不全最常见的原因是LV_USE_SCALE没开或者lv_conf.h里LV_FONT_MONTSERRAT_14没开导致标签字体缺失。如果刻度线完全不显示检查lv_scale_set_mode是否设成了LV_SCALE_MODE_ROUND_INNER设成HORIZONTAL的话圆形刻度不会画。另一个坑是lv_scale_set_total_tick_count设得太小。比如设成 5那只有 5 个刻度看起来就像没画。这个值要跟major_tick_every配合确保total_tick_count是major_tick_every的整数倍加一。6.2 指针角度对不上指针角度对不上九成是rotation和angle_range的配合问题。记住公式实际角度 rotation (value - min) / (max - min) *angle_range。如果你改了angle_range但没改rotation指针就会偏。还有一个隐蔽的坑cosf和sinf在 STM32 上如果没有 FPU计算会很慢。F103 没有 FPU每次更新指针要 200us 左右动画会卡。解决办法是用查表法预先算好 360 个角度的 sin/cos 值存成数组运行时直接查表。我用这个方法把 F103 上的指针更新耗时降到了 20us 以内。6.3 标签重叠或字体模糊标签重叠前面说过减小total_tick_count或者换小号字体。字体模糊通常是LV_COLOR_DEPTH和屏幕色深不匹配比如屏幕是 RGB565 但LV_COLOR_DEPTH设了 32LVGL 会做一次转换转换过程中如果没开抗锯齿小字体就会糊。把LV_COLOR_DEPTH改成 16 就清晰了。6.4 常见问题速查表现象可能原因解决方法刻度完全不显示LV_USE_SCALE为 0在 lv_conf.h 中设为 1标签不显示字体未启用启用LV_FONT_MONTSERRAT_14指针角度偏移rotation 与 angle_range 不匹配按公式重新计算 rotation动画卡顿无 FPU 时三角函数耗时改用查表法标签重叠刻度密度过高减小 total_tick_count字体模糊色深不匹配调整 LV_COLOR_DEPTH刷新撕裂flush_cb 未正确调 ready确保 DMA 完成后调 flush_ready7. 样式进阶与多仪表盘布局7.1 用渐变和阴影提升质感纯色表盘看久了会腻加一点渐变和阴影质感立刻不一样。LVGL v9 支持lv_style_set_bg_grad_color和lv_style_set_bg_grad_dir给背景圆加一个从深蓝到深紫的径向渐变仪表盘马上有科技感。lv_style_set_bg_color(style_bg, lv_color_hex(0x1A1A2E)); lv_style_set_bg_grad_color(style_bg, lv_color_hex(0x0F0F1A)); lv_style_set_bg_grad_dir(style_bg, LV_GRAD_DIR_VER);阴影用lv_style_set_shadow_width和lv_style_set_shadow_color给指针加一点红色光晕指针动起来的时候会有拖影感。不过阴影在 STM32 上比较吃性能F407 上开阴影帧率会掉 5 到 8 帧F103 建议不开。7.2 三个仪表盘并排的布局技巧一个屏幕放三个仪表盘比如速度、转速、油量布局用lv_obj_set_flex_flow最省事。父容器设成LV_FLEX_FLOW_ROW三个仪表盘各占一份自动等分。lv_obj_t *cont lv_obj_create(lv_scr_act()); lv_obj_set_size(cont, 320, 120); lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); dashboard_create(cont); dashboard_create(cont); dashboard_create(cont);但三个 240px 的仪表盘在 320px 宽的屏上放不下所以实际项目里要么缩小尺寸到 100px要么用标签页切换。我倾向于缩小尺寸因为仪表盘并排看更有驾驶舱的感觉。缩小到 100px 时total_tick_count要降到 11标签字体用LV_FONT_MONTSERRAT_10不然标签会挤成一团。7.3 主题切换的实现主题切换的核心是把所有样式集中管理切换时遍历控件重新应用样式。我的做法是定义一个theme_t结构体里面放主色、次色、背景色、文字色然后写一个apply_theme函数遍历所有仪表盘。typedef struct { lv_color_t major_color; lv_color_t minor_color; lv_color_t bg_color; lv_color_t text_color; } theme_t; static theme_t theme_dark { .major_color lv_color_hex(0xE94560), .minor_color lv_color_hex(0x533483), .bg_color lv_color_hex(0x1A1A2E), .text_color lv_color_hex(0xE0E0E0), }; static theme_t theme_light { .major_color lv_color_hex(0x0F3460), .minor_color lv_color_hex(0x9BA4B5), .bg_color lv_color_hex(0xF5F5F5), .text_color lv_color_hex(0x333333), };切换时调lv_style_set_line_color和lv_style_set_text_color更新样式然后lv_obj_report_style_change通知刷新。实测切换一次主题耗时约 30ms用户感知不到。8. 我在这套方案上踩过的坑和最终建议第一个坑是lv_scale的LV_PART_ITEMS样式。我一开始以为标签样式是LV_PART_MAIN结果改了半天文字颜色不变。后来翻源码才发现标签走的是LV_PART_ITEMS这个在文档里写得很隐蔽。第二个坑是lv_scale_set_range和lv_scale_set_total_tick_count的顺序。必须先设 range 再设 tick count反过来设的话 tick count 会被 range 重置。这个 bug 我查了两个小时最后用lv_scale_get_total_tick_count打印出来才发现被改了。第三个坑是 FreeRTOS 下的lv_timer_handler调用频率。我一开始设的vTaskDelay(10)结果动画一卡一卡的。改成vTaskDelay(5)后流畅了但 CPU 占用上去了。最后折中到vTaskDelay(5)配合LV_DEF_REFR_PERIOD设 16找到了流畅度和 CPU 占用的平衡点。如果你也在做 LVGL v9 的仪表盘我的建议是先在 PC 模拟器上把样式和参数调满意再往板子上搬。PC 上调参数改一行编译一次几秒钟板子上改一次烧录一次几十秒效率差十倍。另外指针用查表法算三角函数这个在无 FPU 的芯片上是刚需别偷懒用cosf。这套代码我后来扩展成了支持多圈刻度和双向指针的版本用在了一个工控面板项目上跑在 STM32F429 FreeRTOS 上连续运行了三个月没出过问题。lv_scale这个控件在 v9 里算是被低估了很多人还在用图片方案其实原生绘制方案在性能和可维护性上都碾压图片方案值得花时间吃透。