第2章 DMA控制器架构:通道、请求映射与传输模式

DMA控制器,说白了就是CPU的「数据搬运工」。CPU告诉它「把A搬到B」,它就能自己干完,CPU腾出手来处理更重要的计算任务。这一章我们深入看看这个搬运工的内部结构和工作模式。

2.1 DMA通道与请求映射

每个DMA控制器都有多个通道(Channel),每个通道独立管理一条数据传输链路。我最早接触STM32的DMA时,看到它有7个通道,心想「这么多怎么用?」后来发现,每个通道其实对应一组固定的外设请求。

核心概念:通道是DMA传输的独立执行单元,每个通道有自己的源地址、目的地址、传输计数和配置寄存器。多个通道可以分时复用总线,但同一时刻只能有一个通道传输数据。

请求映射表(以STM32F4为例)

DMA通道 外设请求1 外设请求2 外设请求3
通道0 SPI1_RX USART1_TX TIM2_UP
通道1 SPI1_TX USART1_RX TIM3_CH1
通道2 I2C1_RX USART2_TX TIM4_UP
通道3 I2C1_TX USART2_RX TIM5_CH2
通道4 SPI2_RX USART3_TX TIM1_UP

这张表怎么读?举个例子:你想用DMA从SPI1接收数据,那就必须用通道0。想用DMA发送数据到USART1,也得用通道0。同一个通道可以服务多个外设,但不能同时——你得在运行时切换请求映射。

我的经验:设计电路时,我会提前把DMA通道和外设的对应关系画成表格。有一次项目里SPI和I2C抢同一个DMA通道,调试了两天才发现是映射冲突。从那以后,我习惯在工程文档里专门列一页「DMA资源分配表」。

2.2 DMA传输类型

DMA能搬数据,但数据从哪搬到哪?这就引出了三种传输类型。我按实际项目中的使用频率排序讲。

2.2.1 外设到内存(Peripheral-to-Memory)

这是最常见的场景。ADC采集完数据,DMA自动搬到内存数组里;UART收到一帧数据,DMA直接存到接收缓冲区。CPU只需要在传输完成后去读内存就行。

// 配置ADC用DMA传输到内存
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralToMemory;  // 外设→内存
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)adc_buffer;
DMA_InitStruct.DMA_BufferSize = 100;
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_Init(DMA2_Stream0, &DMA_InitStruct);

这里有个坑:外设地址寄存器通常是固定的,但内存地址可以递增。配置时记得把内存地址增量打开,外设地址增量关掉——除非你连着读一串外设寄存器。

2.2.2 内存到外设(Memory-to-Peripheral)

反过来,CPU准备好数据,DMA负责发送给外设。比如用DMA发送一串数据到DAC生成波形,或者通过SPI发送显示缓冲区到LCD屏幕。

我曾经踩过的坑:用DMA发送数据到USART时,忘了检查USART的TXE(发送寄存器空)标志。DMA发得太快,USART还没准备好,数据就丢了。解决办法是配置DMA请求时,确保外设的请求信号正确——USART只有在TXE=1时才会发出DMA请求。

2.2.3 内存到内存(Memory-to-Memory)

这个模式不需要外设触发,DMA自己就能搬。常用于:

  • 快速拷贝大数组(比CPU用for循环快得多)
  • 图像数据在帧缓冲区间搬移
  • 协议栈中的数据包重组
// 内存到内存传输配置
DMA_InitStruct.DMA_DIR = DMA_DIR_MemoryToMemory;  // 内存→内存
DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)src_buffer;
DMA_InitStruct.DMA_Memory1BaseAddr = (uint32_t)dst_buffer;
DMA_InitStruct.DMA_BufferSize = 1024;  // 一次搬1024个字节
// 注意:内存到内存模式不需要外设触发,配置完直接启动

但要注意,内存到内存传输会占用总线。如果CPU也在同时访问内存,DMA会通过「总线仲裁」和CPU抢带宽。我做过一个测试:DMA搬1MB数据时,CPU的指令执行速度下降了约30%。

2.3 DMA传输模式

传输模式决定了DMA「搬一次还是搬多次」。三种模式对应不同的应用场景。

2.3.1 单次传输(Single Transfer)

每次触发只传输一个数据单元(字节/半字/字)。传输完成后,通道自动关闭。适合:

  • 按键扫描(按一次读一次)
  • 单次ADC采样
  • 调试时手动触发

2.3.2 循环传输(Circular Mode)

传输完设定的数据量后,自动回到起始地址重新开始。这是我最常用的模式,特别适合:

  • 音频数据流(环形缓冲区)
  • 连续ADC采集(比如电力监控)
  • 实时传感器数据记录

关键点:循环模式下,内存地址会回绕。你需要保证缓冲区大小是2的幂次,这样地址回绕时不会越界。我习惯用#define BUF_SIZE 256而不是#define BUF_SIZE 250。

2.3.3 突发传输(Burst Transfer)

一次触发传输多个数据单元。突发长度通常可配置为4、8、16个节拍。好处是:

  • 减少总线仲裁次数(一次申请用多次)
  • 提高传输效率(尤其适合高速外设)
  • 降低CPU干预频率

但突发传输也有代价:它会长时间占用总线,影响其他高优先级任务。我在一个多传感器项目中,DMA用16节拍突发传输,结果定时器中断响应延迟了20μs——差点导致PWM波形失真。

2.4 知识结构总览

下面这张图把DMA控制器的核心要素串起来了。我建议你把它截图保存,调试时对照着看。

DMA控制器架构总览图 DMA控制器架构总览 DMA控制器 通道0 通道1 通道2 通道3 请求映射矩阵 SPI1_RX → 通道0 | USART1_TX → 通道0 | I2C1_RX → 通道2 | TIM2_UP → 通道0 每个通道可映射多个外设请求,但同一时刻只能服务一个 外设 → 内存 内存 → 外设 内存 → 内存 单次传输 循环传输 突发传输 💡 调试建议:先确认通道映射正确,再选择传输类型,最后配置传输模式

2.5 三种传输模式对比

特性 单次传输 循环传输 突发传输
触发方式 每次触发传1个单元 自动回绕连续传 一次触发传多个单元
适用场景 单次采样、按键 音频、连续ADC 高速外设、大数据块
总线占用 低(瞬间完成) 持续占用 高(突发期间独占)
中断频率 每次传输都中断 每轮完成中断 每突发完成中断
配置复杂度 简单 中等(注意缓冲区大小) 较高(需匹配外设时序)

我的选择原则:如果外设速度慢(比如UART 115200bps),用单次传输就够了。如果外设速度快且数据量大(比如音频I2S),用循环传输配合环形缓冲区。突发传输我一般只在DMA带宽成为瓶颈时才用——比如驱动高分辨率LCD时。

2.6 调试避坑指南

最后分享几个我实际调试中遇到的坑:

  • 通道优先级冲突:高优先级通道会打断低优先级通道的传输。如果两个通道同时请求,高优先级的先执行。我遇到过定时器触发的DMA被SPI DMA打断,导致波形出现毛刺。
  • 传输完成标志未清除:DMA传输完成后,硬件会置位完成标志。下次启动前必须软件清除,否则DMA不会重新开始。这个坑我至少踩过三次。
  • 地址对齐问题:有些DMA要求源地址和目的地址按数据宽度对齐。比如传输32位数据时,地址必须是4的倍数。不对齐时,DMA可能产生错误传输或直接不工作。