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。
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)));
3.3 DMA双缓冲传输配置
内存准备好了,接下来配置DMA。这里以 STM32F429 + LTDC + DMA2D 为例。
DMA2D支持双缓冲模式,说白了就是它内部有两个地址寄存器:FGMAR(前景内存地址)和 BGMAR(背景内存地址)。我们可以利用这个特性,实现「一个缓冲区在传输,另一个缓冲区在渲染」。
配置步骤大致如下:
- 初始化DMA2D:设置传输模式为「寄存器到内存」或「内存到内存」。
- 设置源地址和目标地址:源地址指向当前要刷新的缓冲区,目标地址指向LTDC的帧缓冲区地址。
- 设置传输大小:一次传输一整帧。
- 使能传输完成中断:这样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;
}
}
}
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;
}
}
3.6 整体流程图
说了这么多,咱们用一张图把整个流程串起来。你一看就明白。
3.7 常见问题与避坑
最后,分享几个我实际调试中踩过的坑。
- 画面闪烁:大概率是缓冲区切换时机不对。检查一下V-Sync中断是否真的在垂直消隐期触发。我遇到过LTDC的极性配置反了,导致中断在扫描期间触发,画面闪得厉害。
- DMA传输卡死:检查DMA的FIFO阈值和突发传输设置。有些STM32型号的DMA2D在FIFO满时会挂起,需要调整
FIFO_THRESHOLD寄存器。 - 内存对齐问题:DMA2D要求源地址和目标地址按4字节对齐。如果你的缓冲区定义没加
__attribute__((aligned(4))),可能会触发硬件错误。 - 中断优先级:DMA传输完成中断和V-Sync中断的优先级要设置合理。我一般把V-Sync中断设得比DMA中断高,确保缓冲区切换不被延迟。
lv_disp_flush_ready(),结果LVGL以为刷新还没完,一直不发起下一帧的绘制。UI卡得一动不动。查了整整一天才找到原因。所以,这个函数调用千万别漏。
好了,这一章的内容就到这里。配置代码和流程图都在上面了,你照着做,应该能顺利跑起来。如果遇到问题,回头看看「常见问题」那块,八成能找到答案。