4. DMA 与 SPI 结合:SPI 的 DMA 请求线、双缓冲与循环模式、中断与完成回调

好,咱们进入正题。DMA 和 SPI 结合,说白了就是让硬件自己搬数据,CPU 腾出手干别的活。我刚开始搞嵌入式那会儿,总觉得 DMA 是个玄学,配来配去不如直接中断读写省心。后来被一个高速 ADC 采集项目逼得没办法——CPU 忙到连喂狗的时间都没有,才老老实实把 DMA 吃透。今天我把这些经验掰开揉碎讲给你听。

4.1 SPI 的 DMA 请求线:谁在喊“我要数据”?

SPI 外设和 DMA 控制器之间,靠的是硬件请求线通信。SPI 的发送缓冲区空了,它就拉高请求线,告诉 DMA:“快给我塞数据!” 接收缓冲区有数据了,同样拉高请求线:“快来取走!”

不同芯片的请求线编号不一样。拿 STM32F4 系列举例:

SPI 实例 TX 请求(DMA 流) RX 请求(DMA 流)
SPI1 DMA2_Stream3 DMA2_Stream0
SPI2 DMA1_Stream4 DMA1_Stream3
SPI3 DMA1_Stream7 DMA1_Stream2
我曾经踩过的坑: 配 DMA 流的时候,把 SPI1 的 TX 请求挂到了 DMA2_Stream0 上。结果数据死活发不出去,查了两天才发现是请求线编号搞反了。记住:每个外设的 DMA 请求线是固定的,不是你想挂哪个流就挂哪个流。

4.2 双缓冲模式:一边发一边收,不打架

双缓冲,顾名思义,就是准备两个缓冲区。DMA 在搬第一个缓冲区的时候,CPU 可以往第二个缓冲区里写新数据。等 DMA 搬完了,自动切到第二个缓冲区,CPU 接着填第一个。

我习惯把这种模式叫“乒乓操作”。你看这个流程就明白了:

SPI DMA 双缓冲(乒乓操作)流程图 DMA 搬 Buffer A → SPI CPU 填充 Buffer B DMA 搬 Buffer B → SPI CPU 填充 Buffer A DMA 搬 Buffer A → SPI DMA 从 SPI 收 → Buffer B DMA 搬 Buffer B → SPI DMA 从 SPI 收 → Buffer A 关键点:DMA 传输完成时自动切换缓冲区指针 CPU 和 DMA 永远不会同时操作同一个缓冲区
我个人习惯: 双缓冲的缓冲区大小设为 SPI 帧长度的整数倍。比如 SPI 一帧 16 位,缓冲区就设成 32 字节、64 字节这种。否则 DMA 传输结束的位置可能卡在半帧上,数据就乱了。

4.3 循环模式:让数据流永不停止

循环模式是 DMA 的“自动重播”功能。DMA 把缓冲区搬完一遍后,自动把地址指针重置到开头,接着搬第二遍。这对连续数据流场景特别有用——比如音频播放、波形输出。

配置循环模式时,DMA 控制寄存器里有个 CIRC 位(Circular Mode)。置 1 就开启。但要注意:

  • 循环模式必须配合双缓冲,否则 DMA 会反复覆盖同一个缓冲区,数据全乱套。
  • 中断触发时机要选对。我建议用“半传输完成中断”+“传输完成中断”。半传输完成时,CPU 处理前半段数据;传输完成时,CPU 处理后半段。这样 CPU 永远有半个缓冲区的时间来干活。
// STM32 HAL 库配置示例:SPI DMA 循环模式 + 双缓冲
// 假设 SPI 句柄为 hspi1,DMA 句柄为 hdma_tx

// 1. 配置 DMA 为循环模式
hdma_tx.Init.Mode = DMA_CIRCULAR;          // 循环模式
hdma_tx.Init.FIFOMode = DMA_FIFOMODE_ENABLE;
hdma_tx.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL;

// 2. 配置双缓冲
// 注意:HAL 库的 DMA 双缓冲需要手动设置内存地址
// 第一次调用时指定 bufferA,传输完成回调里切换为 bufferB
HAL_DMAEx_ConfigMuxSync(&hdma_tx, ...);

// 3. 启动传输
HAL_SPI_Transmit_DMA(&hspi1, bufferA, BUFFER_SIZE);

// 4. 在传输完成回调中切换缓冲区
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi->Instance == SPI1)
    {
        // 切换 DMA 内存地址到另一个缓冲区
        HAL_DMAEx_ChangeMemory(&hdma_tx, (uint32_t)bufferB, MEMORY_0);
        // 或者用寄存器直接操作
        // hdma_tx.Instance->M0AR = (uint32_t)bufferB;
    }
}
我曾经踩过的坑: 循环模式下,DMA 传输完成中断会一直触发。如果你在中断里做了耗时操作(比如打印日志),系统会卡死在中断里。我的做法是:中断里只设一个标志位,主循环里轮询处理。或者用中断优先级分组,把 DMA 中断优先级设低一点。

4.4 中断与完成回调:什么时候该干活?

DMA 传输完成时,会触发中断。中断服务程序里该干什么?我总结了三件事:

  1. 切换缓冲区指针(双缓冲模式下)
  2. 更新数据计数器(比如记录已传输的字节数)
  3. 通知应用层(通过回调函数或信号量)

回调函数的设计有个讲究:不要在回调里做复杂计算。回调是在中断上下文里执行的,时间长了会影响其他中断。我习惯的做法是:

// 推荐的回调设计模式
volatile uint8_t dma_tx_done = 0;

void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
    // 只做两件事:设标志 + 切换缓冲区
    dma_tx_done = 1;
    // 切换缓冲区代码...
}

void main_loop(void)
{
    while (1)
    {
        if (dma_tx_done)
        {
            dma_tx_done = 0;
            // 在这里处理数据,不在中断里处理
            process_data();
        }
        // 其他任务...
    }
}
性能调优要点:
  • DMA 中断优先级不要高于系统滴答定时器,否则 HAL_Delay() 会失效。
  • 如果 SPI 时钟超过 10MHz,建议开启 DMA 的 FIFO 模式(深度 4 或 8 级),减少总线争抢。
  • 双缓冲的缓冲区建议放在 DTCM 或 SRAM 中,不要放在 AHB 总线上,否则 DMA 和 CPU 会抢总线。

嗯,到这里 DMA 和 SPI 结合的核心机制就讲完了。你想想看,双缓冲解决了数据覆盖问题,循环模式解决了连续传输问题,中断回调解决了同步问题——这三个东西凑在一起,才是一个能跑高速、不丢数据、CPU 还闲着的 SPI 传输方案。我在一个 20MHz SPI 的 OLED 显示项目里就是这么干的,CPU 占用率从 60% 降到了 5% 以下。