ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32H743+LVGL界面刷新优化:从掉帧到丝滑的完整实践

STM32H743+LVGL界面刷新优化:从掉帧到丝滑的完整实践 上周调试一个项目STM32H743IIT6 RGB 4.3寸屏跑LVGL界面元素不算多但滑动列表时掉帧非常明显肉眼可见的拖影和撕裂。说实话这个平台本身不弱Cortex-M7跑到480MHz片内还带着LTDC和DMA2D按理说跑个800x480的LVGL界面不应该这么吃力。问题到底出在哪我花了两天时间把整条刷新链路拆开量了一遍最后发现大多数性能损耗都不是LVGL渲染本身造成的而是底层配置、内存布局和代码习惯在拖后腿。这篇文章就记录一下我在H743上做图形界面刷新速度优化时踩过的坑以及实测下来真正有效的几个方向。1. 先别上来就调代码把刷新链路拆开量清楚时间都去哪儿了很多人一提到LVGL卡第一反应就是去调LV_MEM_SIZE、开DMA2D、换双缓冲结果折腾一圈帧率纹丝不动。原因很简单你连瓶颈在哪都不知道怎么可能优化到点子上。LVGL从应用层产生画面到屏幕亮起来中间要经过一条完整的链路任何一环慢了都会表现为界面卡。1.1 LVGL一次刷新的完整链路正常情况下一帧画面从产生到显示大致经历四个阶段应用层把控件状态变化标记为inactiveLVGL的定时器检测到脏区域后调度软件渲染器在渲染缓冲区里绘制这一帧内容。渲染完成后LVGL调用flush_cb回调把渲染缓冲区里的像素数据交给底层显示驱动。显示驱动把这块数据拷贝到实际用于LTDC输出的显存framebuffer。LTDC控制器按固定的像素时钟从显存读取数据通过RGB接口送给屏幕。这四步里真正属于LVGL本身的只有第一步。第二步和第三步完全取决于驱动代码怎么写第四步取决于硬件时钟和SDRAM带宽。大多数项目卡在第二、三步要么是flush_cb里的拷贝开销过大要么是等待LTDC刷屏导致flush_cb被阻塞渲染管线根本没有重叠起来。1.2 用最原始的手段给链路计时调优之前先确定每一段到底花了多少时间。我的办法很朴素在flush_cb入口和出口各翻转一个GPIO用逻辑分析仪抓电平宽度。这是最直接、最不会骗人的测量方式。static void my_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { HAL_GPIO_WritePin(DBG_GPIO_Port, DBG_Pin, GPIO_PIN_SET); // 真正执行的flush逻辑 // 注意如果用DMA2D异步传输这里不能直接拉低GPIO // 要在传输完成回调里拉低并调用 lv_disp_flush_ready() SCB_CleanDCache_by_Addr((uint32_t *)color_p, lv_area_get_width(area) * lv_area_get_height(area) * sizeof(lv_color_t)); // memcpy 或 DMA2D 拷贝到显存 // ... HAL_GPIO_WritePin(DBG_GPIO_Port, DBG_Pin, GPIO_PIN_RESET); lv_disp_flush_ready(drv); }另外打开LVGL自带的性能监控宏LV_USE_PERF_MONITOR屏幕上会直接显示实时FPS和CPU占用率。不过这个数据只能看出整体帧率看不出瓶颈在哪一段还是要靠GPIO测量来拆分。1.3 不同瓶颈对应的症状特征根据我反复实测的经验不同位置的瓶颈表现其实有很明显的差异如果flush_cb里GPIO高电平时间很长超过几毫秒甚至十几毫秒说明瓶颈在拷贝/搬运数据这一层。通常是由整屏拷贝、SDRAM带宽不足、Cache未命中引起的。如果flush_cb很快但整体FPS还是上不去说明LVGL渲染线程根本没被及时调度或者LV_DISP_DEF_REFR_PERIOD设置得太长。如果画面有撕裂但不是特别卡多半是显存和渲染缓冲区重叠在LTDC正在读某一区域时CPU/DMA2D又改写了这个区域。如果动效偶尔突然卡一下然后又恢复优先怀疑后台定时器、中断优先级、RTOS任务抢占在抢CPU。先把症状对号入座再动手改方向就清楚了。我见过太多人拿着DMA2D一顿配置结果瓶颈在SDRAM带宽上越优化越毛躁。2. 硬件配置里最容易拖后腿的几处时钟、显存、CacheH743的硬件底子本身是够的但要用好它绕不开几个底层设置。这些设置只要有一处不合适性能直接打折而且问题非常隐蔽。2.1 LTDC像素时钟和SDRAM带宽要算明白LTDC的像素时钟决定了屏幕刷新的物理速度。这个时钟来自PLL如果配置偏低哪怕LVGL渲染再快屏幕依然会闪烁、拖影。计算方式很简单以常见的800x480分辨率、60Hz刷新为例需要像素时钟约等于800 × 480 × 60 ≈ 23.04 MHz但这只是去除消隐区后的理想值实际还要加上水平同步、垂直同步、前后肩等消隐时间。以我常用的LTDC时序参数为例水平周期约为824像素垂直周期约为492行那么实际像素时钟是824 × 492 × 60 ≈ 24.3 MHz所以我一般会配置PLL输出到LTDC的时钟在24MHz到30MHz之间留出余量。具体PLL分频系数可以从CubeMX里直接算但要注意PLL源时钟是多少以及LTDC时钟树里经过的分频器别光看CubeMX生成的代码确认运行时RCC_GetClocksFreq返回的LCD_TFT频率符合预期。SDRAM这边也要算一笔账。假设外接16位SDRAM运行在166MHz理论带宽是166MHz × 2字节 332MB/s。LTDC以60Hz刷新800x480 RGB565画面每秒消耗约46MB带宽看起来完全够用。但实际SDRAM访问存在行切换、bank切换、刷新周期等开销有效带宽通常只有理论值的50%-70%。如果LTDC占掉了大量带宽CPU和DMA2D再同时访问SDRAM就很容易出现带宽争抢表现为图像刷新瞬间变慢、偶发撕裂。2.2 显存放哪里直接决定带宽和延迟H743片内有1MB RAM看起来不少但分布在不同总线上。其中AXI SRAM192KB带宽最高延迟最低是放渲染缓冲区的首选DTCM和ITCM虽然访问也快但连的不是AXI总线DMA2D不一定能访问LTDC也不一定能直接从DTCM取数据所以放显存不现实外部SDRAM才是大容量显存的归宿。以320x240分辨率、RGB565为例一帧显存只需要320 × 240 × 2 153600字节约150KB。这个大小勉强能塞进AXI SRAM单缓冲完全没问题渲染缓冲区另放一处性能非常理想。但800x480就不一样了一帧需要768KB只有SDRAM装得下。这种情况下我的建议是显存放SDRAM这是LTDC唯一可取用的大容量区域LVGL的渲染缓冲区放AXI SRAM利用高带宽flush_cb里用DMA2D把渲染缓冲区拷贝到SDRAM显存。不要为了省事让LVGL直接渲染到SDRAM显存里这样每画一个控件都带上SDRAM的写延迟渲染速度会肉眼可见地下降。2.3 DMA2D的正确用法与Cache一致性陷阱DMA2D是H743内置的2D DMA控制器支持内存到内存拷贝、像素格式转换、填充、混合等操作。很多人一听说DMA2D能加速就激动地在flush_cb里调用DMA2D做整屏拷贝结果发现性能没有任何提升甚至更慢了。原因有两类第一DMA2D启动本身有开销。如果你刷的新区域很小比如只有几十行像素DMA2D的配置和启动时间可能比CPU直接memcpy还长。所以小区域拷贝用CPU大区域才用DMA2D这个阈值我一般设在几千字节以上。第二DMA2D不经过CPU的Cache。开启D-Cache之后CPU写渲染缓冲区时数据可能还留在Cache里DMA2D去读SDRAM/内存时读到的是旧数据画面就会出现残缺、错位、花屏。正确做法是在DMA2D启动前先把渲染缓冲区对应的地址空间做一次Cache CleanSCB_CleanDCache_by_Addr((uint32_t *)src_addr, byte_len);反过来如果DMA2D把数据写进一块内存CPU之后还要读这块内存则在读取前要做一次Invalidate否则CPU可能会从Cache里读到陈旧数据。很多人只记得Clean忘了Invalidate结果画面偶尔花一下排查半天找不到原因。2.4 不要忽略MPU对内存区域Cache属性的影响这一条非常容易被忽略。STM32H7的Cortex-M7核带有MPU通过MPU可以设置外部SDRAM区域是否Cacheable、是否Bufferable、是否可共享。CubeMX默认可能在SystemInit或MPU_Config里已经做了配置但不同工程模板差异很大。我踩过一个大坑把SDRAM配置成Write-Back、Write-Allocate模式同时LTDC又直接从这块SDRAM读取显存DMA2D也在往里面写数据。由于共享属性配置不匹配导致LTDC读到的和DMA2D写入的不一致画面偶尔出现整行错色。后来我把显存区域在MPU里配置为Normal、Non-Cacheable渲染缓冲区所在的AXI SRAM保持Cacheable问题立刻消失而且性能损失并不明显。因为显示显存本身不需要CPU反复读写Cache的作用有限不缓存反而省去一坨同步操作。3. lv_conf.h里影响刷新速度的开关逐项过一遍LVGL的性能表现很大程度由lv_conf.h里的编译期配置决定。这些开关不是越大越好也不是越多越好每一项都要结合你的屏幕和场景去权衡。3.1 颜色深度16位还是32位要拿屏幕说话LV_COLOR_DEPTH直接决定每个像素占用多少字节也决定渲染带宽和缓冲大小。如果你的屏是RGB565接口那就用16位每个像素2字节渲染缓冲和显存都省一半SDRAM带宽压力大幅下降。我之前在一个项目里图省事用的屏其实是RGB565但LV_COLOR_DEPTH错误地设成了32位结果800x480单帧显存直接飙到1.5MBSDRAM带宽吃紧刷新率上不去。改回16位之后帧时间立竿见影下降。这里有个小提醒LTDC的像素格式也要和LV_COLOR_DEPTH保持一致否则颜色会偏到时候你又要加一堆颜色转换逻辑性能又白搭。还要注意LV_COLOR_16_SWAP这个选项。如果RGB565数据低字节在前还是高字节在前搞反了颜色会偏蓝偏红。这个其实不影响速度但它会诱导你去做字节交换浪费性能所以能不动就不动接线时保证字节序正确。3.2 显示缓冲策略1/10屏、半屏、双缓冲的取舍LVGL渲染前需要一块缓冲区缓冲区大小直接影响LVGL一次能处理多少脏区域1/10屏缓冲内存占用小但一帧画面需要分十次以上flush每次都触发一次flush_cb和LTDC同步开销很大。全屏单缓冲渲染完一帧再整体flush逻辑简单但内存占用大而且LTDC在读显存的同时CPU可能正在写下一帧容易撕裂。半屏/双缓冲用两块缓冲区交替CPU渲染一块LTDC/DMA2D搬运另一块能有效隐藏拷贝时间但内存占用成倍增加。以800x480 RGB565为例一帧是768KB双缓冲要1.5MB片上RAM肯定放不下必须用SDRAM。这种情况下我常用的折中方案是显存用SDRAM放一块LVGL渲染缓冲用AXI SRAM放半屏或1/4屏配合DMA2D搬运效果比死磕双缓冲好得多。如果是320x240的小屏那就可以大胆一点显存和渲染缓冲都放AXI SRAM甚至直接双缓冲性能会非常从容。3.3 掉坑最多的LV_DISP_DEF_REFR_PERIOD与limited refreshLV_DISP_DEF_REFR_PERIOD默认是33ms对应约30FPS的刷新周期。如果你的屏幕是60Hz这个值明显偏慢LVGL即使性能足够最多也只会以约30FPS刷新界面。项目里需要快速响应的动效建议改成16甚至10#define LV_DISP_DEF_REFR_PERIOD 16但要注意这个值只是LVGL定时检查并触发渲染的周期并不是强制每周期都渲染一帧。如果区域没有变化LVGL不会白耗CPU。改小这个值不会让CPU无意义空转只会让变化更快被响应所以放心调。另一个必须确认的是LV_DISP_USE_LIMITED_REFRESH。LVGL8里这个宏控制是否只刷新脏区域。如果被置成0那么每次渲染都会全屏刷新渲染量直接飙升帧率能掉到个位数。这个宏默认是1但有一些网上拷贝的配置模板会把它关掉移植时务必检查。3.4 GPU/DMA2D相关开关和回调实现的坑LVGL8里涉及DMA2D的有LV_USE_GPU和LV_USE_DMA2D这类开关但LVGL本身并不知道你的芯片是什么、DMA2D怎么用它只能调用你提供的回调。也就是说你光把宏打开没有实现gpu_fill_cb、gpu_blend_cb或gpu_wait_cbLVGL要么编译报错要么实际根本没走GPU路径性能不会有任何变化。我见过一个项目宏全开了但gpu_fill_cb实现里居然用了阻塞式DMA2D等待每填充一次屏幕就要死等DMA2D完成最终渲染速度比纯软件还慢。正确做法是让LVGL认为GPU操作已提交在真正需要访问结果时才等待。LVGL的GPU接口设计里有个gpu_wait_cb就是干这个的你要把同步点放在那里而不是每次调用都阻塞。还有一个通用的建议DMA2D最值得接管的是flush_cb里的像素拷贝不是LVGL内部的填充和混合。因为LVGL内部的fill、blend操作往往是小面积的DMA2D的启动开销会吞掉收益而flush通常是几百KB级别的连续内存搬运DMA2D的优势非常明显。4. 控件写法和渲染开销很多卡顿其实来自代码习惯底层配置都到位之后如果界面依然不够丝滑那就要检查应用代码的写法了。LVGL是软件渲染CPU每个像素都是实打实算出来的控件使用习惯直接决定CPU要干多少活。4.1 半透明、阴影和圆角软件混合的代价LVGL8的软件渲染器对半透明、圆角、阴影的处理非常吃CPU。原因是这些效果需要逐像素做alpha混合还要处理多个图层叠加。我看到不少人为了让界面好看给每个容器都加了半透明背景、圆角边框、阴影效果结果整屏内容每次变化时软件渲染器要反复做混合计算帧率直接从50FPS掉到十几FPS。我的建议是背景层用完全不透明的纯色圆角和阴影只放在静止不动的元素上需要频繁刷新的列表项、进度条、图表尽量保持简单样式。如果非要毛玻璃、高透明度、复杂阴影那就得考虑换硬件平台或者用可视化接口做预渲染了。4.2 频繁创建删除对象以及列表/图表重建另一种常见写法是每次数据变化都把整个列表删了重新创建一遍。这样做不仅会产生大量内存碎片更重要的是每次创建都要触发样式计算和布局渲染开销极大。正确做法是预创建足够多的对象数据变化时只更新文本、颜色、位置或者使用LVGL自带的列表回收机制。之前我做过一个数据监控界面每秒刷新一组曲线数据一开始是每次lv_obj_clean后重建曲线对象CPU占用率高得吓人改成lv_chart_set_next_value更新数据点之后CPU负载直接降了60%以上。图表、列表这类控件都有专门的数据接口优先用它们而不是推倒重建。4.3 字体抗锯齿与位图字体的性能差异字体渲染是另一个隐蔽的性能杀手。LVGL默认字体大多开启抗锯齿Anti-Aliasing绘制文本时需要对字符边缘的每个像素做灰度计算非常耗时。如果你的界面大量使用大号字体、动态数字字体的渲染时间会占到CPU渲染时间的很大比例。对不需要美观场合可以考虑使用不带抗锯齿的字体或者用字体工具如lv_font_conv生成预抗锯齿的位图字体。尤其数字在仪表界面里变化频繁用专门优化过的小字位图字体能明显降低刷新耗时。另外字体缓冲如果尺寸太小LVGL会频繁缩小/放大字符缓存也会拖慢文本渲染适当调大字体缓存能减少重复渲染。4.4 定时器和局部强制刷新的误用有些人为了实现动画在高层用lv_timer_create创建了一个每10ms触发一次的定时器每次触发都调用lv_obj_invalidate强制对象重绘甚至直接lv_refr_now(NULL)整帧刷新。这样的写法会把LVGL精心设计的脏区域合并机制完全废掉每一帧都变成全屏渲染性能自然上不去。LVGL自己的动画框架有内置的缓动更新机制它会自动标记脏区域不需要你手动高频invalidate。如果真要自定义定时器间隔不要短于一个刷新周期比如16ms而且尽量只对变化区域调用lv_obj_invalidate不要碰lv_refr_now。lv_refr_now这个函数是给特殊同步场景用的平时不要碰。5. 从十几帧到流畅一次实际优化过程的完整复盘前面讲了很多理论这一节用一个我实际经手的案例把整个优化过程串一遍顺便给出量化对比。这个项目是H743 800x480 RGB屏 FreeRTOS LVGL8界面包含一个实时曲线图、若干仪表盘数字、一个可滑动的菜单列表。5.1 初始状态UI在裸机FreeRTOS上的表现项目一开始是从某个开发板模板改过来的模板里LTDC时钟用的是CubeMX默认值SDRAM时序参数比较保守D-Cache是开着的但没配MPU显存和LVGL渲染缓冲都放在SDRAM。LVGL配置方面LV_DISP_DEF_REFR_PERIOD还是默认的33ms渲染缓冲区是1/10屏约80KBDMA2D相关宏全部没有启用flush_cb里是阻塞式memcpy。实测表现滑动列表时FPS在12到17之间波动曲线图每秒更新一次但每次更新都能明显看到屏幕从上到下逐行刷新就是那种擦玻璃的效果CPU负载在FreeRTOS里看超过70%。整个体验属于能用但完全谈不上流畅。5.2 第一轮时钟和Cache调整后的变化先把LTDC像素时钟校准到接近25MHz确保屏幕物理刷新率稳定。然后用MPU把SDRAM显存区域配置成Non-CacheableAXI SRAM单独划分出一块作为LVGL渲染缓冲区Cacheable属性保留。最后在flush_cb里加上SCB_CleanDCache_by_Addr保证DMA2D/CPU搬运之前渲染缓冲区的数据已经落到物理内存。这轮改动之后最大变化是画面不再偶发错位、撕裂整体稳定性好了很多。但帧率提升并不明显FPS大概从14涨到18左右瓶颈还是在整帧刷新和多次小buffer flush上。5.3 第二轮缓冲位置和LVGL配置调整把LVGL渲染缓冲从SDRAM挪到AXI SRAM大小从1/10屏加大到1/4屏约192KB刚好接近AXI SRAM上限LV_DISP_DEF_REFR_PERIOD改成16ms确认LV_DISP_USE_LIMITED_REFRESH为1。这一轮的效果非常明显FPS从18跳到了30左右。原因很简单软件渲染在AXI SRAM里跑访问延迟和带宽都远好于SDRAM渲染缓冲区变大后LVGL一帧里的flush次数从十几次降到三四次每次渲染和搬运的开销大幅减少。不过此时滑动列表在极端快速滑动时依然能感觉到轻微掉帧说明还有余力可挖。5.4 第三轮DMA2D接管flush和控件整改最后一轮flush_cb里用DMA2D做显存拷贝代替原来的CPU memcpy。注意用DMA2D异步传输lv_disp_flush_ready放在DMA2D传输完成中断里调用这样LVGL可以提前准备下一块渲染数据与DMA2D搬运重叠起来。由于源缓冲区和目标显存都在不同的内存区域MPU和Cache配置已经在第一轮调好了这里只要在启动DMA2D前做一次Cache Clean。同时把控件里的半透明背景和阴影全部去掉换成纯色背景和简单边框曲线图改用lv_chart_set_next_value更新数据点列表滑动时不再重建子项只更新可见区域的数据文本。再打开LV_USE_PERF_MONITOR观察FPS稳定在55-60之间CPU负载降到30%左右快速滑动时偶尔能掉到45FPS但体感已经非常流畅。5.5 前后数据对比与后续延伸优化项优化前第一轮后第二轮后第三轮后滑动列表FPS12-17约18约3055-60撕裂/错位偶发基本消失消失消失CPU负载约70%约65%约50%约30%整帧刷新方式多次小buffer flush多次小buffer flush4次左右大buffer flush渲染与搬运重叠这三轮改动做完之后我又顺手做了一件事把升级空间留出来。因为LVGL9.x的渲染架构比8.x变化很大9.x引入了一些新的draw unit抽象对DMA2D的支持方式和8不一样。现在做版本选型时如果项目周期允许我都会优先评估LVGL9.x在PC模拟器上先把UI调好再到H743上适配这样能省掉很多在嵌入式端反复编译调UI的时间。6. 进阶思路和那些容易被忽略的细节如果按上面的步骤走完你的H743 LVGL界面应该已经有比较理想的刷新速度了。但如果你的界面复杂度更高或者刷新率还有硬指标下面这几个方向值得再深挖。6.1 刷新时序和TE信号的配合有些RGB屏幕带有TETearing Effect信号引脚用于通知MCU当前帧扫描到了哪个位置。利用TE信号可以把flush_cb的搬运时机对准屏幕的垂直消隐期避免因LTDC正在扫描某一区域时你正好在改这个区域而产生的撕裂。H743的LTDC本身带有中断和同步信号也可以通过LTDC的垂直空白中断来实现类似效果。但我实际用下来TE同步是一把双刃剑它消除撕裂的同时也会让flush不再那么随心所欲如果你在TE中断里做同步等待帧率上限会被屏幕刷新率锁死而且有可能因为等待时间过长导致LVGL渲染线程被拖住。所以我的建议是如果撕裂不明显就别上TE同步如果确实需要把它做成尽量等而不是必须等超时就放弃同步继续flush避免卡死渲染流程。6.2 FreeRTOS下LVGL任务优先级和Tick处理在FreeRTOS里跑LVGL任务优先级设置不当也会表现为性能问题。LVGL的lv_timer_handler需要周期性调用如果它的任务优先级比一些后台杂务任务还低那么系统忙的时候LVGL可能迟迟得不到调度界面自然卡顿。合理做法是给LVGL显示任务一个中等偏高的优先级高于普通后台任务但低于硬实时关键任务比如电机控制、传感器采集。同时保证lv_tick_inc的时基准确FreeRTOS里通常在SysTick或一个高优先级定时器任务里调用。时基不准会导致动画时间计算混乱也会让人误以为刷新速度有问题。6.3 图像资源的解码和存储位置如果界面里有大量图片图片的解码和读取可能成为隐藏瓶颈。LVGL支持PNG、JPG等格式但这些格式需要解码成位图后才能绘制。PNG解码本身就要吃掉不少CPU周期尤其是全屏PNG背景图每帧解码一次根本扛不住。我的习惯是界面图片在交给LVGL之前先离线转换成C Array或者二进制bin文件让LVGL直接加载位图数据。实在需要JPG时用硬件JPEG解码器H743有硬件JPEG/IP来做解码而不是让LVGL的软件解码器硬扛。图片放在外部Flash的话注意Flash读取速度用缓存或内存映射方式优化读取路径。这个部分经常是界面看起来简单但图片加载特别慢的根源。6.4 刷新率上不去时先看是不是全屏刷新最后分享一个排查经验遇到FPS上不去先用LV_USE_PERF_MONITOR看实时帧率然后在UI里只移动一个滑块或只点击一个按钮观察这段时间内是否有全屏刷新发生。如果只是很小的区域变化却触发了几百行甚至整屏的刷新说明有控件在布局阶段把自己的尺寸或者位置改变了或者在event_cb里对父容器调用了lv_obj_invalidate。这类问题从代码逻辑上找比从配置上找更快。我之前在一个仪表盘项目里就是这么定位到问题的数字刷新时文本框尺寸变化父容器重新布局紧接着整屏失效导致每次数字更新都触发全屏渲染。后来把文本控件设成固定宽度并在布局阶段关闭容器的滚动和自适应一帧的渲染区域就从全屏变成了一个几十像素宽的小区域性能立刻上来了。这类局部刷新陷阱在复杂UI里非常常见而且非常隐蔽。建议你在每次优化底层之后都回头检查一遍哪些操作会意外触发大范围失效这是软件层面的最后一公里优化也是提升体验最直观的地方。
返回列表