4. LVGL双缓冲实战(一):基于STM32F429的硬件环境搭建、LTDC与DMA2D配置、双buffer初始化代码

说实话,做GUI最怕什么?最怕画面撕裂。你想想看,屏幕正在刷新的时候,MCU突然把显存内容改了,上半截是旧画面,下半截是新画面——这种割裂感,用户一眼就能看出来。

双缓冲就是来解决这个问题的。这一章,咱们先把STM32F429的硬件环境搭起来,把LTDC和DMA2D配置好,最后把双缓冲的初始化代码跑通。我习惯先把硬件底子打扎实,再往上堆软件,这样出了问题也好定位。

4.1 硬件环境概览

STM32F429这款芯片,我用了好几年了。它内置TFT-LCD控制器(LTDC)和DMA2D图形加速器,做双缓冲简直是天生一对。咱们先看看这次实战用到的硬件资源:

硬件模块 型号/参数 用途
主控MCU STM32F429IGT6 核心处理,运行LVGL
外部SDRAM W9825G6KH-6 (32MB) 存放两个显存buffer + LVGL内存池
LCD屏 7寸 1024×600 RGB接口 显示输出
LTDC 内置于F429 驱动LCD时序
DMA2D 内置于F429 快速拷贝/填充显存
我的经验:SDRAM一定要选够用的。1024×600分辨率,每个像素32位色深,一个buffer就是1024×600×4 ≈ 2.4MB。双buffer就是4.8MB,再加上LVGL自己的内存开销,16MB以上的SDRAM才稳妥。我早期项目吃过亏,选了8MB的SDRAM,结果LVGL内存池不够用,画面卡顿,后来硬着头皮改板子。

4.2 LTDC配置要点

LTDC说白了就是给LCD提供时序信号的。它不需要你手动控制每个像素,只要配好时钟、分辨率、同步参数,它自己就能把显存里的数据刷到屏幕上。

我个人习惯先配时钟。LTDC的像素时钟来自PLLSAI,一般配到33.3MHz左右,这样1024×600@60Hz刚好够用。配高了屏幕可能花屏,配低了刷新率不够。

/* LTDC时钟配置:PLLSAI输出 33.3MHz */
RCC_PeriphCLKInitTypeDef PeriphClkInit = {0};
PeriphClkInit.PeriphClockSelection = RCC_PERIPHCLK_LTDC;
PeriphClkInit.PLLSAI.PLLSAIN = 192;
PeriphClkInit.PLLSAI.PLLSAIR = 5;
PeriphClkInit.PLLSAI.PLLSAIQ = 7;
PeriphClkInit.PLLSAIDivR = RCC_PLLSAIDIVR_2;
HAL_RCCEx_PeriphCLKConfig(&PeriphClkInit);

然后是LTDC的初始化结构体。这里要注意的是水平同步和垂直同步的脉冲宽度,不同屏幕的规格书不一样,一定要对着数据手册配。

/* LTDC初始化结构体 */
LTDC_HandleTypeDef hltdc;
hltdc.Instance = LTDC;
hltdc.Init.HSPolarity = LTDC_HSPOLARITY_AL;
hltdc.Init.VSPolarity = LTDC_VSPOLARITY_AL;
hltdc.Init.DEPolarity = LTDC_DEPOLARITY_AL;
hltdc.Init.PCPolarity = LTDC_PCPOLARITY_IPC;
hltdc.Init.HorizontalSync = 40;      /* 水平同步宽度 */
hltdc.Init.VerticalSync = 9;         /* 垂直同步宽度 */
hltdc.Init.AccumulatedHBP = 53;      /* 水平后肩 */
hltdc.Init.AccumulatedVBP = 11;      /* 垂直后肩 */
hltdc.Init.AccumulatedActiveW = 1077; /* 有效宽度 + HBP + HSync */
hltdc.Init.AccumulatedActiveH = 620;  /* 有效高度 + VBP + VSync */
hltdc.Init.TotalWidth = 1117;        /* 总宽度 */
hltdc.Init.TotalHeigh = 635;         /* 总高度 */
hltdc.Init.Backcolor.Blue = 0;
hltdc.Init.Backcolor.Green = 0;
hltdc.Init.Backcolor.Red = 0;
HAL_LTDC_Init(&hltdc);
我曾经踩过的坑:AccumulatedActiveW和AccumulatedActiveH这两个参数,很多人直接填屏幕分辨率,结果画面偏移。正确的做法是:AccumulatedActiveW = HSync + HBP + 屏幕宽度。比如HSync=40,HBP=13,屏幕宽1024,那AccumulatedActiveW就是40+13+1024=1077。一定要算清楚,不然画面会跑到屏幕外面去。

4.3 DMA2D配置

DMA2D是F429的图形加速器。它可以在后台快速搬运数据,不占用CPU。双缓冲切换的时候,用DMA2D来拷贝buffer,效率比CPU memcpy高好几倍。

我一般把DMA2D配置成两种模式:寄存器模式(直接操作)和中断模式(配合LVGL的刷新回调)。这里先展示寄存器模式的配置,简单直接:

/* DMA2D初始化 */
DMA2D_HandleTypeDef hdma2d;
hdma2d.Instance = DMA2D;
hdma2d.Init.Mode = DMA2D_M2M;          /* 内存到内存模式 */
hdma2d.Init.ColorMode = DMA2D_ARGB8888; /* 32位色深 */
hdma2d.Init.OutputOffset = 0;
hdma2d.Init.BytesSwap = DMA2D_BYTESWAP_DISABLE;
hdma2d.Init.LineOffsetMode = DMA2D_LOM_PIXELS;
hdma2d.Init.LineNumber = 600;          /* 屏幕高度 */
hdma2d.Init.PixelPerLine = 1024;       /* 屏幕宽度 */
HAL_DMA2D_Init(&hdma2d);

配置好之后,拷贝一个buffer只需要一行代码:

/* 使用DMA2D拷贝buffer */
HAL_DMA2D_Start(&hdma2d, 
                (uint32_t)buffer_src, 
                (uint32_t)buffer_dst, 
                1024,  /* 每行像素数 */
                600);  /* 行数 */
HAL_DMA2D_PollForTransfer(&hdma2d, 100);
关键点:DMA2D的LineNumber和PixelPerLine一定要和屏幕分辨率一致。如果配错了,拷贝出来的画面会错位。我建议把这两个值定义成宏,方便以后换屏幕时统一修改。

4.4 双Buffer初始化代码

好了,硬件配置完了,现在来写双缓冲的初始化。两个buffer都放在SDRAM里,地址要连续,方便DMA2D批量操作。

我习惯把buffer地址定义成宏,这样代码可读性更好:

/* SDRAM基地址(F429的FMC映射) */
#define SDRAM_BASE_ADDR     0xC0000000

/* 两个显存buffer的偏移地址 */
#define BUFFER1_OFFSET      0x00000000  /* 第一个buffer从SDRAM起始 */
#define BUFFER2_OFFSET      0x00258000  /* 第二个buffer紧挨着 */

/* 每个buffer的大小:1024 * 600 * 4 = 2,457,600 bytes */
#define BUFFER_SIZE         (1024 * 600 * 4)

/* buffer地址宏 */
#define LCD_BUF1            (SDRAM_BASE_ADDR + BUFFER1_OFFSET)
#define LCD_BUF2            (SDRAM_BASE_ADDR + BUFFER2_OFFSET)

然后写初始化函数。这里要注意:LTDC默认指向第一个buffer,第二个buffer先不激活,等LVGL需要切换时再换过去。

/* 双缓冲初始化 */
void LCD_DoubleBuffer_Init(void)
{
    /* 1. 清空两个buffer */
    memset((void*)LCD_BUF1, 0x00, BUFFER_SIZE);
    memset((void*)LCD_BUF2, 0x00, BUFFER_SIZE);
    
    /* 2. 配置LTDC图层0,指向buffer1 */
    LTDC_LayerCfgTypeDef layer_cfg = {0};
    layer_cfg.WindowX0 = 0;
    layer_cfg.WindowX1 = 1024;
    layer_cfg.WindowY0 = 0;
    layer_cfg.WindowY1 = 600;
    layer_cfg.PixelFormat = LTDC_PIXEL_FORMAT_ARGB8888;
    layer_cfg.FBStartAdress = LCD_BUF1;  /* 当前显示buffer1 */
    layer_cfg.Alpha = 255;
    layer_cfg.Alpha0 = 0;
    layer_cfg.BlendingFactor1 = LTDC_BLENDING_FACTOR1_PAxCA;
    layer_cfg.BlendingFactor2 = LTDC_BLENDING_FACTOR2_PAxCA;
    layer_cfg.ImageWidth = 1024;
    layer_cfg.ImageHeight = 600;
    layer_cfg.Backcolor.Blue = 0;
    layer_cfg.Backcolor.Green = 0;
    layer_cfg.Backcolor.Red = 0;
    HAL_LTDC_ConfigLayer(&hltdc, &layer_cfg, 0);
    
    /* 3. 记录当前显示的buffer编号 */
    current_buffer = 0;  /* 0表示buffer1,1表示buffer2 */
    
    /* 4. 启动LTDC */
    HAL_LTDC_Start(&hltdc);
}
小技巧:清空buffer时用memset,但如果是大buffer(比如2.4MB),memset会占用CPU时间。我后来改用DMA2D的填充功能,速度更快。代码改成:HAL_DMA2D_Fill(&hdma2d, LCD_BUF1, 0, 1024, 600);,这样CPU可以去做别的事。

4.5 双缓冲切换逻辑

初始化完了,还得有个切换函数。LVGL在刷新时会调用这个函数,把后台buffer和前台buffer互换。

/* 双缓冲切换函数 */
void LCD_SwapBuffers(void)
{
    if (current_buffer == 0)
    {
        /* 当前显示buffer1,切换到buffer2 */
        HAL_LTDC_SetAddress(&hltdc, LCD_BUF2, 0);
        current_buffer = 1;
    }
    else
    {
        /* 当前显示buffer2,切换到buffer1 */
        HAL_LTDC_SetAddress(&hltdc, LCD_BUF1, 0);
        current_buffer = 0;
    }
}

这里有个细节:HAL_LTDC_SetAddress函数会等待当前帧结束才切换地址,所以不会出现撕裂。嗯,这就是硬件双缓冲的好处——你不用操心同步问题,LTDC自己会处理好。

4.6 整体流程图

为了让你更直观地理解整个流程,我画了一张图:

STM32F429 双缓冲初始化与切换流程 STM32F429 主控 配置 LTDC 时钟与时序参数 配置 DMA2D 内存到内存模式 在 SDRAM 中分配两个显存 Buffer Buffer1: 0xC0000000 | Buffer2: 0xC0258000 LTDC 图层0 指向 Buffer1(前台) LVGL 刷新时调用 LCD_SwapBuffers() LTDC 自动等待帧同步后切换地址 关键硬件 • LTDC:驱动LCD • DMA2D:加速拷贝 • SDRAM:存放Buffer 双缓冲优势 • 无画面撕裂 • 帧同步自动处理 • CPU占用低

从图上可以看得很清楚:整个流程就是先配硬件,再分配buffer,最后让LTDC和LVGL配合起来做切换。每一步都有明确的输入输出,出了问题也好排查。

4.7 验证与调试

代码写完了,怎么验证对不对?我一般用两个方法:

  1. 填充颜色测试:把buffer1填充成红色,buffer2填充成蓝色。如果屏幕显示红色,说明LTDC正确指向了buffer1。然后调用切换函数,屏幕应该变成蓝色。
  2. DMA2D拷贝测试:在buffer1里画一个图案,用DMA2D拷贝到buffer2,然后切换显示。如果图案一致,说明DMA2D配置正确。
注意:如果屏幕显示花屏或者颜色不对,先检查SDRAM的初始化是否成功。F429的FMC配置比较复杂,时序参数配错了,SDRAM读写就会出错。我建议先用一个简单的memset测试SDRAM,确认读写正常后再跑LTDC。