
1. 为什么我最终选了 lv_scale 来做仪表盘嵌入式 GUI 里做仪表盘这件事说简单也简单说坑也真不少。早几年大家用 LVGL 做仪表盘基本是两条路要么用lv_arc拼一个圆弧加指针要么干脆贴一张背景图然后在上面画一根会转的线。前者灵活但代码量大刻度、数字、指针全得自己算后者省事但分辨率一变就糊改个量程还得重新出图。LVGL v9 把lv_scale这个控件正式独立出来之后我第一时间就把手头几个项目里的仪表盘全换了一遍实测下来代码量能砍掉一半以上而且刻度、标签、指针的联动是原生支持的不用再自己维护一堆坐标数组。lv_scale本质上是一个刻度尺控件它负责画主刻度线、次刻度线、刻度标签以及可选的指针区域。你可以把它理解成一把可以弯成任意角度、可以自定义每一格代表多少数值的尺子。仪表盘只是它最典型的用法之一实际上进度条、旋钮、音量条、转速表、温度计都能用它来做。它解决的核心问题是把数值到角度/位置的映射这件事从业务代码里抽出来交给控件自己管。你只需要告诉它量程是多少、当前值是多少剩下的刻度怎么排、标签怎么显示它自己会算。这篇文章适合两类人看。一类是刚接触 LVGL v9、想找个能直接跑起来的仪表盘例子的朋友我会把完整代码贴出来PC 模拟器和 STM32 上都能用另一类是已经在用 LVGL、但还在用老办法手搓仪表盘的同行我会重点讲清楚lv_scale的几个关键参数怎么调、指针怎么联动、性能怎么优化这些都是我在实际项目里踩过坑之后总结出来的。代码基于 LVGL v9.xv8 的 API 差异比较大如果你还在 v8建议先升级或者对照官方迁移文档改一下。2. lv_scale 的核心机制与设计思路拆解2.1 它到底解决了什么老问题在没有lv_scale之前做一个像样的仪表盘我通常要写这么几块东西一段代码算圆弧的起止角度一段代码循环画刻度线每条刻度线都要算起点终点坐标一段代码放刻度数字每个数字都要算位置和旋转角度最后还要一段代码根据当前值算指针角度。这四块代码互相耦合改一个量程四个地方都得动。更麻烦的是刻度数字的旋转在 LVGL 里一直是个痛点早期版本对文字旋转支持不好很多人干脆不旋转数字全是正的看起来就很别扭。lv_scale把这四块全包了。你给它一个min_value和max_value再给它一个angle_range角度范围它内部会自动把数值区间映射到角度区间然后按你设定的total_tick_count总刻度数和major_tick_every每隔几个刻度算一个主刻度去排布刻度和标签。指针部分v9 提供了lv_scale_set_line_needle_value和lv_scale_set_image_needle_value两个接口前者用一条线当指针后者用一张图片当指针你只要传入当前值它自己算角度。提示lv_scale在 v9 里是独立控件不再是lv_meter的附属。lv_meter在 v9 里依然存在但官方更推荐新项目用lv_scale因为它的 API 更清晰扩展性也更好。2.2 角度映射的数学逻辑理解角度映射是调好仪表盘的关键。假设你设的angle_range是 135 到 405这是 LVGL 里常用的一个范围对应从左下到右下扫过 270 度min_value是 0max_value是 100。那么当前值v对应的角度就是angle 135 (v - 0) / (100 - 0) * (405 - 135) 135 v / 100 * 270当 v0 时角度是 135 度v50 时是 270 度正上方v100 时是 405 度也就是 45 度因为 405-36045。这个映射是线性的lv_scale内部就是这么算的。你调angle_range的时候要注意LVGL 的角度是顺时针增加的0 度在三点钟方向90 度在六点钟方向180 度在九点钟方向270 度在十二点钟方向。所以 135 度其实是七点半方向405 度是四点半方向这样扫出来正好是一个开口朝下的 270 度圆弧符合大多数仪表盘的视觉习惯。如果你想要一个开口朝上的仪表盘比如某些转速表可以把angle_range设成 45 到 315这样扫出来是上半圆。这个没有标准答案看你的 UI 设计稿。2.3 刻度数量与标签的取舍total_tick_count和major_tick_every这两个参数决定了刻度的疏密。假设total_tick_count设成 21major_tick_every设成 5那么总共会有 21 条刻度线其中第 0、5、10、15、20 条是主刻度长线其余是次刻度短线。主刻度上会显示标签次刻度不显示。这里有个容易踩的坑total_tick_count是刻度线的总数不是格数。如果你想要 0 到 100 之间每 10 一个主刻度那就是 11 个主刻度0,10,20,...,100对应total_tick_count至少是 11major_tick_every设成 1。但这样次刻度就没有了。如果你想要每 10 一个主刻度、每 2 一个次刻度那total_tick_count要设成 510 到 100 每 2 一格共 51 个点major_tick_every设成 5每 5 个次刻度一个主刻度5*210。这个换算关系一定要算清楚不然刻度会乱。我个人的经验是仪表盘上主刻度不要超过 11 个否则标签会挤在一起尤其是小尺寸屏幕上。次刻度可以密一点但也不要超过主刻度的 5 倍不然视觉上会显得很乱。3. 从零搭一个仪表盘的完整实操3.1 环境准备与基础框架先假设你已经有一个能跑 LVGL v9 的工程不管是 PC 模拟器比如用 SDL 或者 Wayland 驱动还是 STM32 上的裸机/RTOS 工程。如果你还没搭好PC 模拟器是最快的验证方式官方仓库里有lv_port_pc_visual_studio和lv_port_pc_eclipse两个现成的工程拉下来改改就能用。STM32 的话我一般用 FreeRTOS 跑一个 GUI 任务里面定时调lv_timer_handler这个后面会细说。基础框架很简单创建一个屏幕设置背景色然后创建一个lv_scale对象lv_obj_t *scr lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x1a1a2e), LV_PART_MAIN); lv_obj_t *scale lv_scale_create(scr); lv_obj_set_size(scale, 240, 240); lv_obj_center(scale);这几行跑起来你会看到一个空白的方形区域什么都看不到因为还没设量程和刻度。别急接下来一步步填。3.2 设置量程、角度与刻度lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_range(scale, 0, 100); lv_scale_set_angle_range(scale, 135, 405); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5);LV_SCALE_MODE_ROUND_INNER表示刻度画在内侧指针也朝内。对应的还有LV_SCALE_MODE_ROUND_OUTER刻度画在外侧。仪表盘一般用 INNER因为指针从圆心指向外视觉上更自然。lv_scale_set_range设的是数值范围lv_scale_set_angle_range设的是角度范围这两个是核心映射参数。total_tick_count和major_tick_every按前面说的换算关系来设。设完这些你应该能看到一圈刻度线了但标签可能还没出来因为标签的样式需要单独配。3.3 配置刻度线与标签的样式LVGL v9 的样式系统分 partlv_scale有几个关键的 partLV_PART_MAIN主体背景LV_PART_ITEMS刻度线LV_PART_INDICATOR指示器一般不用LV_PART_TICKSv9 里刻度线的专用 part实际上 v9 的lv_scale刻度线样式是通过LV_PART_ITEMS和LV_PART_INDICATOR配合lv_scale_set_line_needle_value来控制的。更准确地说主刻度和次刻度的样式分别用LV_PART_MAIN和LV_PART_ITEMS来设。我直接贴一段实测能用的配置// 主刻度线样式 lv_obj_set_style_line_width(scale, 3, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x00d9ff), LV_PART_MAIN); lv_obj_set_style_line_opa(scale, LV_OPA_COVER, LV_PART_MAIN); // 次刻度线样式 lv_obj_set_style_line_width(scale, 1, LV_PART_ITEMS); lv_obj_set_style_line_color(scale, lv_color_hex(0x555555), LV_PART_ITEMS); lv_obj_set_style_line_opa(scale, LV_OPA_COVER, LV_PART_ITEMS); // 刻度标签样式 lv_obj_set_style_text_color(scale, lv_color_hex(0xffffff), LV_PART_MAIN); lv_obj_set_style_text_font(scale, lv_font_montserrat_14, LV_PART_MAIN);这里有个细节标签的字体要用lv_font_montserrat_14这种内置字体或者你自己用 LVGL 的字体转换工具生成的字体。默认字体太小仪表盘上根本看不清。如果你要显示中文标签必须自己生成中文字体内置的 Montserrat 不含中文。3.4 添加指针并让它动起来指针有两种做法。第一种是用线lv_scale_set_line_needle_value(scale, 80, 4, 50);第一个参数是 scale 对象第二个是线的长度从圆心往外伸多少像素第三个是线宽第四个是当前值。这行代码会在 scale 上画一条从圆心指向当前值对应角度的线。但注意这个接口是设置而不是创建每次值变化你都要重新调一次它会覆盖上一次的指针。第二种是用图片lv_obj_t *needle_img lv_image_create(scale); lv_image_set_src(needle_img, needle_icon); lv_scale_set_image_needle_value(scale, needle_img, 50);图片指针的好处是可以做得很精致比如一个带渐变和阴影的箭头。但图片要提前转成 LVGL 的 C 数组用官方的图片转换工具就行。我一般用线指针做原型快速验证逻辑等 UI 定稿了再换成图片指针。线指针的性能更好因为不用解码图片。3.5 用定时器驱动数值变化仪表盘要动起来得有个东西定时改值。在 PC 模拟器上你可以用lv_timer_createstatic void anim_cb(lv_timer_t *t) { static int val 0; val (val 3) % 101; lv_scale_set_line_needle_value(scale, 80, 4, val); } lv_timer_t *timer lv_timer_create(anim_cb, 50, NULL);每 50ms 加 3大概 1.7 秒走完一圈。实际项目里这个值应该来自你的传感器或业务逻辑比如 ADC 采到的电压、CAN 总线上的转速信号。在 STM32 FreeRTOS 上lv_timer_handler要放在一个任务里定时调用一般 5ms 一次。lv_timer_create创建的定时器会被lv_timer_handler统一调度所以你不需要额外开任务。但要注意lv_timer_handler的调用周期要稳定不然动画会卡顿。我一般用vTaskDelayUntil来保证周期精确。4. 让仪表盘更好看的几个关键细节4.1 颜色搭配与视觉层次仪表盘好不好看颜色占七成。我的经验是背景用深色比如0x1a1a2e这种深蓝黑刻度线用中灰主刻度用亮色青色或橙色指针用高对比度的颜色红色或白色标签用白色或浅灰。这样层次分明远看也能看清。不要用纯黑背景配纯白刻度对比度太高看久了眼睛累。也不要用太多颜色三种以内就够了。如果你要做多仪表盘并排每个仪表盘的主色调可以不同但背景色要统一。4.2 刻度标签的旋转与对齐LVGL v9 的lv_scale支持标签旋转但默认是不旋转的。如果你想要标签跟着刻度方向转需要设置lv_scale_set_label_show(scale, true);实际上 v9 里标签旋转是自动的当LV_SCALE_MODE_ROUND_INNER时标签会自动朝向圆心。如果你发现标签方向不对检查一下 mode 设置。有些版本需要手动调lv_scale_set_text_src来指定标签文本但大多数情况下自动生成的数字就够用了。如果标签数字太长比如四位数在 240x240 的屏幕上会挤。这时候要么减少主刻度数量要么把字体调小要么用缩写比如 1000 显示成 1k。我一般会在lv_scale_set_text_src里传一个自定义的字符串数组把长数字替换成短标签。4.3 指针的平滑动画直接改值指针会跳看起来生硬。LVGL 有内置的动画系统可以给指针加过渡lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, scale); lv_anim_set_values(a, old_val, new_val); lv_anim_set_time(a, 300); lv_anim_set_exec_cb(a, (lv_anim_exec_xcb_t)anim_needle_cb); lv_anim_start(a);anim_needle_cb里调lv_scale_set_line_needle_value。这样指针会在 300ms 内平滑地从旧值转到新值视觉效果提升非常明显。但要注意动画期间如果新值又来了要先把旧动画删掉不然会冲突。我一般用一个静态变量存当前动画指针新动画开始前调lv_anim_delete。5. 常见问题与排查技巧实录5.1 刻度不显示或显示不全最常见的原因是total_tick_count和major_tick_every设得不合理。比如total_tick_count设成 10major_tick_every设成 5那只有第 0 和第 5 条是主刻度第 10 条不存在因为总共只有 10 条索引 0 到 9。这时候你会觉得怎么少了一个标签。解决办法是把total_tick_count设成 11这样索引 0 到 10第 10 条就是最后一个主刻度。另一个原因是角度范围设反了。angle_range的 start 必须小于 end如果你写成 405 到 135LVGL 不会报错但什么都不画。这个坑我踩过查了半天才发现。5.2 指针位置不对指针位置不对九成是量程和角度范围的映射没对上。检查一下lv_scale_set_range的 min/max 和你传入的当前值是否在同一个数量级。比如你设 range 是 0 到 100但传进去的值是 0 到 1000那指针永远在最大位置附近晃。还有一种情况是lv_scale_set_line_needle_value的第二个参数线长设得太短指针缩在圆心附近看不见。240x240 的 scale线长设 70 到 90 比较合适。5.3 性能问题与优化在 STM32F103 这种没有硬件浮点、主频 72MHz 的片子上lv_scale如果刻度设得太密比如total_tick_count超过 100刷新会明显卡顿。因为每帧都要重绘所有刻度线。优化办法有几个一是减少刻度数量主刻度 11 个、次刻度 50 个以内二是把 scale 的背景和刻度缓存成图片只让指针动但这需要额外内存三是降低刷新率仪表盘不需要 60fps20fps 就够了。我在 F103 上实测240x240 的 scale51 个刻度20fps 刷新CPU 占用大概 15% 左右可以接受。如果你用 F407 或更高基本不用操心性能。5.4 常见问题速查表现象可能原因解决办法刻度完全不显示angle_range 设反了确保 start end少一个标签total_tick_count 少 1改成主刻度数 * major_tick_every 1指针不动没调 set_line_needle_value每次值变化都要调一次指针位置偏range 和值数量级不匹配检查 range 的 min/max标签看不清字体太小或颜色太暗换大字体提高对比度刷新卡顿刻度太密或刷新率太高减少刻度降到 20fps中文显示乱码用了内置字体生成并注册中文字体6. 完整代码与移植要点6.1 PC 模拟器完整示例#include lvgl.h static lv_obj_t *scale; static int current_val 0; static void needle_anim_cb(void *var, int32_t v) { lv_scale_set_line_needle_value(scale, 80, 4, v); } static void timer_cb(lv_timer_t *t) { int new_val (current_val 5) % 101; lv_anim_t a; lv_anim_init(a); lv_anim_set_var(a, scale); lv_anim_set_values(a, current_val, new_val); lv_anim_set_time(a, 200); lv_anim_set_exec_cb(a, needle_anim_cb); lv_anim_start(a); current_val new_val; } void dashboard_create(void) { lv_obj_t *scr lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x1a1a2e), LV_PART_MAIN); scale lv_scale_create(scr); lv_obj_set_size(scale, 240, 240); lv_obj_center(scale); lv_scale_set_mode(scale, LV_SCALE_MODE_ROUND_INNER); lv_scale_set_range(scale, 0, 100); lv_scale_set_angle_range(scale, 135, 405); lv_scale_set_total_tick_count(scale, 51); lv_scale_set_major_tick_every(scale, 5); lv_obj_set_style_line_width(scale, 3, LV_PART_MAIN); lv_obj_set_style_line_color(scale, lv_color_hex(0x00d9ff), LV_PART_MAIN); lv_obj_set_style_line_width(scale, 1, LV_PART_ITEMS); lv_obj_set_style_line_color(scale, lv_color_hex(0x555555), LV_PART_ITEMS); lv_obj_set_style_text_color(scale, lv_color_hex(0xffffff), LV_PART_MAIN); lv_obj_set_style_text_font(scale, lv_font_montserrat_14, LV_PART_MAIN); lv_scale_set_line_needle_value(scale, 80, 4, 0); lv_timer_create(timer_cb, 100, NULL); }这段代码在 PC 模拟器上直接能跑你会看到一个深色背景的仪表盘指针每 100ms 动一次平滑地转圈。6.2 STM32 FreeRTOS 移植要点移植到 STM32 上核心是把lv_timer_handler放进一个 FreeRTOS 任务void gui_task(void *pvParameters) { dashboard_create(); TickType_t last_wake xTaskGetTickCount(); while (1) { lv_timer_handler(); vTaskDelayUntil(last_wake, pdMS_TO_TICKS(5)); } }任务栈建议至少 4KB因为 LVGL 内部有些函数递归比较深。堆的话LVGL 自己有一套内存管理LV_MEM_SIZE在lv_conf.h里设240x240 的仪表盘加一个屏幕建议至少 32KB。如果你还要跑其他 UI64KB 更稳妥。显示驱动和触摸驱动按你用的屏来配SPI 屏一般用 DMA 刷不然刷新率上不去。我用的是一块 240x240 的 ST7789 SPI 屏DMA 刷一帧大概 8ms20fps 很轻松。6.3 几个移植时容易忽略的配置lv_conf.h里有几个宏必须开LV_USE_SCALE必须设成 1v9 默认可能是关的LV_FONT_MONTSERRAT_14设成 1不然字体链接会报错LV_COLOR_DEPTH根据你的屏设16 位色最常用LV_USE_ANIM设成 1不然动画不生效还有一点v9 的lv_conf.h模板和 v8 差别很大不要直接拿 v8 的配置改建议用 v9 自带的模板重新配一遍。我一开始偷懒用了旧配置编译报了一堆错折腾了半天。7. 我踩过的几个坑和一点心得第一个坑是lv_scale_set_line_needle_value的线宽参数。我一开始设成 1结果指针细得几乎看不见后来改成 4 才正常。这个参数是像素值不是 LVGL 的 line width 样式别搞混了。第二个坑是动画冲突。我一开始没删旧动画快速连续改值时指针会乱跳甚至卡死。后来在timer_cb里加了一句lv_anim_delete(scale, needle_anim_cb)问题就解决了。这个细节官方文档里没写是我调试了半天才发现的。第三个坑是字体。我想在仪表盘中间显示当前数值用了lv_label结果中文标签显示成方块。后来用 LVGL 的字体转换工具生成了一个只含常用汉字的字体文件大小控制在 200KB 以内才搞定。如果你只需要数字和英文内置的 Montserrat 就够了别折腾中文字体。最后分享一个小技巧如果你想让仪表盘看起来更精致可以在 scale 下面垫一个圆形的lv_obj设成深色带一点渐变然后把 scale 放上去。这样刻度线不会直接贴在背景上视觉上更有层次。这个做法在多个项目里都验证过成本很低效果很明显。代码里的量程、角度、刻度数这些参数你按自己的需求改就行核心逻辑不用动。如果要做多个仪表盘并排把dashboard_create里的对象创建部分封装成带参数的函数传入不同的位置和量程即可。