3. 显示驱动优化:帧缓冲配置、双缓冲与三缓冲、DMA加速、刷新率调优
显示驱动,说白了就是LVGL和硬件屏幕之间的那座桥。桥修得不好,数据就堵在路上,帧率自然上不去。我见过太多项目,CPU跑得飞起,内存也够大,但UI就是卡顿——问题十有八九出在显示驱动这一层。
这一章,咱们就把这座桥彻底修通。从最基础的帧缓冲配置,到双缓冲、三缓冲的取舍,再到DMA加速和刷新率调优,一步步来。
核心观点:显示驱动优化的目标只有一个——让LVGL的绘制操作和屏幕的刷新操作互不等待,流水线化运作。
3.1 帧缓冲配置:选对大小和位置
帧缓冲,就是屏幕上一帧画面的内存映像。LVGL绘制的时候,往这块内存里写像素数据;屏幕控制器刷新的时候,从这块内存里读像素数据。
配置帧缓冲,核心就两个问题:多大?放哪?
3.1.1 缓冲大小怎么算?
公式很简单:宽度 × 高度 × 每像素字节数。
举个例子,一块 480×272 的屏幕,颜色深度 16位(RGB565),那么一帧就是:
480 × 272 × 2 = 261,120 字节 ≈ 255 KB
嗯,看起来不大。但如果你用 32位色(ARGB8888),同样分辨率就变成:
480 × 272 × 4 = 522,240 字节 ≈ 510 KB
翻了一倍。我有个项目,一开始图省事用了 ARGB8888,结果内存吃紧,后来改成 RGB565,帧率直接提升了 30%。
我的建议:除非你的UI需要半透明效果或高精度色彩,否则优先用 RGB565。省内存、省带宽,而且大多数屏幕本身也是 16位接口。
3.1.2 缓冲放哪?内部RAM还是外部SDRAM?
这取决于你的硬件平台。我列个对比表,你一看就明白:
| 存储类型 | 访问速度 | 容量 | 适用场景 |
|---|---|---|---|
| 内部 SRAM | 极快(零等待) | 通常 64KB~512KB | 小分辨率屏幕、单缓冲 |
| 外部 SDRAM | 较快(有等待周期) | 通常 8MB~64MB | 大分辨率屏幕、双缓冲/三缓冲 |
| 内部 DTCM | 最快(CPU直连) | 通常 64KB~128KB | 部分帧缓冲、关键数据 |
我个人习惯:能放内部RAM就放内部RAM。但如果你要做双缓冲,480×272 的 RGB565 就需要 510KB,很多MCU的内部RAM根本不够。这时候只能上外部SDRAM。
我曾经踩过一个坑:把帧缓冲放在外部SDRAM,但没开Cache。结果每次绘制都要通过总线去读SDRAM,速度慢得离谱。后来开了Cache,并且把帧缓冲所在的区域配置为“写通(Write-Through)”模式,性能才正常。记住:外部SDRAM一定要配合Cache使用,但要注意Cache一致性。
3.2 双缓冲与三缓冲:告别撕裂
单缓冲的问题很明显:LVGL在往缓冲里写数据的时候,屏幕控制器也在从同一个缓冲里读数据。你想想看,如果写到一半屏幕刷新了,画面上半部分是旧帧,下半部分是新帧——这就是撕裂(Tearing)。
怎么解决?加缓冲。
3.2.1 双缓冲(Double Buffering)
两个缓冲:一个叫前台缓冲(Front Buffer),屏幕正在读它;一个叫后台缓冲(Back Buffer),LVGL正在往它里面画。
绘制完成后,交换指针。屏幕下一帧就从新的前台缓冲读,整个过程无缝衔接。
LVGL里配置双缓冲很简单:
static lv_color_t buf_1[MY_DISP_HOR_RES * MY_DISP_VER_RES];
static lv_color_t buf_2[MY_DISP_HOR_RES * MY_DISP_VER_RES];
lv_disp_draw_buf_init(&draw_buf_dsc, buf_1, buf_2, MY_DISP_HOR_RES * MY_DISP_VER_RES);
lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.draw_buf = &draw_buf_dsc;
disp_drv.flush_cb = my_flush_callback;
lv_disp_drv_register(&disp_drv);
注意看:lv_disp_draw_buf_init 的第二个和第三个参数分别传了两个缓冲的地址。LVGL会自动在它们之间切换。
关键点:双缓冲消除了撕裂,但代价是内存翻倍。如果你的内存够用,双缓冲是性价比最高的选择。
3.2.2 三缓冲(Triple Buffering)
三缓冲比双缓冲多了一个缓冲。为什么需要三个?
你想想看,双缓冲虽然解决了撕裂,但有一个隐藏问题:如果LVGL绘制一帧的时间超过了屏幕刷新一帧的时间,那LVGL就得等——等屏幕读完当前帧,才能开始画下一帧。这个等待时间就是性能浪费。
三缓冲的玩法是:
- 屏幕在读缓冲 A
- LVGL在画缓冲 B
- 缓冲 C 空闲,随时准备接手
这样一来,LVGL几乎不用等待。画完 B 就切到 C,如果 C 还没被屏幕读完,那就继续画别的?不对,LVGL会等,但等待的概率大大降低了。
三缓冲的配置和双缓冲类似,只是多传一个缓冲地址:
static lv_color_t buf_1[MY_DISP_HOR_RES * MY_DISP_VER_RES];
static lv_color_t buf_2[MY_DISP_HOR_RES * MY_DISP_VER_RES];
static lv_color_t buf_3[MY_DISP_HOR_RES * MY_DISP_VER_RES];
lv_disp_draw_buf_init(&draw_buf_dsc, buf_1, buf_2, buf_3, MY_DISP_HOR_RES * MY_DISP_VER_RES);
我的经验:三缓冲适合帧率要求极高(比如 60fps 以上)或者绘制负载波动很大的场景。但代价是内存占用再翻 1.5 倍。如果你的内存捉襟见肘,双缓冲已经够用了。
3.3 DMA加速:让CPU喘口气
CPU最怕什么?最怕搬数据。
你想想看,LVGL画完一帧,要把数据从帧缓冲搬到屏幕的显存或者接口上。如果这个搬运工作让CPU来做,那CPU就被占死了,没法干别的。
DMA(Direct Memory Access)就是专门干这个的。它可以在不占用CPU的情况下,把数据从一块内存搬到另一块内存,或者从内存搬到外设。
3.3.1 在刷新回调里用DMA
LVGL的刷新回调 flush_cb 是触发DMA的最佳位置:
static void my_flush_callback(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
// 启动DMA传输,把 color_p 指向的数据搬到屏幕接口
dma_transfer(screen_buffer, color_p, area_width * area_height * sizeof(lv_color_t));
// 注意:这里不能直接调用 lv_disp_flush_ready()
// 因为DMA还没传完,需要等DMA完成中断里再调用
}
// DMA传输完成中断
void dma_transfer_complete_irq(void)
{
lv_disp_flush_ready(disp_drv);
}
这个模式叫异步刷新。LVGL启动DMA后立刻返回,继续绘制下一帧。等DMA传完了,中断里通知LVGL“我搞定了”。
我曾经犯过一个错误:在DMA传输还没完成的时候就调用了 lv_disp_flush_ready(),结果屏幕显示花屏。因为LVGL以为缓冲已经空了,开始往里面写新数据,但DMA还在读旧数据。记住:一定要等DMA彻底完成后才通知LVGL。
3.3.2 DMA2D:专为图形优化的DMA
有些MCU(比如STM32系列)有专门的 DMA2D 控制器,不仅能搬数据,还能做像素格式转换、颜色填充、图像混合等操作。
举个例子,用DMA2D填充一个矩形区域:
void fill_rect_with_dma2d(lv_color_t * buf, lv_coord_t width, lv_coord_t height, lv_color_t color)
{
// 配置DMA2D为寄存器到内存模式(填充)
DMA2D->CR = DMA2D_CR_MODE_0; // 寄存器到内存
DMA2D->OOR = 0; // 输出偏移
DMA2D->OMAR = (uint32_t)buf; // 输出内存地址
DMA2D->OCOLR = color.full; // 填充颜色
DMA2D->NLR = (uint32_t)(height << 16) | width; // 行数 | 列数
// 启动
DMA2D->CR |= DMA2D_CR_START;
// 等待完成
while(DMA2D->CR & DMA2D_CR_START);
}
用DMA2D做填充,比CPU用循环写快 5~10 倍。我有个项目,UI里大量使用纯色背景和按钮,用DMA2D填充后,帧率从 25fps 飙到了 55fps。
核心思路:凡是涉及大量像素搬运或填充的操作,都优先交给DMA/DMA2D。CPU只负责逻辑和绘制命令,不做数据搬运工。
3.4 刷新率调优:找到最佳平衡点
刷新率不是越高越好。你想想看,屏幕刷新率 60fps,但LVGL只能画 30fps,那多出来的 30fps 就是在重复显示同一帧,浪费带宽和功耗。
刷新率调优的目标是:让LVGL的绘制帧率和屏幕的刷新帧率匹配。
3.4.1 怎么调?
LVGL里控制刷新率的核心参数是 LV_DISP_DEF_REFR_PERIOD,单位是毫秒。默认值是 30ms,对应约 33fps。
你可以根据实际情况调整:
| 场景 | 推荐刷新周期 | 对应帧率 |
|---|---|---|
| 静态UI(仪表盘、菜单) | 40~50ms | 20~25fps |
| 动态UI(动画、滑动) | 16~30ms | 33~60fps |
| 视频播放 | 16ms | 60fps |
修改方式:
// 在 lv_conf.h 中修改
#define LV_DISP_DEF_REFR_PERIOD 30 // 单位:ms
3.4.2 自适应刷新率
我个人的做法是:不要固定刷新率,而是根据UI的活跃度动态调整。
比如:
- 用户没有操作时,降低刷新率到 20fps,省电
- 用户触摸滑动时,提升到 50fps,保证流畅
- 动画播放时,锁定 60fps
实现方式很简单,在LVGL的 lv_tick_inc() 或者定时器里动态修改 disp_drv.refr_period 即可。
我的经验:大多数嵌入式屏幕的物理刷新率是 60Hz,但LVGL的绘制帧率很难跑满 60fps。与其让屏幕空刷,不如把刷新周期调到 30ms(33fps),这样CPU负载更低,电池更耐用。
3.5 本章小结
显示驱动优化,说白了就是三件事:
- 缓冲配置:选对大小、放对位置、开对Cache
- 缓冲策略:单缓冲够用就用单缓冲,不够就上双缓冲,极致性能上三缓冲
- 传输加速:DMA是标配,DMA2D是利器,别让CPU干搬砖的活
嗯,把这些搞定了,你的LVGL显示驱动就算真正优化到位了。下一层,咱们要深入LVGL的内部渲染机制,看看那些绘制命令是怎么变成像素的。