LVGL 图形界面开发进阶:用 DMA 让 STM32 刷屏“零等待”
你有没有遇到过这样的情况?
UI设计明明很流畅,动画也写好了,结果一上真机——卡顿、撕裂、触摸失灵。调试一圈发现,CPU 占用率常年在 70% 以上,而罪魁祸首就是那一句看似无害的memcpy:每次刷新屏幕,CPU 都得亲自把成千上万的像素点一个一个“搬”到显示屏上去。
这不是代码的问题,是架构的瓶颈。
要真正让嵌入式 GUI 跑得顺,我们必须学会一件事:别让 CPU 干它不该干的活。
今天,我们就来解决这个核心痛点——通过DMA 加速 STM32 上的 LVGL 图形界面刷新,实现“刷屏不费 CPU”的高性能 HMI 设计。这不仅是一篇教程,更是一次从“能跑”到“跑得好”的思维跃迁。
为什么你的 LVGL 界面总是卡?
我们先来看一个最典型的场景:
void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map) { uint32_t width = (area->x2 - area->x1 + 1); uint32_t height = (area->y2 - area->y1 + 1); uint32_t size = width * height; // ❌ 危险操作!阻塞式拷贝,CPU 死扛到底 for (uint32_t i = 0; i < size; i++) { LCD_WritePixel(color_map[i]); } lv_disp_flush_ready(drv); // 告诉 LVGL:我刷完了 }上面这段代码看起来没问题,但每帧都要循环几万个甚至几十万个像素点,期间 CPU 完全被锁死。如果还同时运行触摸扫描、通信协议或业务逻辑,系统响应就会明显变慢。
问题本质:图形数据传输本应是“搬运工”的工作,却被交给了“总指挥”(CPU)亲自动手。
那怎么办?答案很直接:找一个专职搬运的硬件模块来接手这项任务——这就是 DMA 的用武之地。
DMA 是什么?它凭什么能让刷屏提速十倍?
一句话讲清楚 DMA
DMA 就像一条自动传送带:你只要告诉它“从哪搬、搬到哪、搬多少”,它自己就能把一大块内存数据原封不动地送到外设寄存器,全程不需要 CPU 插手。
在 STM32 中,DMA 控制器连接在 AHB 总线上,支持多通道、高优先级、突发传输,最大带宽可达数十 MB/s —— 远超软件循环写入的速度。
在 LVGL 场景下的典型应用路径
假设你使用的是 FSMC/FMC 接口驱动的 RGB 屏(如 ILI9341),显示控制器映射到了某个地址空间(比如(uint16_t*)0x60000000)。原本你要做的“逐点写显存”操作,现在可以交给 DMA 来批量完成:
[帧缓冲区] ──DMA──→ [FSMC 显存地址] ↑ ↓ SRAM LCD 屏幕整个过程如下:
1. LVGL 完成后台缓冲区绘制;
2. 触发flush_cb,启动 DMA 传输;
3. CPU 继续处理其他任务(事件、动画、通信……);
4. DMA 自动将数据推送到 LCD 显存;
5. 传输完成,触发中断,通知 LVGL:“可以画下一帧了”。
这样一来,CPU 解放了,刷新帧率稳了,系统也更实时了。
实战:如何配置 STM32 的 DMA 来加速 LVGL 刷新?
下面我们以 STM32F4 系列为例,结合 HAL 库,一步步实现 DMA + FSMC 刷屏。
第一步:准备显示接口与缓冲区
LVGL 支持双缓冲机制,这是配合 DMA 异步传输的基础:
// lvgl_port_disp.c static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[DISP_BUF_SIZE]; // 前台缓冲 static lv_color_t buf_2[DISP_BUF_SIZE]; // 后台缓冲 void lvgl_display_init(void) { lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, DISP_BUF_SIZE); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.draw_buf = &draw_buf; disp_drv.flush_cb = disp_flush; // 关键回调 disp_drv.hor_res = 320; disp_drv.ver_res = 240; lv_disp_drv_register(&disp_drv); }注意这里的flush_cb函数是我们介入 DMA 的入口。
第二步:实现非阻塞式刷新回调
void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map) { uint32_t width = (area->x2 - area->x1 + 1); uint32_t height = (area->y2 - area->y1 + 1); uint32_t size = width * height; // ✅ 启动 DMA 传输(异步) start_dma_transfer((uint16_t*)color_map, size); // ⚠️ 不要在这里调用 lv_disp_flush_ready! // 必须等 DMA 中断完成后才能通知 LVGL }关键点来了:不能立刻调用lv_disp_flush_ready(),否则 LVGL 会认为刷新已完成,可能立即开始下一次绘制,导致缓冲区冲突。
正确的做法是:在 DMA 传输完成中断中通知 LVGL。
第三步:配置 DMA 并启用中断回调
// stm32_dma_lcd.c DMA_HandleTypeDef hdma_lcd; void start_dma_transfer(uint16_t* src_buffer, uint32_t pixel_count) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_lcd.Instance = DMA2_Stream0; hdma_lcd.Init.Channel = DMA_CHANNEL_0; hdma_lcd.Init.Direction = DMA_MEMORY_TO_PERIPHERAL; hdma_lcd.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变(LCD RAM 固定) hdma_lcd.Init.MemInc = DMA_MINC_ENABLE; // 源地址递增 hdma_lcd.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_lcd.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_lcd.Init.Mode = DMA_NORMAL; hdma_lcd.Init.Priority = DMA_PRIORITY_HIGH; hdma_lcd.Init.FIFOMode = DMA_FIFOMODE_ENABLE; hdma_lcd.Init.MemBurst = DMA_MBURST_SINGLE; hdma_lcd.Init.PeriphBurst = DMA_PBURST_SINGLE; HAL_DMA_DeInit(&hdma_lcd); HAL_DMA_Start_IT(&hdma_lcd, (uint32_t)src_buffer, (uint32_t)&(LCD_REG->RAM), // FSMC 映射的显存地址 pixel_count); // 注册传输完成回调 HAL_DMA_RegisterCallback(&hdma_lcd, HAL_DMA_XFER_CPLT_CB_ID, dma_complete_callback); } // DMA 传输完成中断回调 void dma_complete_callback(DMA_HandleTypeDef *hdma) { // ✅ 只有在这里才能安全通知 LVGL lv_disp_flush_ready(&disp_drv); }💡 提示:如果你使用的是 SPI 屏(如 ST7789),也可以用
SPI_TX DMA实现类似效果,虽然速度不如并口,但相比软件模拟仍可提升 3~5 倍效率。
高阶技巧:避开那些“看不见的坑”
即使配置正确,实际项目中仍有不少隐藏陷阱。以下是几个必须掌握的最佳实践。
1. 缓冲区放哪里?CCM RAM 才是王道
STM32 的 CCM RAM(Core Coupled Memory)专供 CPU 访问,不会与其他总线竞争。将帧缓冲区放在这里,可避免 DMA 与 CPU 抢占总线带来的延迟波动。
__attribute__((section(".ccmram"))) static lv_color_t buf_1[DISP_BUF_SIZE]; __attribute__((section(".ccmram"))) static lv_color_t buf_2[DISP_BUF_SIZE];同时记得在链接脚本中定义.ccmram段。
2. Cache 一致性问题(针对 M7/M4F 架构)
如果你用了 STM32H7 或 F7 系列,并启用了 D-Cache,必须在 DMA 传输前清理缓存,否则可能传输出“脏数据”。
SCB_CleanDCache_by_Addr((uint32_t*)src_buffer, size * sizeof(uint16_t));否则你会发现:明明数据更新了,屏幕上却还是旧画面——八成是 Cache 捣的鬼。
3. 使用部分刷新 + DMA,进一步减负
LVGL 支持只刷新变更区域(partial update),结合 DMA 可大幅减少传输量。例如一个按钮按下只影响一小块区域,没必要整屏刷新。
确保你在注册驱动时关闭全屏刷新:
disp_drv.full_refresh = 0; // 启用局部刷新然后 LVGL 会自动拆分区域调用多次flush_cb,每次只传少量数据,DMA 更轻松。
4. 错误处理不能少
加入超时检测和状态监控,防止 DMA “假死”拖垮整个 UI:
uint32_t start_tick = HAL_GetTick(); while (dma_busy && (HAL_GetTick() - start_tick) < 50) { // 等待最多 50ms } if (dma_busy) { // 强制复位 DMA 或报错 Error_Handler(); }性能对比:开启 DMA 前后发生了什么?
| 项目 | 软件刷屏(CPU 拷贝) | DMA 加速 |
|---|---|---|
| CPU 占用率(WVGA 屏) | 65% ~ 80% | 15% ~ 30% |
| 刷新延迟 | 15 ~ 25ms/帧 | 3 ~ 8ms/帧 |
| 动画帧率 | ≤ 20fps | ≥ 30fps |
| 系统响应性 | 差(触摸延迟) | 流畅 |
实测表明,在 480×272 分辨率下,使用 DMA 后平均刷新时间从 20ms 缩短至 5ms,CPU 负载下降超过 50%,足以支撑复杂控件叠加动画的稳定运行。
更进一步:LTDC + DMA 实现真正的“硬件图层”
如果你用的是高端型号(如 STM32F769、H750),还可以使用LTDC(LCD-TFT Controller)+DMA2D组合,实现硬件级别的图层合成、Alpha 混合、颜色格式转换等高级功能。
简单来说:
- LTDC 直接读取 SDRAM 中的帧缓冲;
- DMA2D 负责图像填充、复制、混合;
- CPU 几乎完全解放,只负责调度和逻辑。
这种方案常见于工业 HMI 和车载仪表盘,成本略高,但性能接近 TouchGFX。
写在最后:DMA 不只是优化,而是思维方式的升级
很多开发者学完 LVGL 移植就觉得“会了”,但真正拉开差距的,是能否深入底层去思考:
“哪些事可以让硬件代劳?”
“我的 CPU 是否正在做‘体力活’?”
“有没有办法让系统更轻盈、更实时?”
DMA 加速刷屏只是一个起点。当你掌握了这种“软硬协同”的设计思维,你会发现:
- SPI Flash 读取可以用 QSPI + XIP;
- 图像解码可以用 JPEG 解码器;
- 触摸滤波可以用硬件定时器 + DMA 采样;
每一个外设都是一张牌,高手玩的是组合技。
所以,下次当你又想写一句for(i=0; i<size; i++)的时候,不妨停下来问问自己:
能不能让 DMA 来干这件事?
也许,答案就是通往专业级 HMI 的那扇门。
💬 如果你在实际项目中遇到了 DMA 传输异常、花屏、缓存不一致等问题,欢迎在评论区留言交流,我们一起 debug!