news 2026/9/19 14:53:27

LVGL脏矩形刷新机制:原理、源码与STM32性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LVGL脏矩形刷新机制:原理、源码与STM32性能优化实战

1. 脏矩形机制到底在解决什么问题

第一次接触 LVGL 的刷新机制时,很多人会有一个疑问:屏幕上那么多像素,每次界面变化难道都要全部重绘一遍?如果真是这样,那在 STM32 这类主频只有一两百兆的 MCU 上,刷个屏估计要卡成幻灯片。实际上 LVGL 用了一套非常聪明的办法——脏矩形(Dirty Rectangle)刷新机制,只重绘真正发生变化的那一小块区域,其余部分原封不动。

这个机制的核心思想可以用一个生活场景来类比。假设你家里客厅墙上挂了一幅巨大的拼图,某天你发现其中三块拼错了需要替换。正常人的做法是把整幅拼图拆下来重新拼一遍吗?显然不是,你只会把那三块抠出来换掉,其他部分碰都不碰。LVGL 的脏矩形就是这个道理——它把屏幕划分成若干区域,当某个控件状态改变时,只把受影响的矩形区域标记为“脏”,下一帧刷新时只处理这些脏区域。

为什么这个机制如此重要?因为在嵌入式设备上,渲染性能直接决定用户体验。一块 320x240 的屏幕,全屏刷新一次需要处理 76800 个像素点,而如果只是某个按钮被按下,可能只需要刷新 60x30 的矩形区域,像素量直接降到 1800 个,相差 40 多倍。这个差距在低端 MCU 上就是“流畅”和“卡顿”的分水岭。

脏矩形机制主要解决三个层面的问题。第一是降低 CPU 负载,减少不必要的像素计算和内存拷贝;第二是降低总线带宽占用,特别是在 SPI 接口的屏幕上,数据传输量直接决定刷新速度;第三是降低功耗,对于电池供电的设备来说,少刷一个像素就少消耗一点电量。

适合阅读这篇内容的人包括:正在学习 LVGL 的嵌入式开发者、遇到界面卡顿需要优化性能的工程师、准备在 STM32 或 ESP32 上移植 LVGL 的同学,以及想深入理解 GUI 框架底层渲染原理的技术爱好者。不管你用的是 LVGL 8.x 还是 9.x,脏矩形的核心逻辑是一致的,只是 API 层面有些差异,我会在文中标注清楚。

2. 脏矩形的数据结构与核心原理拆解

2.1 脏矩形在 LVGL 内部是怎么表示的

LVGL 内部用一个lv_area_t结构体来表示矩形区域,它包含四个成员:x1y1x2y2,分别代表矩形左上角和右下角的坐标。注意这里的坐标是闭区间,也就是说x2y2对应的像素是包含在区域内的。这一点和很多图形库用开区间表示的习惯不同,写自定义控件时如果搞错了会导致边缘少刷一个像素。

typedef struct { lv_coord_t x1; lv_coord_t y1; lv_coord_t x2; lv_coord_t y2; } lv_area_t;

在 LVGL 的显示刷新上下文lv_disp_t(9.x 中改为lv_display_t)中,维护了一个脏矩形数组。这个数组的大小由LV_INDEV_DEF_READ_PERIOD无关,而是由刷新缓冲区的配置决定。默认情况下,LVGL 会维护一定数量的脏矩形,当脏矩形数量超过上限时,会触发合并操作。

脏矩形的产生来源主要有这么几个:控件调用了lv_obj_invalidate()或其变体、控件的位置或大小发生了变化、样式属性被修改、屏幕发生了滚动或动画。每次调用lv_obj_invalidate_area(),LVGL 就会把对应的区域加入到脏矩形列表中。

2.2 脏矩形的合并策略与算法逻辑

脏矩形最核心的难点不在于“标记”,而在于“合并”。如果每次控件变化都往列表里塞一个新矩形,那一个复杂界面动一下可能产生几十个脏矩形,最后刷新时反而比全屏还慢。所以 LVGL 有一套合并逻辑,我把它拆成几个关键步骤来讲。

第一步是判断新脏矩形是否与已有脏矩形相交。如果相交,就把两个矩形合并成一个更大的矩形,这个更大的矩形是能同时包含两者的最小矩形。这里用的是轴对齐包围盒(AABB)的思路,合并后的矩形面积可能比原来两个矩形面积之和大,但减少了矩形数量,整体上更划算。

第二步是判断新脏矩形是否被已有脏矩形完全包含。如果是,直接丢弃新的,因为已有区域已经覆盖了它。反过来,如果新矩形完全包含了某个已有矩形,就把那个旧矩形删掉。

第三步是数量上限控制。LVGL 内部有一个LV_INV_BUF_SIZE宏(默认值通常是 32),当脏矩形数量达到这个上限时,LVGL 会采取更激进的合并策略,甚至可能退化为全屏刷新。这个设计是为了防止极端情况下脏矩形列表无限膨胀。

注意:LV_INV_BUF_SIZE可以在lv_conf.h中调整。如果你做的界面控件非常多、动画很复杂,可以适当调大这个值,但代价是占用更多 RAM。在 STM32F103 这种 RAM 只有 20KB 的芯片上,建议保持默认甚至调小。

2.3 刷新缓冲区与脏矩形的配合关系

脏矩形决定了“刷哪里”,而刷新缓冲区决定了“怎么刷”。LVGL 支持三种缓冲模式:单缓冲、双缓冲和全屏缓冲。这三种模式对脏矩形的处理方式有细微差别。

单缓冲模式下,LVGL 把脏矩形区域的渲染结果写入缓冲区,然后通过flush_cb回调把缓冲区数据发送到屏幕。由于缓冲区可能比脏矩形区域小,所以一个脏矩形可能需要分多次传输。双缓冲模式下,有两个缓冲区交替使用,渲染和传输可以并行,效率更高。全屏缓冲就是缓冲区大小等于屏幕大小,一次渲染完整个脏矩形区域再统一发送。

这里有个关键点:脏矩形区域的大小和缓冲区大小的关系直接影响刷新效率。如果脏矩形区域远大于缓冲区,LVGL 需要把脏矩形切分成多个小块逐个渲染传输,这会增加开销。所以在实际项目中,合理设置缓冲区大小很重要。一般来说,缓冲区设为屏幕大小的 1/10 到 1/4 是比较常见的做法。

3. 从源码角度看脏矩形的完整生命周期

3.1 标记阶段:invalidate 系列函数做了什么

当你在代码里调用lv_obj_invalidate(obj)时,LVGL 内部实际执行的操作比想象中复杂。这个函数首先会检查对象是否可见、是否在屏幕内,然后获取对象的坐标区域,最后调用_lv_inv_area()把这个区域加入到脏矩形列表。

void lv_obj_invalidate(const lv_obj_t * obj) { if(lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return; if(lv_obj_get_parent(obj) == NULL) return; lv_area_t obj_area; lv_obj_get_coords(obj, &obj_area); /* 考虑样式中的阴影、轮廓等超出边界的部分 */ lv_area_t ext_area; lv_obj_get_ext_draw_size(obj, &ext_area); lv_area_union(&obj_area, &obj_area, &ext_area); _lv_inv_area(lv_obj_get_disp(obj), &obj_area); }

注意这里有一个容易被忽略的细节:扩展绘制区域。很多控件有阴影、发光、圆角等效果,这些效果的绘制范围会超出控件本身的坐标区域。如果只标记控件本身的区域,阴影部分就不会被刷新,导致画面上出现残影。LVGL 通过lv_obj_get_ext_draw_size()获取扩展尺寸,把这块也纳入脏矩形。

_lv_inv_area()是真正操作脏矩形列表的函数。它会遍历当前的脏矩形数组,尝试合并,如果合并失败且数组未满就追加新条目,如果数组已满就触发全屏刷新。这个函数的逻辑虽然不复杂,但它是整个刷新机制的心脏。

3.2 合并阶段:脏矩形列表的维护细节

脏矩形列表的合并过程可以用一个具体的例子来说明。假设屏幕上先后产生了三个脏矩形:A(10,10,50,50)、B(40,40,80,80)、C(100,100,120,120)。

处理 A 时,列表为空,直接加入,列表变为 [A]。处理 B 时,发现 B 与 A 相交(因为 40<50 且 40<50),于是合并成 D(10,10,80,80),列表变为 [D]。处理 C 时,C 与 D 不相交,直接加入,列表变为 [D, C]。最终只需要刷新两个矩形区域。

但如果 C 是 (70,70,90,90),它和 D(10,10,80,80) 相交,合并后变成 (10,10,90,90),列表又变回一个矩形。可以看到,合并策略的效果和脏矩形的产生顺序、位置分布密切相关。

实操心得:如果你的界面上有多个独立的小控件频繁变化,比如一排 LED 指示灯,它们各自产生脏矩形且互不相交,这时候脏矩形列表会保持多个条目。如果数量接近上限,LVGL 可能会触发全屏刷新,反而降低性能。这种情况下可以考虑把这些小控件放在一个容器里,统一 invalidate 容器,让它们合并成一个大矩形。

3.3 渲染阶段:脏矩形如何驱动实际绘制

到了刷新阶段,LVGL 的主循环会调用_lv_disp_refr_timer(),这个函数遍历脏矩形列表,对每个脏矩形执行渲染。渲染过程大致分为这几步:先调用draw_rect相关的函数把背景画上,然后遍历该区域内所有需要重绘的对象,按层级从低到高依次绘制。

这里有一个优化点值得注意:LVGL 在渲染脏矩形时,会做裁剪(clipping)。也就是说,即使一个对象很大,但只有一部分落在脏矩形内,LVGL 也只会绘制落在脏矩形内的那部分。这个裁剪操作是在绘制函数内部通过设置裁剪区域实现的,能进一步减少实际绘制的像素量。

渲染完成后,LVGL 调用你注册的flush_cb回调,把缓冲区数据发送到显示屏。在flush_cb中,你需要根据传入的area参数确定数据要写到屏幕的哪个位置。这个area就是当前正在刷新的脏矩形区域。

void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p) { /* 设置屏幕的刷新窗口为 area 指定的区域 */ set_display_window(area->x1, area->y1, area->x2, area->y2); /* 把 color_p 中的数据写入屏幕 */ send_pixels_to_display((uint16_t *)color_p, lv_area_get_size(area)); /* 必须调用,通知 LVGL 刷新完成 */ lv_disp_flush_ready(disp_drv); }

3.4 清除阶段:脏矩形列表的重置时机

一帧刷新完成后,LVGL 会清空脏矩形列表,等待下一轮的标记。但这里有个时序问题:如果在刷新过程中又有新的 invalidate 调用,这些新的脏矩形会被记录到下一帧处理。LVGL 通过一个标志位来区分当前帧和下一帧的脏矩形,确保不会丢失刷新请求。

在 LVGL 9.x 中,这部分逻辑有一些调整,引入了lv_display_refr_timer和更精细的刷新控制。但核心思路没变:标记、合并、渲染、清除,四个阶段循环往复。

4. 实际项目中的脏矩形优化实战

4.1 缓冲区大小与脏矩形刷新效率的平衡

在实际项目中,缓冲区大小的选择直接影响脏矩形的刷新效率。我拿 STM32F407 驱动 ILI9341 屏幕(320x240)做过一组对比测试,结果如下:

缓冲区大小占屏幕比例平均刷新时间RAM 占用
320x104.2%8.5ms6.4KB
320x4016.7%5.2ms25.6KB
320x8033.3%4.1ms51.2KB
320x240100%3.8ms153.6KB

从数据可以看出,缓冲区从 10 行增加到 40 行,刷新时间下降了近 40%,但继续增加到全屏,收益就很小了。这是因为脏矩形区域通常不会太大,缓冲区达到一定大小后,大部分脏矩形都能一次装下,不需要分块传输。

实操心得:在 RAM 有限的 MCU 上,缓冲区设为屏幕高度的 1/8 到 1/4 是比较甜的点。以 320x240 屏幕为例,320x30 或 320x60 的缓冲区大小,既能保证大部分脏矩形一次刷完,又不会占用太多 RAM。另外记得把缓冲区定义为全局数组或使用 DMA 可访问的内存区域,否则 DMA 传输会出问题。

4.2 控件布局对脏矩形合并的影响

控件的布局方式会显著影响脏矩形的合并效果。我做过一个实验:在屏幕上放 10 个按钮,排成一行,每个按钮 60x30 像素,间距 5 像素。当依次点击这些按钮时,观察脏矩形的产生情况。

如果按钮直接放在屏幕上,每次点击产生一个 60x30 的脏矩形,由于按钮之间有间距,脏矩形不相交,列表里会积累多个条目。但如果把这 10 个按钮放在一个容器里,点击按钮时同时 invalidate 容器,脏矩形就变成了整个容器的区域,虽然面积大了,但只有一个条目,合并和管理的开销更小。

这个实验说明一个道理:脏矩形的优化不只是减少面积,还要考虑数量。在某些场景下,适当增大单个脏矩形的面积来减少数量,反而能提升整体性能。特别是当脏矩形数量接近LV_INV_BUF_SIZE上限时,合并成大矩形比触发全屏刷新要划算得多。

4.3 动画场景下的脏矩形行为分析

动画是脏矩形机制面临的最大挑战。一个旋转的指针、一个滑动的列表,每帧都会产生新的脏矩形。如果处理不当,动画会变得非常卡顿。

以圆弧进度条为例,当进度值变化时,LVGL 需要重绘圆弧的一部分。如果每次只 invalidate 变化的那一小段弧线,脏矩形会非常碎,合并开销大。LVGL 的实际做法是 invalidate 整个圆弧控件的区域,虽然面积大了,但保证了脏矩形的完整性。

对于列表滚动这种场景,脏矩形的处理更复杂。滚动时,列表内容整体位移,理论上只需要刷新新进入视野的部分和移出视野的部分。但 LVGL 目前的实现是 invalidate 整个列表区域,然后重新绘制。这在列表项不多时没问题,但如果列表很长,性能就会下降。

注意:如果你在做长列表滚动,可以考虑用 LVGL 的lv_obj_scroll_to_view()配合局部刷新,或者自己实现分页加载,减少单次刷新的区域。另外,LVGL 9.x 对滚动刷新做了一些优化,如果项目允许,升级到 9.x 会有改善。

4.4 自定义控件中的脏矩形处理技巧

写自定义控件时,脏矩形的处理是最容易出错的地方。我踩过的一个坑是:自定义了一个带阴影的卡片控件,只 invalidate 了卡片本身的区域,结果阴影部分在状态变化时出现残影。后来在LV_EVENT_DRAW_MAIN回调里用lv_obj_get_ext_draw_size()获取扩展区域,把阴影也纳入脏矩形,问题才解决。

另一个坑是局部刷新与全局刷新的冲突。有些开发者为了优化性能,在自定义控件里手动调用lv_obj_invalidate_area()只刷新变化的部分。但如果这个控件同时参与了动画或样式过渡,局部刷新可能导致画面撕裂。我的建议是:除非你非常清楚控件的绘制逻辑,否则优先使用lv_obj_invalidate()让 LVGL 自己决定刷新区域。

5. 常见问题排查与性能调优速查

5.1 画面残影与撕裂问题的排查思路

残影是脏矩形机制最常见的症状,表现为控件移动或消失后,原来的位置还残留着旧图像。排查残影问题,我通常按这个顺序检查:

首先确认flush_cb中是否正确设置了刷新窗口。如果窗口设置错误,数据会写到错误的位置,导致残影。其次检查控件的扩展绘制区域是否被正确 invalidate,特别是带阴影、圆角、边框的控件。然后确认LV_INV_BUF_SIZE是否够用,如果脏矩形数量经常触顶,会触发全屏刷新,理论上不会残影,但性能会下降。

撕裂问题通常和缓冲区模式有关。单缓冲模式下,如果flush_cb是阻塞式的,渲染和传输不能并行,可能出现撕裂。改用双缓冲模式,并确保flush_cb中使用 DMA 传输,可以缓解这个问题。

5.2 刷新率上不去的几个典型原因

刷新率低是另一个高频问题。我整理了一个排查表,按可能性从高到低排列:

可能原因排查方法解决方向
缓冲区太小查看脏矩形是否被分块传输增大缓冲区到屏幕 1/8 以上
SPI 时钟太低测量实际 SPI 时钟频率提高到屏幕支持的上限
flush_cb 阻塞检查是否用了 DMA改用 DMA 传输
脏矩形过多打印脏矩形数量优化布局,减少独立控件
绘制函数太慢用 GPIO 翻转测绘制耗时简化样式,减少渐变和阴影
全屏刷新频繁检查是否触发了全屏 invalidate排查代码中的全屏刷新调用

实操心得:用 GPIO 翻转配合示波器测量刷新时间是最直接的方法。在flush_cb开头拉高一个 GPIO,在lv_disp_flush_ready()前拉低,示波器上就能看到每次刷新的实际耗时。如果发现某次刷新特别长,大概率是触发了全屏刷新,可以重点排查那次刷新前后的代码逻辑。

5.3 LVGL 8.x 与 9.x 在脏矩形上的差异

LVGL 9.x 对显示刷新架构做了较大重构,脏矩形的处理也有一些变化。主要差异体现在这几个方面:

9.x 引入了lv_display_t替代 8.x 的lv_disp_t,API 名称有变化但功能对应。9.x 的刷新定时器逻辑更精细,支持按显示设备独立配置刷新周期。9.x 对脏矩形的合并算法做了优化,在高密度脏矩形场景下性能更好。另外 9.x 新增了lv_display_set_flush_wait_cb()等回调,对 DMA 传输的同步控制更灵活。

如果你正在用 8.x 且项目稳定,没必要为了脏矩形优化专门升级。但如果是新项目,建议直接上 9.x,刷新相关的 API 设计更合理,文档也更完善。

5.4 高频踩坑点与避坑清单

最后整理一份我在实际项目中踩过的坑和对应的避坑建议:

  • 忘记调用lv_disp_flush_ready():这是最致命的错误,LVGL 会一直等待刷新完成,整个界面卡死。每次写完flush_cb都要检查这一句。
  • 在中断中调用 invalidate:LVGL 的 invalidate 不是线程安全的,在中断中调用可能导致脏矩形列表损坏。如果需要在中断中触发刷新,用lv_async_call()或设置标志位在主循环中处理。
  • 缓冲区没有对齐:DMA 传输通常要求缓冲区地址对齐,如果缓冲区定义在奇数地址,DMA 可能传输错误数据。用__attribute__((aligned(4)))确保对齐。
  • 脏矩形区域超出屏幕:自定义控件的坐标计算错误可能导致脏矩形超出屏幕范围,LVGL 虽然会做裁剪,但可能引发断言失败。在 invalidate 前用lv_area_intersect()和屏幕区域求交集。
  • 频繁全屏 invalidate:有些操作会隐式触发全屏刷新,比如修改屏幕背景色、切换主题。如果发现刷新率突然下降,优先排查这类操作。

脏矩形机制看起来只是“只刷变化的部分”这么简单一句话,但真正用好它,需要对 LVGL 的渲染流程、缓冲区管理、控件生命周期都有深入理解。我在实际项目中的体会是,大部分性能问题不是脏矩形本身的问题,而是控件布局和刷新策略的问题。把界面结构设计得合理一些,让脏矩形自然合并,比事后各种优化都有效。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 14:51:40

机器人控制器中的PCIe协议栈重构与确定性通信实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 14:50:24

基于SSH框架的Java固定资产管理系统设计与实现

简介&#xff1a;基于Java的固定资产管理系统毕业设计文档&#xff0c;是一份完整的论文资料&#xff0c;面向计算机专业学生、毕业设计者及希望掌握SSH框架的开发者。文档以某公司固定资产管理为背景&#xff0c;采用浏览器/服务器模式&#xff0c;运用JSP、Struts、Hibernate…

作者头像 李华
网站建设 2026/9/19 14:50:01

QT 串口助手从零开发:QSerialPort 收发、HEX 与打包发布

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 14:46:48

ROS机器人平台搭建:从一键安装到工程交付的6道硬坎

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 14:44:13

Multisim单相半波可控整流仿真:从原理到波形分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华