4. DMA 传输模式选择:内存到内存、内存到外设、外设到内存、外设到外设

DMA 的传输模式,说白了就是数据从哪搬到哪。很多初学者觉得这有什么好选的,不就是配置几个寄存器吗?

我刚开始做嵌入式开发时也这么想。直到有一次,一个音频采集项目死活跑不满采样率,查了三天才发现是 DMA 模式选错了。嗯,从那以后我再也不敢小看这个环节了。

4.1 四种传输模式的核心差异

DMA 控制器通常支持四种传输方向。它们的区别不仅仅是源和目标不同,还涉及总线仲裁、数据宽度对齐、传输完成信号等细节。

传输模式 源地址 目标地址 总线占用 典型触发方式
内存到内存 内存 内存 CPU 总线 + DMA 总线 软件触发
内存到外设 内存 外设 FIFO/寄存器 DMA 总线为主 外设请求
外设到内存 外设 FIFO/寄存器 内存 DMA 总线为主 外设请求
外设到外设 外设 A 外设 B DMA 总线独占 外设请求

关键点:内存到内存模式通常需要 CPU 参与总线仲裁,而外设相关的模式可以由 DMA 独立完成。这个差异直接影响系统吞吐量。

4.2 内存到内存:最容易被忽视的坑

这种模式常用于数据搬移、缓冲区拷贝、图像帧复制等场景。配置起来最简单——源地址、目标地址、传输长度,搞定。

但我在项目中遇到过一个问题:用 DMA 做内存拷贝,结果比 CPU 直接 memcpy 还慢。为什么会这样?

  • 总线竞争:内存到内存模式下,DMA 和 CPU 都在抢系统总线。如果总线带宽不够,DMA 反而成了瓶颈。
  • 缓存一致性问题:DMA 直接操作物理内存,而 CPU 可能还有 Cache。数据不一致时,你读到的可能是脏数据。
  • 传输粒度:很多 DMA 控制器要求源地址和目标地址按 4 字节对齐。不对齐时,性能会断崖式下跌。

我的习惯:小数据量(小于 64 字节)用 CPU memcpy,大数据量(大于 1KB)才用 DMA。中间区域,实测为准。

4.3 内存到外设:最常见的应用场景

说白了就是「把数据喂给外设」。比如 SPI 发送数据、I2C 写寄存器、UART 发字符串、DAC 输出波形。

配置时要注意几个点:

  1. 外设地址固定:目标地址通常是外设的数据寄存器,不会自增。DMA 要配成「目标地址不变」模式。
  2. 触发源匹配:DMA 通道必须绑定到对应的外设请求线。比如 USART1_TX 对应 DMA1 通道 4。
  3. 数据宽度对齐:外设寄存器通常是 8/16/32 位,内存中的数据宽度要匹配。不匹配时,DMA 会自动拆分或填充,但会损失性能。
// 以 STM32 为例,配置内存到外设(UART 发送)
DMA_InitTypeDef DMA_InitStruct = {0};

DMA_InitStruct.PeriphBaseAddr  = (uint32_t)&USART1->DR;  // 外设地址,固定
DMA_InitStruct.MemoryBaseAddr  = (uint32_t)tx_buffer;    // 内存地址,自增
DMA_InitStruct.Direction       = DMA_MEMORY_TO_PERIPH;   // 方向
DMA_InitStruct.BufferSize      = tx_len;
DMA_InitStruct.PeriphInc       = DMA_PINC_DISABLE;       // 外设地址不变
DMA_InitStruct.MemInc          = DMA_MINC_ENABLE;        // 内存地址自增
DMA_InitStruct.PeriphDataWidth = DMA_PDATAALIGN_BYTE;    // 8 位
DMA_InitStruct.MemDataWidth    = DMA_MDATAALIGN_BYTE;    // 8 位
DMA_InitStruct.Mode            = DMA_NORMAL;             // 单次传输
DMA_InitStruct.Priority        = DMA_PRIORITY_HIGH;

HAL_DMA_Init(&hdma_usart1_tx, &DMA_InitStruct);

我曾经踩过的坑:外设地址配成了自增模式。结果每次传输后地址偏移,数据全写到了错误的位置。UART 发出的全是乱码。排查了整整一天。

4.4 外设到内存:数据采集的核心路径

ADC 采样、SPI 接收、I2C 读传感器、UART 接收——这些场景都依赖外设到内存模式。

配置要点和内存到外设基本对称,但有三个特殊之处:

  • 溢出保护:外设数据到达速度可能快于 DMA 搬移速度。要开启 FIFO 或双缓冲机制。
  • 半传输中断:我习惯开启半传输中断,这样可以在 DMA 搬完一半数据时就开始处理,实现流水线操作。
  • 循环模式:连续采集场景(如音频、示波器)要用循环模式,DMA 自动回绕到缓冲区开头。

性能调优技巧:外设到内存时,尽量让 DMA 使用「突发传输」模式。一次突发搬 4/8/16 个数据,比单次传输效率高 30%-50%。

4.5 外设到外设:用得少,但用好了很惊艳

这种模式把数据从一个外设直接搬到另一个外设,不经过内存。比如:

  • ADC 采集的数据直接送给 DAC 输出
  • SPI 接收的数据直接送给 I2C 发送
  • 定时器捕获的数据直接送给 PWM 发生器

你想想看,CPU 完全不用参与,数据在外设之间「飞」过去了。延迟极低,吞吐量极高。

但配置起来也最麻烦:

  1. 两个外设都要支持 DMA:不是所有外设都能做 DMA 的源或目标。
  2. 时钟域同步:两个外设可能跑在不同的时钟域,DMA 要处理好跨时钟域传输。
  3. 数据宽度必须一致:源和目标的数据宽度必须完全匹配,否则数据会错位。

我的建议:除非对延迟有极致要求(比如高速信号链),否则尽量用「外设→内存→外设」的路径。调试起来方便很多。

4.6 模式选择决策流程

下面这张图是我自己总结的决策流程,每次做 DMA 配置时我都会过一遍:

DMA 传输模式选择决策流程 确定数据源和目标 源和目标是否都位于内存? 是 内存到内存模式 否 涉及外设传输 源和目标是否都是外设? 是 外设到外设模式 否 判断传输方向 外设→内存 外设到内存模式 内存→外设 内存到外设模式 提示:外设到外设模式配置复杂,建议优先考虑「外设→内存→外设」路径

4.7 实际项目中的选择建议

说了这么多理论,最后给点实在的:

  • 批量数据搬移:用内存到内存。但注意缓存一致性,记得做 Cache 刷新操作。
  • 外设发送:用内存到外设。开启双缓冲,避免传输间隙。
  • 外设接收:用外设到内存。开启循环模式 + 半传输中断,实现无间断采集。
  • 高速信号链:用外设到外设。但要做好时钟域同步和错误处理。

重要提醒:无论选哪种模式,一定要在系统层面做吞吐量测试。理论带宽和实际带宽之间,往往差着一个「总线竞争」的距离。