3、帧缓冲管理:显存分配、双缓冲机制、DMA传输优化

帧缓冲管理,说白了就是给LCD屏幕准备一个「画板」。你画什么,屏幕就显示什么。但这个画板怎么分配、怎么切换、怎么搬运数据,里面门道不少。我这些年调过的屏,至少有一半的闪烁、撕裂、卡顿问题,根源都在帧缓冲管理上。

3.1 显存分配:别让内存成为瓶颈

显存,就是帧缓冲占用的内存区域。分配多少?怎么对齐?用哪块内存?这些看似基础的问题,踩坑的人可不少。

显存大小计算

公式很简单:显存大小 = 分辨率宽度 × 分辨率高度 × 每像素字节数

举个例子:一块480×272的RGB565屏,每像素2字节。那么一帧需要:

480 × 272 × 2 = 261,120 字节 ≈ 255 KB

嗯,这里要注意。很多MCU的DMA控制器要求源地址和目的地址按4字节对齐。如果你分配的内存没对齐,DMA传输会出问题。我个人习惯在分配时多申请几个字节,手动对齐到32字节甚至64字节边界。

避坑指南

我曾经在一个项目里,用malloc直接分配显存,结果DMA传输时断时续。查了两天才发现,malloc返回的地址只按8字节对齐,而我的DMA控制器要求32字节对齐。从那以后,我都是自己实现一个对齐分配函数。

内存区域选择

不同的内存区域,访问速度天差地别:

内存类型 访问速度 适用场景 注意事项
内部SRAM 最快 小尺寸屏、低分辨率 容量有限,通常几十到几百KB
外部SDRAM 较快 大尺寸屏、高分辨率 需要初始化,延迟稍高
PSRAM 中等 中等分辨率、低功耗场景 读写速度不如SDRAM
Flash模拟显存 最慢 静态画面、电子墨水屏 不能频繁写入,寿命有限

你想想看,如果屏是800×480的RGB565,一帧需要750KB。内部SRAM通常只有256KB或512KB,根本放不下。这时候就必须用外部SDRAM。但SDRAM的初始化时序、刷新周期,都是坑。

3.2 双缓冲机制:告别撕裂和闪烁

单缓冲的问题很明显:你在往显存里写数据的同时,LCD控制器也在读同一块显存去刷新屏幕。结果就是——上半屏是新的,下半屏还是旧的。这就是撕裂(tearing)。

双缓冲的工作原理

说白了就是准备两块显存:

  • 前端缓冲(Front Buffer):LCD控制器正在读取并显示的内容
  • 后端缓冲(Back Buffer):CPU/GPU正在绘制的新内容

绘制完成后,交换指针。LCD控制器读取新的前端缓冲,整个过程无缝衔接。

// 双缓冲交换示例
volatile uint16_t *front_buffer = (uint16_t *)0x20000000;
volatile uint16_t *back_buffer  = (uint16_t *)0x20040000;

void swap_buffers(void) {
    // 等待垂直消隐期(VBlank)
    while (!(LCD->SR & LCD_SR_VBLANK));
    
    // 交换缓冲区指针
    LCD->FBAR = (uint32_t)back_buffer;
    
    // 交换逻辑指针
    volatile uint16_t *tmp = front_buffer;
    front_buffer = back_buffer;
    back_buffer = tmp;
}

三缓冲又是怎么回事?

双缓冲有个小问题:如果绘制速度跟不上刷新速度,交换后前端缓冲的内容可能还没绘制完。三缓冲多加了一个缓冲区,让绘制和显示完全解耦。不过代价是内存占用增加50%。我个人觉得,对于手持终端这种资源受限的设备,双缓冲已经够用了。三缓冲更多是用在高端平板或游戏机上。

我的经验

双缓冲交换时,一定要等VBlank信号。我曾经偷懒没等,结果撕裂率从0%直接飙到15%。用户反馈说「屏幕老闪」,其实就是撕裂。加了个等待VBlank的循环,问题立刻解决。

3.3 DMA传输优化:让CPU喘口气

如果每次刷新屏幕都让CPU逐像素搬运数据,那CPU基本干不了别的了。DMA(直接存储器访问)就是干这个的——它自己搬数据,CPU该干嘛干嘛。

DMA传输的关键参数

配置DMA时,这几个参数必须搞清楚:

  • 源地址:显存地址,通常是递增模式
  • 目的地址:LCD控制器的GRAM地址,通常是固定模式
  • 传输宽度:8位、16位还是32位?取决于屏的接口
  • 传输长度:一帧的总字节数
  • 触发方式:软件触发还是硬件自动触发
// DMA传输配置示例(基于STM32 HAL库)
void lcd_dma_start(uint16_t *src_addr, uint32_t size) {
    // 配置DMA
    hdma.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma.Init.PeriphInc = DMA_PINC_DISABLE;      // 外设地址不变
    hdma.Init.MemInc    = DMA_MINC_ENABLE;        // 内存地址递增
    hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;  // 16位
    hdma.Init.MemDataAlignment    = DMA_MDATAALIGN_HALFWORD;
    hdma.Init.Mode = DMA_NORMAL;                  // 单次传输
    
    HAL_DMA_Start(&hdma, (uint32_t)src_addr, (uint32_t)&LCD->DR, size);
}

DMA传输的优化技巧

为什么同样是DMA,有人用起来流畅,有人却卡顿?区别在于细节:

  1. 使用双缓冲DMA:配置两个DMA通道,一个传输当前帧,另一个准备下一帧。交替使用,消除等待时间。
  2. 合理设置优先级:DMA优先级不要太高,否则会阻塞CPU访问内存。我一般设置为中等优先级。
  3. 利用FIFO或突发传输:很多DMA控制器支持FIFO和突发模式,一次传输多个数据,减少总线占用。
  4. 避免DMA中断频繁触发:每帧只触发一次传输完成中断,不要在每行传输完成后都进中断。

注意:DMA与Cache的一致性

如果你的MCU带Cache(比如Cortex-M7),DMA传输的数据可能被Cache缓存,导致LCD读到的是旧数据。解决办法是:在DMA传输前,调用SCB_CleanInvalidateDCache()刷新Cache。我见过有人忘了这步,调试了整整一周。

行中断 vs 帧中断

有些场景下,你需要在特定行位置更新显存内容(比如滚动字幕、分屏显示)。这时候可以用行中断:

// 行中断服务函数
void LCD_LINE_IRQHandler(void) {
    uint16_t current_line = LCD->LCR & 0xFFFF;
    
    if (current_line == 100) {
        // 在第100行更新显存内容
        dma_transfer_partial(back_buffer + 100 * 480, 480 * 50);
    }
}

但行中断频率很高(每秒上万次),处理不当会拖垮系统。我建议能用帧中断就别用行中断,除非你真的需要逐行控制。

3.4 综合实战:一个完整的帧缓冲管理方案

说了这么多,咱们来个实际的。假设你手头有个项目:480×272的RGB565屏,MCU是STM32F429,带LTDC控制器和DMA2D。

方案设计

  1. 显存分配:在外部SDRAM中分配两块256KB的缓冲区,地址按32字节对齐
  2. 双缓冲管理:使用LTDC的硬件双缓冲功能,通过修改LTDC_Layer_CFBAR寄存器切换
  3. DMA传输:使用DMA2D的存储器到存储器模式,配合行中断实现局部刷新
// 初始化显存
#define LCD_WIDTH  480
#define LCD_HEIGHT 272
#define LCD_BPP    2  // RGB565

// 按32字节对齐分配
uint16_t *buffer1 = (uint16_t *)0xD0000000;  // SDRAM基址
uint16_t *buffer2 = (uint16_t *)0xD0040000;  // 偏移256KB

void lcd_fb_init(void) {
    // 配置LTDC层
    LTDC_Layer_InitTypeDef layer_cfg;
    layer_cfg.FBStartAdress = (uint32_t)buffer1;
    layer_cfg.ImageWidth    = LCD_WIDTH;
    layer_cfg.ImageHeight   = LCD_HEIGHT;
    layer_cfg.PixelFormat   = LTDC_PIXEL_FORMAT_RGB565;
    HAL_LTDC_Init(&hltdc, &layer_cfg, 0);
}

// 交换缓冲区
void lcd_swap(void) {
    static uint8_t using_buffer1 = 1;
    
    // 等待VBlank
    while (!(LTDC->CDSR & LTDC_CDSR_VSYNCS));
    
    if (using_buffer1) {
        LTDC->Layer1->CFBAR = (uint32_t)buffer2;
    } else {
        LTDC->Layer1->CFBAR = (uint32_t)buffer1;
    }
    
    // 重新加载配置
    LTDC->Layer1->CFBLR = (LCD_WIDTH * LCD_BPP) << 16;
    LTDC->SRCR |= LTDC_SRCR_IMR;
    
    using_buffer1 = !using_buffer1;
}

这个方案我实际用过,在480×272的屏上跑到了60fps,CPU占用率不到10%。剩下的90% CPU时间,可以用来跑GUI、处理触摸、做业务逻辑。

嗯,帧缓冲管理这块,说白了就是三个字:快、稳、省。快——DMA传输要快;稳——双缓冲防止撕裂;省——内存分配要精打细算。把这三点做到位,你的LCD驱动就成功了一大半。