4. DMA 传输模式详解:内存到内存、内存到外设、外设到内存、循环模式与单次模式

DMA 的传输模式,说白了就是数据从哪搬到哪,以及搬完一次之后怎么办。很多刚入行的朋友觉得这玩意儿就是配几个寄存器,其实不然。我见过不少项目,DMA 配错了模式,要么数据错位,要么 CPU 被频繁打断,性能反而更差。

今天咱们就把这几种模式彻底捋清楚。我会结合自己踩过的坑,帮你建立一套「模式选择直觉」。

4.1 四种基本传输方向

DMA 传输方向由硬件通道的配置决定。绝大多数 MCU 的 DMA 控制器都支持以下四种组合:

传输模式 源地址 目的地址 典型场景
内存到内存 (M2M) 内存 内存 数据拷贝、帧缓冲搬运、CRC 计算
内存到外设 (M2P) 内存 外设 FIFO/DR DAC 波形输出、SPI 发送、UART 发送
外设到内存 (P2M) 外设 FIFO/DR 内存 ADC 采样、SPI 接收、UART 接收
外设到外设 (P2P) 外设 外设 定时器触发 ADC、GPIO 到定时器

关键点:M2M 模式通常需要软件显式触发,而 M2P 和 P2M 由外设的硬件请求信号自动触发。P2P 模式不是所有 DMA 控制器都支持,选型时要看数据手册。

4.2 内存到内存 (M2M) —— 最纯粹的搬运工

M2M 模式不需要外设参与。CPU 只需要告诉 DMA:「从地址 A 搬 N 个字节到地址 B」,DMA 就会自己干完。

我个人习惯用 M2M 做三件事:

  • 快速 memcpy 替代品 —— 当 CPU 的 memcpy 函数成为瓶颈时,DMA 可以释放 CPU 去处理更高优先级的任务。
  • 图像帧缓冲翻转 —— 双缓冲切换时,用 DMA 把后台缓冲搬到前台显示区。
  • 数据格式转换前的整理 —— 比如把分散的传感器数据拼成一个连续报文。

这里有个坑:M2M 模式下,DMA 会占用总线带宽。如果 CPU 也在频繁访问内存,两者会争抢总线。我在一个项目中遇到过,DMA 搬运大块数据时,CPU 的指令预取被阻塞,导致中断响应延迟飙升。解决办法是降低 DMA 的优先级,或者把搬运任务拆成小块。

// 伪代码:配置 M2M 传输
dma_channel_config_t cfg = {
    .src_addr      = (uint32_t)src_buf,
    .dst_addr      = (uint32_t)dst_buf,
    .transfer_size = 1024,          // 1024 字节
    .src_inc       = DMA_ADDR_INC,  // 源地址递增
    .dst_inc       = DMA_ADDR_INC,  // 目的地址递增
    .mode          = DMA_MODE_M2M,
    .trigger       = DMA_TRIGGER_SOFTWARE  // 软件触发
};
dma_channel_start(DMA1, CH0, &cfg);

4.3 内存到外设 (M2P) —— 喂饱外设的嘴

M2P 模式的核心逻辑是:外设「饿」了,就发一个请求信号给 DMA,DMA 从内存拿一个数据喂给外设。

举个例子,你用 SPI 驱动一个 DAC 输出正弦波。传统做法是 CPU 在定时器中断里往 SPI 数据寄存器写值。用 DMA 的话,你只需要把正弦波表放在内存里,配置好 DMA,剩下的全交给硬件。

我曾经调试过一个音频播放项目,用 I2S 接口输出音频数据。一开始 CPU 占用率高达 60%,换成 DMA M2P 模式后,CPU 占用率直接降到 5% 以下。嗯,这就是 DMA 的价值。

经验之谈:M2P 模式下,外设的数据宽度必须和 DMA 配置一致。比如外设是 16 位寄存器,DMA 也配成 16 位。否则会出现数据错位——我因为这个 bug 排查了整整一个下午。

4.4 外设到内存 (P2M) —— 捕获外部数据

P2M 是 M2P 的逆过程。外设每产生一个数据,就通知 DMA 来取走,存入内存缓冲区。

最常见的场景是 ADC 连续采样。你配置好 ADC 的采样率,DMA 自动把结果搬到一个环形缓冲区里。CPU 只需要在缓冲区满时去读,或者用双缓冲机制避免数据覆盖。

这里要注意一个细节:P2M 模式下,外设的请求信号可能比 DMA 的处理速度快。如果外设连续产生数据,而 DMA 来不及搬走,就会发生 溢出 (Overrun)。我建议在硬件设计时,给外设配一个 FIFO,或者降低外设的触发频率。

// 伪代码:配置 ADC -> DMA P2M 传输
dma_channel_config_t cfg = {
    .src_addr      = (uint32_t)&(ADC1->DR),  // ADC 数据寄存器
    .dst_addr      = (uint32_t)adc_buffer,
    .transfer_size = 256,           // 采样 256 次
    .src_inc       = DMA_ADDR_FIXED, // 外设地址固定
    .dst_inc       = DMA_ADDR_INC,   // 内存地址递增
    .mode          = DMA_MODE_P2M,
    .trigger       = DMA_TRIGGER_ADC  // ADC 硬件触发
};
dma_channel_start(DMA1, CH1, &cfg);

4.5 单次模式 vs 循环模式

说完方向,咱们聊聊「次数」。DMA 传输完成后,有两种行为:

  • 单次模式 (Single/One-shot):传输完设定的数据量后,DMA 通道自动关闭。需要重新配置才能再次启动。
  • 循环模式 (Circular/Auto-reload):传输完设定的数据量后,地址和计数器自动重置,从头开始新一轮传输。永远不停。
特性 单次模式 循环模式
传输完成后行为 停止,触发完成中断 自动重置,继续传输
CPU 干预频率 每次传输完都需要重新配置 几乎不需要干预
适用场景 一次性数据拷贝、协议包发送 连续采样、音频流、PWM 波形
缓冲区管理 简单,一次填满一次读完 需要双缓冲或环形缓冲

警告:循环模式下,如果你不及时读取缓冲区数据,新数据会覆盖旧数据。我曾经在一个电机控制项目中,因为循环缓冲区读指针没跟上,导致控制指令用了过时的采样值,电机直接抖了起来。解决方案是使用「半传输中断」——在缓冲区一半位置触发中断,让 CPU 及时处理。

4.6 模式选择决策树

说了这么多,到底怎么选?我画了一张图,帮你快速决策:

DMA 传输模式选择决策树 DMA 传输模式选择 数据源和目标是什么? 内存 → 内存 内存 → 外设 外设 → 内存 传输完成后是否继续? 单次模式 (One-shot) 循环模式 (Circular) 一次性数据拷贝 协议包发送/接收 连续 ADC 采样 音频流 / 波形输出 提示:P2P 模式较少用,未在图中展开

4.7 实战中的模式组合

实际项目中,很少只用一种模式。我举两个常见的组合:

组合一:P2M 循环 + M2P 单次

ADC 用循环模式连续采样,数据存入环形缓冲区。CPU 在缓冲区半满时处理数据,然后通过 M2P 单次模式把处理结果发送到 DAC 或显示器。这种组合在数字信号处理中很常见。

组合二:M2M 单次 + P2M 单次

先通过 P2M 从外设接收一帧数据,然后用 M2M 把数据拷贝到协议栈的缓冲区。两个 DMA 通道可以串联——第一个完成时触发第二个启动。嗯,这就是所谓的「DMA 链式传输」,后面章节会详细讲。

避坑指南:我曾经在一个项目中同时用了 M2P 循环和 P2M 循环,结果两个 DMA 通道抢总线,导致音频数据出现周期性毛刺。后来我把 P2M 的优先级调高,M2P 的优先级调低,问题就解决了。记住:输入通道的优先级通常应该高于输出通道,因为输入数据丢了就没了,输出数据可以等一等。

4.8 小结

DMA 传输模式的选择,本质上是在回答两个问题:

  1. 数据从哪来,到哪去? —— 决定传输方向 (M2M/M2P/P2M/P2P)
  2. 搬完一次还搬不搬? —— 决定单次还是循环

把这两个问题想清楚,DMA 配置就完成了一半。剩下的就是寄存器细节和调试了。调试部分我们后面专门有一章来讲,这里先不展开。