SPI_DMA双缓冲区实现高帧率旋转立方体
STM32 SPI + DMA + 双缓冲实现 LCD 高帧率刷新
一、为什么要使用双缓冲
SPI DMA 发送 LCD 图片时,DMA 会持续读取内存中的像素数据。
如果只有一块缓冲区:
CPU → 修改 framebuffer
↕
DMA → 读取 framebufferCPU 修改数据和 DMA 读取数据可能同时发生,导致当前正在发送的数据被修改,出现画面撕裂或数据异常。
因此准备两块缓冲区:
static uint8_t fb[2][FB_SIZE];两块缓冲区交替使用:
CPU DMA
第1帧 写 fb[0] 发送 fb[1]
第2帧 写 fb[1] 发送 fb[0]
第3帧 写 fb[0] 发送 fb[1]核心就是:
CPU 永远写 DMA 没有正在发送的那一块。
二、双缓冲的完整工作流程
定义一个当前缓冲区:
uint8_t cur = 0;主循环:
while (1)
{
/* ① CPU渲染当前帧 */
demo_show_cube(fb[cur]);
/* ② 等待上一块缓冲区DMA发送完成 */
lcd_fb_flush(fb[cur]);
/* ③ 切换缓冲区 */
cur ^= 1;
}这里最容易搞错的是:
等待 DMA 完成的位置不是放在渲染之前,而是放在渲染之后。
完整时序:
CPU: 渲染 fb[0] ──────┐
│
DMA: 发送上一帧 ──┘
↓
等DMA完成
↓
发送 fb[0]
CPU: 渲染 fb[1]这样 CPU 的渲染时间和 DMA 的发送时间可以重叠。
三、为什么等待放在渲染之后
假设:
DMA 正在发送 fb[1]
CPU 正在渲染 fb[0]CPU 很快就把 fb[0] 渲染好了。
但是此时不能马上让 DMA 发送 fb[0],因为 SPI 还在发送 fb[1]。
所以:
demo_show_cube(fb[cur]);
lcd_fb_flush(fb[cur]);进入 lcd_fb_flush() 后:
while (spi_tx_done == 0);等待上一帧发送完成。
一旦 DMA 完成:
DMA完成
↓
spi_tx_done = 1
↓
退出while
↓
设置LCD窗口
↓
启动当前帧DMA四、DMA完成标志怎么来的
定义:
static volatile uint8_t spi_tx_done = 1;启动 DMA 前:
spi_tx_done = 0;
HAL_SPI_Transmit_DMA(
&g_spi_handle,
fb,
FB_SIZE
);DMA 完成后,进入:
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
if (hspi->Instance == SPI2)
{
spi_tx_done = 1;
}
}因此:
启动DMA
↓
spi_tx_done = 0
↓
DMA后台发送
↓
DMA传输完成
↓
DMA中断
↓
HAL_DMA_IRQHandler()
↓
HAL内部SPI DMA完成函数
↓
HAL_SPI_TxCpltCallback()
↓
spi_tx_done = 1五、LCD刷新函数
可以把等待和启动 DMA 集中到一个函数:
static void lcd_fb_flush(uint8_t *fb)
{
/* 等待上一帧DMA发送完成 */
while (spi_tx_done == 0)
{
}
/* 设置LCD显示窗口 */
atk_md0240_async_window(
FB_X,
FB_Y,
FB_X + FB_W - 1,
FB_Y + FB_H - 1
);
/* 当前缓冲区交给DMA */
spi_tx_done = 0;
HAL_SPI_Transmit_DMA(
&g_spi_handle,
fb,
FB_SIZE
);
}这里的关键顺序是:
① 等待上一帧 DMA 完成
↓
② 设置 LCD 窗口
↓
③ spi_tx_done = 0
↓
④ 启动当前帧 DMA
↓
⑤ 函数立即返回不要在启动 DMA 后马上等待。
错误:
HAL_SPI_Transmit_DMA(...);
while (spi_tx_done == 0);这样就失去了异步 DMA 的意义,CPU又在这里傻等 DMA。
六、DMA为什么使用普通模式
这里使用:
DMA_NORMAL因为我们发送的是:
一帧
↓
发送完成
↓
停止DMA
↓
下一帧重新启动DMA例如:
fb[0]
↓
6498 Bytes
↓
DMA完成
↓
停止下一次:
fb[1]
↓
6498 Bytes
↓
DMA完成
↓
停止不需要 DMA_CIRCULAR。
如果使用循环模式:
fb[0] → fb[1] → ... → fb[6497]
↑ ↓
└─────────────────────────┘DMA会不断重复发送同一块内存。
七、DMA和双缓冲不是一回事
这里要特别区分:
双缓冲:
软件定义两块RAM
DMA:
负责把RAM中的数据搬到SPI本例:
static uint8_t fb[2][FB_SIZE];这是软件双缓冲。
而:
DMA_NORMAL只是 DMA 的工作模式。
所以这里实际上是:
软件双缓冲
+
DMA普通模式并不是 STM32 DMA 控制器自身的 Double Buffer Mode。
八、SPI DMA配置的关键参数
发送 LCD:
DMA_MEMORY_TO_PERIPH数据方向:
RAM → DMA → SPI->DR外设地址不能递增:
DMA_PINC_DISABLE因为始终写:
SPI->DR内存地址递增:
DMA_MINC_ENABLE因为:
fb[0]
fb[1]
fb[2]
...需要连续读取。
数据宽度:
DMA_PDATAALIGN_BYTE
DMA_MDATAALIGN_BYTE因为这里按字节发送 RGB565 数据。
模式:
DMA_NORMAL优先级:
DMA_PRIORITY_LOW实际项目中可以根据系统中其他 DMA 的重要程度调整。
九、DMA中断链路
DMA完成后不是直接调用:
HAL_SPI_TxCpltCallback()而是:
DMA传输完成
↓
DMA产生TC中断
↓
NVIC
↓
DMA1_Stream4_IRQHandler()
↓
HAL_DMA_IRQHandler()
↓
SPI内部DMA完成处理
↓
HAL_SPI_TxCpltCallback()IRQ入口:
void DMA1_Stream4_IRQHandler(void)
{
HAL_DMA_IRQHandler(&SPI2_DMA_Tx_Handler);
}而最终我们自己关心的通常是:
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
if (hspi->Instance == SPI2)
{
spi_tx_done = 1;
}
}十、完整核心模板
#define FB_W 57
#define FB_H 57
#define FB_SIZE (FB_W * FB_H * 2)
static uint8_t fb[2][FB_SIZE];
static volatile uint8_t spi_tx_done = 1;
static void lcd_fb_flush(uint8_t *buf)
{
/* 等上一帧DMA完成 */
while (spi_tx_done == 0)
{
}
/* 设置LCD窗口 */
lcd_set_window(
FB_X,
FB_Y,
FB_X + FB_W - 1,
FB_Y + FB_H - 1
);
/* 启动当前帧DMA */
spi_tx_done = 0;
HAL_SPI_Transmit_DMA(
&hspi2,
buf,
FB_SIZE
);
}
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
if (hspi->Instance == SPI2)
{
spi_tx_done = 1;
}
}
void LCD_Task(void *argument)
{
uint8_t cur = 0;
while (1)
{
/* CPU写当前缓冲区 */
demo_render(fb[cur]);
/* 等上一帧DMA完成,然后发送当前缓冲区 */
lcd_fb_flush(fb[cur]);
/* 切换到另一块缓冲区 */
cur ^= 1;
}
}整个系统最终就是:
┌──────────────┐
│ CPU渲染 │
└──────┬───────┘
↓
fb[cur]
↓
HAL_SPI_Transmit_DMA
↓
DMA
↓
SPI->DR
↓
LCD
↓
DMA完成
↓
HAL_SPI_TxCpltCallback
↓
spi_tx_done = 1
↓
切换缓冲区
↓
cur ^= 1先渲染,再等待上一帧 DMA 完成,随后启动当前帧 DMA,最后切换到另一块缓冲区。
这是这个里“CPU渲染 + SPI DMA发送 + 软件乒乓缓冲”的核心。

