3、双缓冲驱动适配实战:在STM32上配置两个LCD缓冲区、DMA双缓冲传输、中断同步

好,咱们进入实战环节。

前面两章讲了双缓冲的原理和LVGL的刷新机制。说白了,那些都是理论铺垫。这一章,咱们直接上手STM32,把双缓冲驱动真正跑起来。

我个人习惯,做嵌入式UI驱动,最怕的就是「理论通,一跑就崩」。所以这一章,我会带着你一步步把代码敲出来。你跟着走一遍,以后换任何MCU,思路都一样。

3.1 硬件前提与资源评估

先看看你手头的板子。不是所有STM32都能爽快地跑双缓冲+DMA。

我遇到过有人拿STM32F103C8T6硬上,结果RAM不够,画面撕裂不说,还经常死机。所以,动手前先确认三件事:

  • RAM容量:两个缓冲区,每个至少占 分辨率 × 像素深度 字节。比如480×272的屏,RGB565格式,一个缓冲区就是480×272×2 ≈ 261KB。两个就是522KB。F103只有64KB?嗯,基本没戏。
  • DMA控制器:STM32F4/H7系列基本都有DMA2D(专用2D图形DMA)或普通DMA。我建议优先用DMA2D,它能直接处理颜色格式转换和填充,省CPU。
  • LTDC或FSMC接口:如果你的LCD用的是RGB并行接口,必须要有LTDC外设。如果是8080并口或SPI,那就用FSMC/FMC或SPI DMA。
我的经验: 别在F1系列上折腾双缓冲。RAM太小,DMA通道也少。至少从F4或H7起步,体验会好很多。

3.2 双缓冲区的内存布局

确认硬件够用后,咱们来规划内存。

我习惯把两个缓冲区放在连续的内存地址上。这样DMA传输时,地址计算简单,不容易出错。

假设你的LCD分辨率是 480×272,颜色格式 RGB565(每个像素2字节)。

参数 值
分辨率 480 × 272
像素深度 2 字节 (RGB565)
单个缓冲区大小 480 × 272 × 2 = 261,120 字节
两个缓冲区总大小 522,240 字节

在代码里,我通常这样定义:

// 定义两个缓冲区,放在连续的内存区域
// 注意:需要内存对齐,DMA传输通常要求4字节或8字节对齐
#define LCD_WIDTH  480
#define LCD_HEIGHT 272
#define LCD_PIXEL_SIZE 2  // RGB565

// 使用 __attribute__((aligned(4))) 强制4字节对齐
static uint8_t lcd_buf1[LCD_WIDTH * LCD_HEIGHT * LCD_PIXEL_SIZE] __attribute__((aligned(4)));
static uint8_t lcd_buf2[LCD_WIDTH * LCD_HEIGHT * LCD_PIXEL_SIZE] __attribute__((aligned(4)));

// 或者用一个大数组,手动切分
static uint8_t lcd_double_buf[2][LCD_WIDTH * LCD_HEIGHT * LCD_PIXEL_SIZE] __attribute__((aligned(4)));
注意: 如果你用的是STM32H7系列,强烈建议把缓冲区放在 AXI SRAM 或 D2域 SRAM 中。DTCM虽然快,但DMA访问不到。我曾经在这上面栽过跟头,画面死活刷不出来,查了半天才发现是内存域选错了。

3.3 DMA双缓冲传输配置

内存准备好了,接下来配置DMA。这里以 STM32F429 + LTDC + DMA2D 为例。

DMA2D支持双缓冲模式,说白了就是它内部有两个地址寄存器:FGMAR(前景内存地址)和 BGMAR(背景内存地址)。我们可以利用这个特性,实现「一个缓冲区在传输,另一个缓冲区在渲染」。

配置步骤大致如下:

  1. 初始化DMA2D:设置传输模式为「寄存器到内存」或「内存到内存」。
  2. 设置源地址和目标地址:源地址指向当前要刷新的缓冲区,目标地址指向LTDC的帧缓冲区地址。
  3. 设置传输大小:一次传输一整帧。
  4. 使能传输完成中断:这样DMA传完一帧后,我们可以立刻切换缓冲区。

代码示例:

// DMA2D 初始化函数
void DMA2D_Init(void)
{
    // 使能DMA2D时钟
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA2DEN;

    // 配置DMA2D为内存到内存模式
    DMA2D->CR = 0x00000000;  // 先复位控制寄存器
    DMA2D->CR |= DMA2D_CR_MODE_0;  // 内存到内存模式

    // 设置颜色格式为RGB565
    DMA2D->FGPFCCR = 0x02;  // 前景像素格式:RGB565
    DMA2D->BGPFCCR = 0x02;  // 背景像素格式:RGB565

    // 使能传输完成中断
    DMA2D->CR |= DMA2D_CR_TCIE;
    NVIC_EnableIRQ(DMA2D_IRQn);
}

// 启动一次DMA2D传输
void DMA2D_StartTransfer(uint32_t src_addr, uint32_t dst_addr, uint16_t width, uint16_t height)
{
    // 等待DMA2D空闲
    while (DMA2D->CR & DMA2D_CR_START);

    // 设置源地址
    DMA2D->FGMAR = src_addr;
    // 设置目标地址
    DMA2D->OMAR = dst_addr;

    // 设置传输大小(像素数)
    DMA2D->NLR = (height << 16) | width;

    // 启动传输
    DMA2D->CR |= DMA2D_CR_START;
}

3.4 中断同步与缓冲区切换

DMA传输完成后,会触发中断。我们在中断里做缓冲区切换。

这里有个关键点:什么时候切换?

不是传完就立刻切。要等到LCD控制器完成当前帧的扫描(即V-Sync期间)再切,否则会出现画面撕裂。

我一般用两种方式同步:

  • 方式一:LTDC的V-Sync中断。在V-Sync中断里检查DMA是否完成,如果完成则切换缓冲区。
  • 方式二:DMA传输完成中断 + 等待V-Sync。DMA传完后,在中断里设置一个标志位,主循环或V-Sync中断里检查这个标志,然后切换。

我个人更推荐方式二。为什么呢?因为DMA传输完成和V-Sync可能不同步,方式二更灵活,不容易丢帧。

代码实现:

// 全局变量:当前正在显示的缓冲区索引
volatile uint8_t current_display_buf = 0;
// 全局变量:DMA传输完成标志
volatile uint8_t dma_transfer_done = 0;

// DMA2D 传输完成中断服务函数
void DMA2D_IRQHandler(void)
{
    if (DMA2D->ISR & DMA2D_ISR_TCIF)
    {
        // 清除中断标志
        DMA2D->IFCR = DMA2D_IFCR_CTCIF;

        // 设置传输完成标志
        dma_transfer_done = 1;
    }
}

// LTDC V-Sync 中断服务函数
void LTDC_IRQHandler(void)
{
    if (LTDC->ISR & LTDC_ISR_LI)
    {
        // 清除中断标志
        LTDC->ICR = LTDC_ICR_CLI;

        // 检查DMA是否传输完成
        if (dma_transfer_done)
        {
            // 切换缓冲区
            // 将LTDC的帧缓冲区地址切换到另一个缓冲区
            current_display_buf ^= 1;  // 0变1,1变0

            // 更新LTDC帧缓冲区地址
            if (current_display_buf == 0)
            {
                LTDC->CFB1->CFBBR = (uint32_t)lcd_buf1;
            }
            else
            {
                LTDC->CFB1->CFBBR = (uint32_t)lcd_buf2;
            }

            // 清除标志,准备下一次传输
            dma_transfer_done = 0;
        }
    }
}
核心逻辑: DMA负责把数据从「渲染缓冲区」搬到「显示缓冲区」。V-Sync中断负责在安全时刻切换显示缓冲区。两者配合,画面流畅无撕裂。

3.5 与LVGL的对接

驱动层搞定了,怎么让LVGL用上这两个缓冲区?

LVGL的刷新回调函数 disp_flush 里,我们只需要做一件事:把LVGL传过来的绘制数据,拷贝到当前「不在显示」的那个缓冲区里,然后启动DMA传输。

代码示例:

// LVGL 刷新回调函数
void my_disp_flush(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    // 计算需要刷新的区域大小
    int32_t x1 = area->x1;
    int32_t y1 = area->y1;
    int32_t x2 = area->x2;
    int32_t y2 = area->y2;
    int32_t w = x2 - x1 + 1;
    int32_t h = y2 - y1 + 1;

    // 确定当前要写入的缓冲区(不是正在显示的那个)
    uint8_t * target_buf;
    if (current_display_buf == 0)
    {
        target_buf = lcd_buf2;  // 显示buf1,写入buf2
    }
    else
    {
        target_buf = lcd_buf1;  // 显示buf2,写入buf1
    }

    // 计算目标缓冲区中的偏移地址
    uint32_t offset = (y1 * LCD_WIDTH + x1) * LCD_PIXEL_SIZE;
    uint32_t dst_addr = (uint32_t)target_buf + offset;
    uint32_t src_addr = (uint32_t)color_p;

    // 启动DMA2D传输,将LVGL的绘制数据拷贝到目标缓冲区
    DMA2D_StartTransfer(src_addr, dst_addr, w, h);

    // 通知LVGL刷新完成(DMA传输完成后,在中断里调用)
    // 注意:这里不能直接调用 lv_disp_flush_ready()
    // 需要在DMA传输完成中断里调用
}

// 在DMA传输完成中断中调用
void DMA2D_IRQHandler(void)
{
    if (DMA2D->ISR & DMA2D_ISR_TCIF)
    {
        DMA2D->IFCR = DMA2D_IFCR_CTCIF;

        // 通知LVGL,当前区域的刷新已完成
        lv_disp_flush_ready(&disp_drv);

        // 设置标志,等待V-Sync切换缓冲区
        dma_transfer_done = 1;
    }
}
一个小技巧: 如果LVGL的绘制区域不是整帧,而是局部刷新,DMA2D可以只传输脏区域。这样带宽占用更小,帧率更高。我一般在复杂UI页面(比如带地图或图表)上,用局部刷新能省下30%~50%的传输时间。

3.6 整体流程图

说了这么多,咱们用一张图把整个流程串起来。你一看就明白。

双缓冲DMA传输流程图 双缓冲 + DMA + V-Sync 同步流程图 LVGL 渲染引擎 渲染缓冲区 (Buffer A/B) DMA2D 传输 显示缓冲区 (LTDC) LCD 屏幕显示 DMA传输完成中断 V-Sync 中断切换缓冲区 关键点: 1. 渲染和显示并行 2. DMA负责搬运数据 3. V-Sync保证无撕裂

3.7 常见问题与避坑

最后,分享几个我实际调试中踩过的坑。

  • 画面闪烁:大概率是缓冲区切换时机不对。检查一下V-Sync中断是否真的在垂直消隐期触发。我遇到过LTDC的极性配置反了,导致中断在扫描期间触发,画面闪得厉害。
  • DMA传输卡死:检查DMA的FIFO阈值和突发传输设置。有些STM32型号的DMA2D在FIFO满时会挂起,需要调整 FIFO_THRESHOLD 寄存器。
  • 内存对齐问题:DMA2D要求源地址和目标地址按4字节对齐。如果你的缓冲区定义没加 __attribute__((aligned(4))),可能会触发硬件错误。
  • 中断优先级:DMA传输完成中断和V-Sync中断的优先级要设置合理。我一般把V-Sync中断设得比DMA中断高,确保缓冲区切换不被延迟。
曾经我犯过的错: 有一次我忘了在DMA传输完成中断里调用 lv_disp_flush_ready(),结果LVGL以为刷新还没完,一直不发起下一帧的绘制。UI卡得一动不动。查了整整一天才找到原因。所以,这个函数调用千万别漏。

好了,这一章的内容就到这里。配置代码和流程图都在上面了,你照着做,应该能顺利跑起来。如果遇到问题,回头看看「常见问题」那块,八成能找到答案。