3. DMA 传输类型:四种模式深度解析

各位好,这一章我们聊聊DMA的四种传输类型。很多资料讲得挺清楚,但我在项目里发现,不少工程师对这几种类型的本质区别其实没有吃透。结果就是配置错了,数据丢了,还得回头查手册。说实话,我也是踩过坑才真正搞明白的。

核心知识点:DMA传输类型由源地址和目的地址的地址空间归属决定,而不是由数据流向决定。记住这个根本,后面就好理解了。
DMA四种传输类型核心图 DMA控制器 内存 → 内存 内存 → 外设 外设 → 内存 循环传输 地址空间归属决定传输类型,而非数据流向

3.1 内存到内存传输

这是最基础的模式,也是很多新手最先接触到的。说白了,就是DMA自己把数据从一块内存搬到另一块内存。CPU只负责发个启动指令,剩下的事DMA全包了。

属性 说明
源地址 系统内存(DDR/SRAM)
目的地址 系统内存(DDR/SRAM)
典型场景 图像帧缓冲拷贝、协议栈数据包搬运、内存加密/校验前数据整理
关键参数 源地址增量/固定、目的地址增量/固定、传输宽度(字节/半字/字)

我在项目中遇到过一个问题:用DMA做内存拷贝,源和目的地址都设了增量,结果拷贝出来的数据是错的。排查了半天,发现是地址对齐没处理好。比如你要拷贝一个未对齐的字节数组,DMA如果配置成32位传输,就会出错。你想想看,硬件不知道你的数据结构是什么,它只管按配置的宽度去搬数据。

避坑指南:我曾经因为内存到内存的地址对齐问题,浪费了一整个下午。建议你写代码前,先确认源地址和目的地址是否满足DMA控制器的对齐要求。如果不满足,要么改成字节传输模式(效率低),要么提前做内存对齐。

3.2 内存到外设传输

这个模式最常见。CPU先把要发送的数据准备好,放在内存里,然后告诉DMA:「帮我把这些数据送到外设的发送FIFO里去。」我习惯把这个过程叫做「往外倒数据」。

配置要点主要有三个:

  • 地址属性:源地址来自内存(通常设增量模式),目的地址是外设寄存器地址(设固定模式)。
  • 触发信号:一般由外设的「发送就绪」信号触发。举个例子,UART的TXFIFO空时,会向DMA发送请求,DMA收到后就搬一次数据。
  • 突发传输:如果你的外设支持突发(比如AHB总线上的外设),可以把DMA配置成一次传输多个数据,这样效率更高。但别贪心,突发长度受外设FIFO深度限制。
// 伪代码:配置DMA进行内存到外设传输
dma_config_t cfg;
cfg.src_addr     = (uint32_t)tx_buffer;     // 内存缓冲区
cfg.dst_addr     = (uint32_t)&UART->DR;     // UART数据寄存器
cfg.src_inc      = DMA_INC_ENABLE;          // 源地址递增
cfg.dst_inc      = DMA_INC_DISABLE;         // 目的地址固定
cfg.transfer_width = DMA_WIDTH_8BIT;        // 1字节传输
cfg.burst_size   = DMA_BURST_4;             // 4次突发
cfg.trigger      = DMA_TRIG_UART_TX;        // UART发送触发
dma_start(&cfg);

嗯,这里要注意一点:配置好触发信号后,一定要确认外设的DMA请求是否已经使能。我记得有一次我怎么调都没有数据发出去,查了半天,原来是UART模块内部的DMA发送请求位没有置位。这个细节,数据手册里通常写在小字部分,容易被忽略。

3.3 外设到内存传输

和上面正好相反,这是把外设收到的数据搬回内存。最常见的场景:ADC采集、UART接收、SPI从设备接收、以及摄像头数据采集。

我个人觉得,这个模式对实时性的要求比前两个都高。为什么?因为外设的接收FIFO通常很小,比如就16字节。你不及时搬走,FIFO溢出了,数据就丢了。

外设类型 FIFO深度(典型值) 建议DMA传输策略
UART 低速 16字节 单次传输,触发阈值设到8字节再请求DMA
SPI 高速 32-64字节 突发传输,开启循环模式持续接收
ADC 连续采样 单个寄存器 每次转换完成立即触发DMA,采用循环模式

配置时,地址对齐是另一个容易踩的坑。外设数据寄存器的地址可能不是自然对齐的。比如某个8位宽度的外设,它的数据寄存器地址是0x4000_1001。如果你把DMA配置成32位传输,那就出问题了——它会尝试从0x4000_1000读32位数据,结果读到了其他外设的寄存器上。我建议你遇到这种外设时,老老实实按外设数据宽度去配置DMA。

小技巧:在外设到内存的场景下,我习惯用双缓冲(ping-pong buffer)模式。一个缓冲区在给CPU处理,另一个缓冲区在由DMA填数据。这样CPU和DMA可以并行工作,吞吐量直接翻倍。

3.4 循环传输模式

循环模式就是当传输完成后,地址计数器自动回滚到初始值,然后继续下一轮传输。这个模式在连续数据流的场景下非常有用,比如音频播放、数据采集、以及电机控制的电流环。

循环模式的关键参数只有一个:周期长度。就是每轮传输多少字节。比如我要采集1024个点的数据,每轮传输完成后自动从头开始填。

// 伪代码:配置循环传输
dma_config_t cfg;
cfg.src_addr     = (uint32_t)&ADC->DR;      // ADC数据寄存器
cfg.dst_addr     = (uint32_t)adc_buf;        // 内存缓冲区
cfg.dst_inc      = DMA_INC_ENABLE;          // 目的地址递增
cfg.block_size   = 1024;                     // 每轮1024次传输
cfg.circular     = DMA_CIRCULAR_ENABLE;     // 开启循环模式
cfg.trigger      = DMA_TRIG_ADC_READY;      // ADC转换完成触发
dma_start(&cfg);

但在实战中,循环模式有个大坑——数据竞争。你想想看,DMA在往缓冲区写数据的时候,CPU也在读同一块缓冲区。如果指针管理不好,CPU读到一半的数据被DMA覆盖了,那就产生坏数据了。

避坑指南:我曾经做一个音频采集项目,用了循环模式但没加保护。结果回放时经常听到杂音,排查了一周才发现是CPU和DMA同时操作同一个缓冲区。解决方案是:把缓冲区分成两半,DMA写前半段时CPU读后半段,反之亦然,靠一个标志位来切换。后来我在所有循环模式的项目里都用这个方法,再没出过问题。

3.5 四种类型对比速查

传输类型 源地址 目的地址 典型触发源 常用配置
内存→内存 内存(增量) 内存(增量/固定) 软件启动 对齐检查、突发传输
内存→外设 内存(增量) 外设(固定) 外设请求 触发匹配、FIFO深度
外设→内存 外设(固定) 内存(增量) 外设请求 地址对齐、双缓冲
循环传输 取决于功能 取决于功能 外设请求 周期长度、数据竞争保护

最后总结一句:不管用哪种类型,核心就是搞清楚你的数据「从哪里来、到哪里去」。源地址和目的地址的归属决定了你选哪种传输类型,而传输类型又决定了DMA控制器的配置方式。把这些理清了,DMA基本就不会出大问题。