3. DMA控制器原理:传输流程、地址配置与突发传输

DMA这东西,说白了就是给CPU找了个搬运工。CPU只管发号施令,搬数据的事全交给DMA干。我刚开始做嵌入式时,总觉得DMA可有可无,直到有一次项目里CPU被中断占用了80%的时间……嗯,从那以后我再也不敢轻视DMA了。

3.1 DMA传输流程:从触发到完成

一个完整的DMA传输,其实就三步:请求→传输→结束。你想想看,跟你去快递站取包裹差不多——先叫号(请求),然后搬走(传输),最后签收(结束)。

核心流程拆解:

  1. 外设触发:SPI的RXNE(接收非空)或TXE(发送空)信号拉高,通知DMA控制器“我有数据要搬”。
  2. DMA仲裁:多个DMA通道同时请求时,仲裁器按优先级决定谁先上。我习惯把SPI的DMA通道设成最高优先级,毕竟SPI时钟不等人。
  3. 地址递增:DMA从源地址读一个数据,写到目的地址。源/目的地址可以自动加1,也可以固定不变。
  4. 传输计数递减:每传完一笔,计数器减1。减到0时,传输完成。
  5. 中断上报:传输完成或出错时,DMA给CPU发中断。我曾经踩过一个坑——忘了清传输完成标志,结果中断一直进,CPU直接卡死。

这里有个细节:DMA传输是字节对齐的。你设了4字节宽度,那传输次数必须是4的倍数。不然最后几个字节会丢,我吃过这个亏。

3.2 源地址与目的地址配置

地址配置是DMA调优的关键。配置错了,数据要么搬错地方,要么搬不全。我个人习惯把地址配置分成三种场景:

场景 源地址模式 目的地址模式 典型应用
外设→内存 固定(外设数据寄存器) 递增(内存缓冲区) SPI接收
内存→外设 递增(内存缓冲区) 固定(外设数据寄存器) SPI发送
内存→内存 递增(源缓冲区) 递增(目的缓冲区) 数据拷贝

你可能会问:为什么外设地址要固定?因为SPI的数据寄存器就一个地址,你读它一次,它自动更新数据。地址递增反而会读到别的地方去。

我的配置习惯:

  • 外设地址:永远设成固定模式,除非你确定外设有FIFO且支持地址自增。
  • 内存地址:环形缓冲区用递增模式,但要注意缓冲区边界回绕。
  • 传输方向:从外设角度定义,别搞反了。我见过有人把SPI发送配成从内存读数据到外设,结果DMA一直在往外设写空数据。

3.3 传输宽度:字节、半字还是字?

传输宽度决定了DMA一次搬多少数据。STM32的DMA支持三种宽度:

  • 字节(8位):一次搬1字节,适合SPI配置为8位数据帧。
  • 半字(16位):一次搬2字节,适合SPI配置为16位数据帧。
  • 字(32位):一次搬4字节,适合SPI配置为32位数据帧或内存间拷贝。

这里有个容易翻车的地方:源宽度和目的宽度可以不相等。比如SPI是8位外设,但内存缓冲区是32位的。DMA会自动做数据打包或拆包。但要注意——打包时高位补0,拆包时低位保留。我曾在项目中用8位SPI接收16位音频数据,结果DMA自动打包后,数据顺序全乱了。

宽度不匹配的坑:

  • 源宽度 < 目的宽度:DMA会把多个源数据拼成一个目的数据。顺序是LSB先入。
  • 源宽度 > 目的宽度:DMA会把一个源数据拆成多个目的数据。只保留低位。
  • 建议:尽量保持源和目的宽度一致,避免不必要的字节序处理。

3.4 突发传输:一次搬一车

突发传输(Burst Transfer)是DMA性能的关键。普通传输一次搬一个数据,突发传输一次搬一整个数据块。你想想看,从仓库搬箱子,一次搬一个和一次搬一车,效率能一样吗?

突发传输的配置参数主要有两个:

  • 突发大小:4拍、8拍、16拍(具体看芯片支持)。我一般选8拍,平衡总线占用和传输效率。
  • 突发类型:固定突发(地址连续)和增量突发(地址跳跃)。SPI用固定突发就够了。

突发传输的好处很明显:

  • 减少总线仲裁次数,提高吞吐量。
  • 降低CPU干预频率,适合大数据量传输。

但也不是越大越好。突发太大,会长时间占用总线,其他外设(比如定时器、ADC)可能抢不到总线。我有个项目,SPI DMA设了16拍突发,结果定时器中断延迟从5μs飙到50μs,电机控制直接抖成筛子。

突发传输配置建议:

  • SPI速率 < 10MHz:用4拍突发就够了。
  • SPI速率 10-30MHz:用8拍突发,平衡性能和总线占用。
  • SPI速率 > 30MHz:用16拍突发,但务必确认总线带宽够用。
  • 多外设共享DMA时:用4拍突发,避免饿死其他外设。

3.5 DMA传输的完整流程(SVG图)

下面这张图,把DMA从触发到完成的整个流程串起来了。我建议你对照着看,比光看文字清楚得多。

DMA传输完整流程图 外设触发请求 DMA仲裁(优先级) 源地址 → 目的地址(递增/固定) 传输宽度:8/16/32位 传输完成 → 中断 关键配置项 • 源/目的地址模式 • 传输宽度匹配 • 突发大小选择 常见坑点 • 地址递增方向反了 • 宽度不匹配丢数据 • 突发太大饿死其他外设

3.6 实战配置示例:SPI DMA接收

拿STM32F4系列举例,配置一个SPI DMA接收通道。我习惯用HAL库,但底层寄存器也得懂,不然出了问题不好定位。

// SPI DMA接收配置(以STM32F4为例)
void SPI_DMA_Receive_Config(void)
{
    // 1. 使能DMA时钟
    __HAL_RCC_DMA2_CLK_ENABLE();
    
    // 2. 配置DMA流(Stream 0,通道3对应SPI1_RX)
    hdma_spi_rx.Instance = DMA2_Stream0;
    hdma_spi_rx.Init.Channel = DMA_CHANNEL_3;
    hdma_spi_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;  // 外设→内存
    hdma_spi_rx.Init.PeriphInc = DMA_PINC_DISABLE;      // 外设地址固定
    hdma_spi_rx.Init.MemInc = DMA_MINC_ENABLE;          // 内存地址递增
    hdma_spi_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;  // 8位
    hdma_spi_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;     // 8位
    hdma_spi_rx.Init.Mode = DMA_CIRCULAR;               // 循环模式(环形缓冲区)
    hdma_spi_rx.Init.Priority = DMA_PRIORITY_HIGH;      // 高优先级
    hdma_spi_rx.Init.FIFOMode = DMA_FIFOMODE_ENABLE;    // 使能FIFO
    hdma_spi_rx.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL;  // 半满触发
    hdma_spi_rx.Init.MemBurst = DMA_MBURST_SINGLE;      // 单次突发(先保守)
    hdma_spi_rx.Init.PeriphBurst = DMA_PBURST_SINGLE;
    
    // 3. 初始化DMA
    HAL_DMA_Init(&hdma_spi_rx);
    
    // 4. 链接DMA到SPI
    __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi_rx);
    
    // 5. 启动DMA传输(缓冲区地址、长度)
    HAL_SPI_Receive_DMA(&hspi1, rx_buffer, BUFFER_SIZE);
}

配置要点:

  • 循环模式:配合环形缓冲区使用,传输完自动从头开始,不用手动重配。
  • FIFO阈值:设成半满触发,可以减少中断频率。但要注意FIFO深度,别溢出。
  • 突发大小:先设成单次,调通后再改大。我习惯逐步增加,直到总线占用报警为止。

3.7 性能调优:突发传输的实战经验

突发传输不是越大越好。我分享一个实际案例:

项目是做高速数据采集,SPI时钟40MHz,DMA把数据搬进环形缓冲区。一开始设了16拍突发,结果发现:

  • DMA传输效率确实高,总线利用率到90%。
  • 但定时器中断延迟从3μs涨到30μs,PWM输出开始抖动。
  • ADC的DMA传输也被堵住,采样率掉了一半。

后来我把突发降到8拍,总线利用率降到70%,但所有外设都正常工作。你想想看,为了10%的效率提升,牺牲系统实时性,值不值?

突发传输避坑指南:

  • 我曾经把突发设成16拍,结果SPI的NSS信号被拉高,通信直接断掉——因为DMA占总线太久,SPI超时了。
  • 多通道DMA共享总线时,建议所有通道都用4拍或8拍突发,避免优先级低的通道饿死。
  • 如果系统里有时钟敏感的外设(如I2S、定时器),突发大小不要超过8拍。

嗯,DMA控制器的原理就这些。记住一句话:配置越简单,系统越稳定。别为了追求极致性能,把系统搞崩了。