ARTICLE DETAIL

资讯详情

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

STM32 DMA2D加速LVGL:双缓冲与中断实现高帧率

STM32 DMA2D加速LVGL:双缓冲与中断实现高帧率 1. 项目缘起与整体设计思路STM32 上跑 LVGL 的朋友大概率都遇到过这个尴尬局面界面逻辑明明不复杂CPU 占用率却居高不下帧率死活上不去滑动列表时肉眼可见地卡顿。我最早用 F429 驱动一块 480×272 的 RGB 屏纯软件刷屏只能跑到 20 帧出头后来换成 DMA2D 硬件加速帧率直接翻了几倍。但随之而来的问题是DMA2D 传输完成中断和 LVGL 的刷新流程如果配合不好会出现画面撕裂、颜色错位甚至系统直接卡死。这篇文章就把我在这个方向上踩过的坑和最终跑通的方案完整梳理一遍。先说清楚这个项目要解决的核心问题。LVGL 默认的渲染流程是在缓冲区里用 CPU 逐像素画好一帧内容然后调用flush_cb把缓冲区数据搬到屏幕上。这个“搬”的过程如果也用 CPU 来做对于 RGB 接口的屏幕来说就是一次大规模的内存拷贝分辨率越高越吃力。DMA2D 是 STM32 的 Chrom-ART 图形加速器它能独立完成内存到内存、内存到显示器的块传输还能顺便做颜色格式转换、混合、填充等操作。把刷屏这件事交给 DMA2DCPU 就能腾出来去处理 LVGL 的下一帧渲染逻辑这就是高帧率的关键。但这里有个设计上的取舍需要想明白DMA2D 传输是异步的你启动一次传输后它自己在后台干活干完了通过中断通知你。如果 LVGL 在 DMA2D 还没传完的时候就往同一个缓冲区写下一帧数据那画面必然出问题。所以整个方案的核心就是设计一套可靠的同步机制让 LVGL 的渲染和 DMA2D 的传输像接力赛一样有序进行既不互相踩踏又不浪费时间空等。我最终采用的方案是双缓冲加中断回调的组合。LVGL 配置两个绘制缓冲区DMA2D 传输缓冲区 A 的时候LVGL 可以往缓冲区 B 里渲染下一帧。DMA2D 传输完成中断触发后在中断回调里通知 LVGL 可以切换缓冲区了。这样 CPU 和 DMA2D 真正实现了并行工作帧率提升非常明显。下面把每个环节拆开讲透。2. 核心细节解析与实操要点2.1 DMA2D 的工作模式选择与配置要点DMA2D 支持几种工作模式用哪个直接决定了你的刷屏效率。最常用的是寄存器到存储器模式R2M和存储器到存储器模式M2M。R2M 模式适合纯色填充比如清屏或者画背景色速度极快。M2M 模式适合把 LVGL 渲染好的缓冲区数据搬到显示区域支持颜色格式转换这是刷屏的主力模式。配置 DMA2D 的时候有几个参数必须搞清楚。首先是输出颜色格式要和你的屏幕接口匹配。RGB565 屏幕就配DMA2D_OUTPUT_RGB565RGB888 就配对应的格式。输入格式要和 LVGL 的颜色深度一致LVGL 默认是 16 位色深对应 RGB565。如果两边格式不匹配DMA2D 会帮你做转换但这会额外消耗时间能统一就统一。其次是行偏移和行数。DMA2D 的传输是按行组织的你需要告诉它每一行的像素数、目标区域在屏幕上的起始坐标、以及总共传多少行。这里有个容易翻车的地方LVGL 的缓冲区大小和屏幕宽度不一定相等。比如屏幕 480 宽但 LVGL 的缓冲区可能只覆盖屏幕的一部分区域局部刷新这时候行偏移的计算就要特别小心算错了画面就会错位。注意DMA2D 的寄存器配置必须在启动传输前一次性写好传输过程中改寄存器会导致不可预期的行为。我习惯把配置封装成一个函数每次刷屏时传入起始坐标和尺寸函数内部完成所有寄存器赋值再启动。还有一个细节是中断优先级。DMA2D 的传输完成中断优先级不能设得太低否则会被其他中断频繁打断影响刷屏节奏。但也不能设得比系统滴答定时器还高不然会影响 LVGL 的时基。我一般把它设在中等偏上的位置具体数值要根据你系统里其他中断的分布来调。2.2 LVGL 刷新回调与 DMA2D 的对接方式LVGL 的显示驱动核心是flush_cb这个回调函数。当 LVGL 渲染完一块区域后会调用这个回调把区域坐标和颜色数据指针传给你。你要做的就是在回调里启动 DMA2D 传输把数据搬到屏幕上。关键点在于回调返回的时机。如果你在flush_cb里启动 DMA2D 后立刻返回LVGL 会认为刷屏已经完成马上开始渲染下一帧。但此时 DMA2D 还在传输如果它用的是同一个缓冲区下一帧的渲染就会覆盖正在传输的数据。所以flush_cb里必须等 DMA2D 传输完成才能返回或者用双缓冲机制让 LVGL 渲染到另一个缓冲区。我采用的是后者。具体做法是在flush_cb里调用lv_disp_flush_ready之前先判断当前使用的缓冲区是否正在被 DMA2D 使用。如果是就等中断回调里置位的标志如果不是直接启动传输并返回。这样在大多数情况下 LVGL 都能立刻返回去渲染下一帧只有在缓冲区冲突时才会短暂等待。void my_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { // 等待上一次 DMA2D 传输完成如果还在进行 while (dma2d_busy) { // 可以在这里喂狗或者做低功耗处理 } dma2d_busy 1; // 配置 DMA2D 传输参数 DMA2D-FGMAR (uint32_t)color_p; DMA2D-OMAR (uint32_t)LCD_FRAME_BUFFER[area-y1 * LCD_WIDTH area-x1]; DMA2D-NLR ((area-x2 - area-x1 1) 16) | (area-y2 - area-y1 1); DMA2D-CR DMA2D_M2M | DMA2D_IT_TC; DMA2D-CR | DMA2D_START; // 注意这里不能调用 lv_disp_flush_ready // 要等 DMA2D 中断回调里再调用 }上面这段代码展示了一个简化的对接逻辑。注意lv_disp_flush_ready的调用时机很关键它告诉 LVGL“这块区域我刷完了你可以继续了”。如果调用早了LVGL 会在 DMA2D 还没传完时就复用缓冲区调用晚了LVGL 会一直等帧率上不去。我的做法是在 DMA2D 的传输完成中断里调用它这样时机刚刚好。2.3 中断服务程序的设计与优化DMA2D 的传输完成中断服务程序要做的事情很明确清除中断标志、置位缓冲区空闲标志、调用lv_disp_flush_ready。但就是这几件事写法不同性能差异很大。首先中断里不要做耗时操作。我见过有人在 DMA2D 中断里直接启动下一次传输结果因为中断嵌套和优先级问题导致系统不稳定。正确的做法是在中断里只做最必要的状态更新把后续处理留给主循环或者 LVGL 的任务处理器。其次标志位的读写要注意原子性。dma2d_busy这个标志在主循环和中断里都会被访问如果编译器优化不当或者读写时序有问题可能会出现标志位状态不一致的情况。我一般把它声明为volatile并且在修改时用关中断的方式保护。void DMA2D_IRQHandler(void) { if (DMA2D-ISR DMA2D_FLAG_TC) { DMA2D-IFCR DMA2D_FLAG_TC; // 清除标志 dma2d_busy 0; // 置位空闲标志 lv_disp_flush_ready(disp_drv); // 通知 LVGL } }这段代码看起来简单但有几个细节值得说。DMA2D_FLAG_TC是传输完成标志清除它必须写IFCR寄存器写ISR是没用的。lv_disp_flush_ready的调用必须在清除标志之后否则可能触发重复中断。另外如果你的系统里还有其他地方会操作 DMA2D记得在中断里加个判断确保这次中断确实是刷屏操作触发的。实操心得我一开始把lv_disp_flush_ready放在flush_cb里调用结果帧率只有 30 多。后来改到中断里调用直接飙到 60 帧满帧。原因就是前者让 LVGL 在 DMA2D 传输期间空等后者让两者真正并行起来了。3. 实操过程与核心环节实现3.1 硬件与软件环境搭建先说一下我用的硬件配置方便你对照参考。主控是 STM32F429IGT6自带 DMA2D 外设和 LTDC 液晶控制器。屏幕是一块 480×272 的 RGB565 屏通过 LTDC 接口驱动。开发环境是 Keil MDK 5.38LVGL 版本是 8.3。如果你用的是 F7 或者 H7 系列DMA2D 的用法基本一致只是寄存器地址和中断向量号不同。软件层面需要准备的东西不多STM32CubeMX 用来生成初始化代码LVGL 源码直接拖进工程再配一个显示驱动文件。CubeMX 里要记得使能 DMA2D 外设和对应的中断LTDC 也要配好时序参数。LTDC 的时序配置根据屏幕手册来每个屏幕不一样这里不展开。LVGL 的移植主要改三个地方lv_conf.h里打开显示相关的宏配置颜色深度和缓冲区大小实现flush_cb回调在 main 函数里注册显示驱动并启动 LVGL 的任务处理器。这些步骤网上教程很多我重点讲和 DMA2D 配合的部分。3.2 双缓冲机制的实现细节双缓冲是这套方案的核心。LVGL 支持配置一个或两个绘制缓冲区配两个的时候它会自动在缓冲区之间切换。但要注意LVGL 的双缓冲和 DMA2D 的双缓冲是两回事需要配合使用。我的做法是分配两个和屏幕分辨率等大的缓冲区在lv_disp_drv_t里注册为 LVGL 的绘制缓冲区。LVGL 渲染完一帧后会调用flush_cb我在回调里启动 DMA2D 把当前缓冲区传到屏幕。同时LVGL 因为有两个缓冲区可用会立刻开始在另一个缓冲区里渲染下一帧。DMA2D 传完后触发中断中断里通知 LVGL 当前缓冲区已空闲。这里有个内存分配的坑要提醒。两个 480×272 的 RGB565 缓冲区每个占 480×272×2 261KB两个就是 522KB。F429 有 256KB 的 SRAM 和 64KB 的 CCM加起来不够用。所以我把缓冲区分配到了外部 SDRAM 里。如果你也用外部 SDRAM记得配置好 FMC 控制器并且把 SDRAM 的地址映射到正确的区域。DMA2D 访问 SDRAM 的速度比访问内部 SRAM 慢一些但实测下来对帧率影响不大因为 DMA2D 是硬件搬运不占 CPU。// 在 SDRAM 中定义双缓冲区 #define LCD_WIDTH 480 #define LCD_HEIGHT 272 #define BUFFER_SIZE (LCD_WIDTH * LCD_HEIGHT) // SDRAM 起始地址根据你的硬件配置来 __attribute__((section(.sdram))) lv_color_t buf1[BUFFER_SIZE]; __attribute__((section(.sdram))) lv_color_t buf2[BUFFER_SIZE]; // 注册到 LVGL static lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(draw_buf, buf1, buf2, BUFFER_SIZE);上面这段代码里__attribute__((section(.sdram)))是 GCC 的语法Keil 里要用__attribute__((at(0xC0000000)))或者直接在分散加载文件里指定。不管用哪种方式核心目的就是把缓冲区放到 DMA2D 能访问到的内存区域。注意 DMA2D 不能访问 CCM 内存如果你把缓冲区放在 CCM 里DMA2D 会报传输错误。3.3 帧率测试与性能数据方案跑通后我做了几组对比测试数据如下。测试场景是一个包含列表滚动和按钮点击的界面用 LVGL 自带的性能计数器统计帧率。方案平均帧率CPU 占用率画面撕裂纯 CPU 刷屏22 fps85%无DMA2D 单缓冲38 fps60%偶发DMA2D 双缓冲 中断60 fps35%无DMA2D 双缓冲 中断 局部刷新60 fps22%无从数据可以看出双缓冲加中断的方案相比纯 CPU 刷屏帧率提升了将近三倍CPU 占用率从 85% 降到了 35%。如果再配合 LVGL 的局部刷新功能只刷新界面上发生变化的区域CPU 占用率还能进一步降到 22%。局部刷新的原理是 LVGL 会记录哪些区域需要重绘只把这些区域的数据传给flush_cbDMA2D 也只传输这些区域大大减少了数据搬运量。不过局部刷新有个前提就是你的界面不能有大面积频繁变化的元素。如果整个屏幕都在动局部刷新反而会增加 LVGL 计算无效区域的负担。我的经验是列表滚动、按钮点击这类场景用局部刷新效果很好全屏动画或者视频播放就别用了。注意事项帧率测试的时候记得把 LVGL 的日志输出关掉串口打印会严重拖慢帧率。我一开始没注意测出来只有 40 帧排查了半天才发现是日志的锅。4. 常见问题与排查技巧实录4.1 画面撕裂与颜色错位问题画面撕裂是 DMA2D 和 LVGL 配合时最常见的问题表现是屏幕上出现一条横向的撕裂线或者上下两半显示的内容不一致。根本原因就是 DMA2D 还在传输上一帧数据的时候LVGL 已经开始往同一个缓冲区写下一帧了。排查这个问题的第一步是确认双缓冲是否真正生效。你可以在flush_cb里打印当前使用的缓冲区地址看看 LVGL 是否在交替使用两个缓冲区。如果发现它一直用同一个那说明双缓冲配置有问题检查lv_disp_draw_buf_init的第二个参数是不是传了 NULL。如果双缓冲配置没问题但还是撕裂那就是同步机制有漏洞。重点检查lv_disp_flush_ready的调用时机确保它是在 DMA2D 传输完成中断里调用的而不是在flush_cb里。另外dma2d_busy标志的读写要加保护我遇到过因为编译器优化导致标志位读取顺序错乱的情况加volatile关键字后问题消失。颜色错位通常是格式不匹配导致的。检查三个地方的格式设置LVGL 的LV_COLOR_DEPTH、DMA2D 的输入输出格式、LTDC 的像素格式。这三个必须一致有一个不对就会偏色。RGB565 和 RGB888 之间的转换虽然 DMA2D 支持但转换过程会引入额外的延迟能统一就统一。4.2 DMA2D 传输中断不触发的排查中断不触发这个问题我遇到过两次一次是中断向量表没配好一次是中断标志没清除干净。排查思路如下。先确认 DMA2D 的中断使能位有没有打开。在 CubeMX 里配置的时候要勾选 DMA2D 的全局中断生成的代码里会有HAL_NVIC_EnableIRQ(DMA2D_IRQn)这一句。如果你是自己写的初始化代码记得手动调用NVIC_EnableIRQ。然后检查中断服务函数的名称是否和启动文件里的向量表一致。STM32 的中断服务函数名是固定的比如 DMA2D 的就是DMA2D_IRQHandler写错了就不会被调用。这个错误很隐蔽编译不会报错但中断就是进不去。最后检查中断标志的清除方式。DMA2D 的传输完成标志在ISR寄存器里清除要写IFCR寄存器。如果你只读不写标志会一直置位中断会反复触发看起来像是“中断不触发”实际上是“中断触发太频繁导致系统卡死”。用调试器看一下ISR寄存器的值就能确认。4.3 帧率上不去的性能瓶颈定位帧率上不去的原因有很多我整理了一个排查表按可能性从高到低排列。可能原因排查方法解决方法缓冲区在 CCM 内存查看链接文件中的地址分配把缓冲区移到 SRAM 或 SDRAM颜色格式不匹配对比 LVGL 和 DMA2D 的格式配置统一为 RGB565中断优先级冲突查看 NVIC 配置调整 DMA2D 中断优先级局部刷新未启用检查LV_INDEV_DEF_READ_PERIOD启用LV_USE_PERF_MONITOR观察SDRAM 带宽不足用示波器测 SDRAM 时钟提高 SDRAM 时钟或优化访问模式LVGL 任务处理不及时在lv_timer_handler里加计时增加任务处理频率或减少界面复杂度这个表里的每一项我都实际遇到过。最常见的是缓冲区放错内存区域DMA2D 访问 CCM 会直接报传输错误但如果你没开错误中断可能只是表现为帧率极低而不是报错。其次是颜色格式不匹配DMA2D 做格式转换会消耗额外的时间虽然不多但积少成多。还有一个容易被忽略的点是LVGL 的任务处理频率。LVGL 需要一个定时器定期调用lv_timer_handler来处理界面逻辑和刷新请求。如果这个调用间隔太长LVGL 的刷新请求就会堆积帧率自然上不去。我一般把它放在主循环里不加延时让它尽可能频繁地执行。如果主循环里还有其他耗时任务可以考虑把 LVGL 的处理放到一个独立的 FreeRTOS 任务里优先级设高一些。独家避坑技巧调试帧率问题的时候我习惯先用一个纯色填充的测试界面跑一遍。如果纯色填充能跑满帧说明 DMA2D 和中断机制没问题瓶颈在 LVGL 的渲染逻辑上。如果纯色填充也跑不满那就是底层配置有问题。这个方法能快速定位问题是在上层还是下层。5. 进阶优化与扩展思路5.1 局部刷新与脏矩形合并LVGL 的局部刷新功能是提升帧率的利器但要用好需要理解它的工作原理。LVGL 内部维护一个“脏矩形”列表记录哪些区域需要重绘。每次刷新时它会把脏矩形合并成若干个矩形区域逐个调用flush_cb。如果你的界面只有小部分区域在变化局部刷新能大幅减少 DMA2D 的传输量。但脏矩形的合并是有开销的。如果界面上有很多分散的小变化区域LVGL 合并出来的矩形可能覆盖了大部分屏幕这时候局部刷新反而比全屏刷新更慢。我的经验是列表滚动、按钮点击这类场景用局部刷新效果很好全屏动画或者视频播放就别用了。启用局部刷新的方法是在lv_conf.h里把LV_DISP_DEF_REFR_PERIOD设小一些比如 16 毫秒然后在显示驱动里正确实现flush_cb的区域参数处理。注意flush_cb收到的area参数是相对于屏幕的绝对坐标DMA2D 的目标地址计算要用这个坐标不能用缓冲区的相对坐标。5.2 与 FreeRTOS 的协同调度如果你的项目里还跑了 FreeRTOS那 LVGL 的任务处理和 DMA2D 的中断处理需要仔细安排优先级。我的做法是创建一个专门的 LVGL 任务优先级设为中等任务里循环调用lv_timer_handler每次调用后延时 5 毫秒让出 CPU。DMA2D 的中断优先级设得比 LVGL 任务高确保传输完成能及时响应。这里有个坑要注意FreeRTOS 的中断服务程序里不能直接调用lv_disp_flush_ready因为这个函数内部可能会操作 LVGL 的数据结构而 LVGL 不是线程安全的。正确的做法是在中断里用一个信号量或者任务通知来唤醒 LVGL 任务让任务去调用lv_disp_flush_ready。这样虽然多了一次任务切换的开销但保证了线程安全。// 在 DMA2D 中断里 void DMA2D_IRQHandler(void) { if (DMA2D-ISR DMA2D_FLAG_TC) { DMA2D-IFCR DMA2D_FLAG_TC; BaseType_t xHigherPriorityTaskWoken pdFALSE; vTaskNotifyGiveFromISR(lvgl_task_handle, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } // 在 LVGL 任务里 void lvgl_task(void *pvParameters) { while (1) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); lv_disp_flush_ready(disp_drv); lv_timer_handler(); } }上面这段代码展示了 FreeRTOS 环境下的正确做法。中断里只发任务通知实际的处理在任务里做。这样既保证了实时性又避免了线程安全问题。5.3 不同 STM32 系列的适配要点这套方案在 F429 上跑通后我又在 F767 和 H743 上做了移植发现几个系列之间的差异需要注意。F429 的 DMA2D 是初代版本功能相对简单只支持基本的 M2M 和 R2M 模式。F767 的 DMA2D 增加了混合模式和颜色查找表功能配置寄存器略有不同。H743 的 DMA2D 性能最强支持更高的像素时钟和更大的传输块但中断向量号和 F 系列不一样移植时要改启动文件里的中断服务函数名。另外H7 系列的 Cache 机制是个大坑。DMA2D 访问的内存如果被 Cache 缓存了可能会出现数据不一致的问题。解决方法是在 DMA2D 传输前后调用 Cache 维护函数或者把缓冲区所在的 MPU 区域配置为 Write-Through 模式。这个问题在 F 系列上不存在因为 F 系列没有 Cache。实操心得从 F 系列移植到 H 系列的时候我因为没处理 Cache 问题调试了整整两天。现象是画面偶尔出现几条花屏重启后又正常非常随机。后来用 MPU 把 SDRAM 区域配成非缓存模式才彻底解决。如果你用 H7 跑这套方案记得第一时间处理 Cache 问题。6. 实际项目中的经验沉淀这套方案我在三个量产项目里用过累计出货量大概几万套稳定性没问题。但有几个经验是文档里不会写的这里分享一下。第一个是关于缓冲区对齐。DMA2D 的传输效率对地址对齐很敏感如果缓冲区起始地址不是 4 字节对齐的传输速度会明显下降。我一般用__attribute__((aligned(4)))强制对齐或者直接在链接文件里把缓冲区段配置为对齐。这个细节很小但对帧率的影响实测有 5 到 8 帧的差距。第二个是关于中断频率。DMA2D 每传完一帧就触发一次中断60 帧就是每秒 60 次中断。这个频率不算高但如果你的系统里还有其他高频中断就要注意中断嵌套的深度。我遇到过因为中断嵌套太深导致栈溢出的情况后来把 DMA2D 的中断优先级调低了一级问题解决。第三个是关于LVGL 版本兼容性。LVGL 8.x 和 9.x 的显示驱动接口有变化9.x 把flush_cb的参数改成了lv_display_t类型注册方式也不一样。如果你用的是 9.x上面的代码需要做相应调整。核心思路不变还是双缓冲加中断同步只是 API 调用方式变了。最后说一个调试技巧。如果你怀疑 DMA2D 传输有问题可以用一个简单的方法验证在flush_cb里把缓冲区的前几个像素值打印出来然后在 DMA2D 传输完成后读一下屏幕对应位置的实际像素值对比是否一致。如果不一致说明传输过程中数据被篡改了重点检查缓冲区的内存保护和 Cache 配置。这个方法比用逻辑分析仪抓波形简单得多适合快速定位问题。这套方案的核心思想其实很简单让 CPU 和 DMA2D 各干各的通过中断和双缓冲来同步。但要把细节都处理对需要对这些外设的工作原理有比较深的理解。我见过很多人移植 LVGL 的时候只关注界面效果忽略了底层刷屏机制结果界面是能跑但帧率惨不忍睹。如果你正在做类似的项目建议先把 DMA2D 和中断这块吃透后面的界面开发会顺畅很多。
返回列表