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 输出波形。
配置时要注意几个点:
- 外设地址固定:目标地址通常是外设的数据寄存器,不会自增。DMA 要配成「目标地址不变」模式。
- 触发源匹配:DMA 通道必须绑定到对应的外设请求线。比如 USART1_TX 对应 DMA1 通道 4。
- 数据宽度对齐:外设寄存器通常是 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 完全不用参与,数据在外设之间「飞」过去了。延迟极低,吞吐量极高。
但配置起来也最麻烦:
- 两个外设都要支持 DMA:不是所有外设都能做 DMA 的源或目标。
- 时钟域同步:两个外设可能跑在不同的时钟域,DMA 要处理好跨时钟域传输。
- 数据宽度必须一致:源和目标的数据宽度必须完全匹配,否则数据会错位。
我的建议:除非对延迟有极致要求(比如高速信号链),否则尽量用「外设→内存→外设」的路径。调试起来方便很多。
4.6 模式选择决策流程
下面这张图是我自己总结的决策流程,每次做 DMA 配置时我都会过一遍:
4.7 实际项目中的选择建议
说了这么多理论,最后给点实在的:
- 批量数据搬移:用内存到内存。但注意缓存一致性,记得做 Cache 刷新操作。
- 外设发送:用内存到外设。开启双缓冲,避免传输间隙。
- 外设接收:用外设到内存。开启循环模式 + 半传输中断,实现无间断采集。
- 高速信号链:用外设到外设。但要做好时钟域同步和错误处理。
重要提醒:无论选哪种模式,一定要在系统层面做吞吐量测试。理论带宽和实际带宽之间,往往差着一个「总线竞争」的距离。