3. DMA传输类型:内存到内存、内存到外设、外设到内存、外设到外设传输的差异与配置

DMA传输类型,说白了就是数据从哪搬到哪的问题。很多初学者觉得这四种类型差不多,不就是把数据从一个地方搬到另一个地方吗?其实差别大了去了。我刚开始做驱动时,就因为没搞清楚内存到外设和外设到内存的区别,折腾了整整两天。

咱们先看一张整体关系图,把四种传输类型放在一起对比,心里就有谱了。

DMA 四种传输类型总览 DMA 控制器 系统内存 (DDR/SRAM) 外设 (UART/SPI/ADC) 外设2 (GPIO/Timer) 内存2 (另一片区域) 内存→内存 内存→外设 外设→内存 外设→外设 说明:实线为常用传输类型(内存↔外设),虚线为特殊场景(内存↔内存、外设↔外设) DMA控制器作为数据中转站,不参与数据运算,只负责搬运

3.1 内存到内存传输

内存到内存,就是把数据从内存的一块区域搬到另一块区域。听起来像memcpy对吧?没错,本质上就是硬件版的memcpy。

适用场景:

  • 大块数据拷贝(比如图像帧缓冲区的复制)
  • 数据整理和重排
  • 双缓冲切换时的数据迁移

关键差异:内存到内存传输不需要外设触发,由软件主动发起。DMA控制器配置好源地址、目的地址和传输长度后,启动传输即可。

配置要点:

  • 源地址和目的地址都必须是内存地址
  • 通常需要设置地址递增模式(源和目的都递增)
  • 传输宽度可以灵活配置(8位、16位、32位、64位)
  • 注意内存对齐要求,有些DMA控制器要求地址按传输宽度对齐
// 内存到内存传输配置示例(伪代码)
struct dma_transfer_config cfg;

cfg.src_addr      = (uint32_t)src_buffer;    // 源内存地址
cfg.dst_addr      = (uint32_t)dst_buffer;    // 目的内存地址
cfg.transfer_size = 4096;                    // 传输4KB
cfg.src_inc       = DMA_ADDR_INCREMENT;      // 源地址递增
cfg.dst_inc       = DMA_ADDR_INCREMENT;      // 目的地址递增
cfg.data_width    = DMA_WIDTH_32BIT;         // 32位传输
cfg.transfer_mode = DMA_MODE_MEM2MEM;        // 内存到内存模式

dma_start_transfer(dma_handle, &cfg);

我的经验:内存到内存传输看起来简单,但有个坑——它占用了总线带宽。我在一个项目中用DMA做内存拷贝,结果CPU访问内存时被严重拖慢。后来我加了优先级控制,把DMA优先级调低,问题才解决。

3.2 内存到外设传输

内存到外设,就是把内存中的数据发送给外设。比如通过UART发送数据、通过SPI输出波形、通过DAC输出模拟信号。

适用场景:

  • UART/SPI/I2C数据发送
  • DAC波形输出
  • LCD显示数据刷新
  • PWM占空比更新

配置要点:

  • 源地址是内存地址(递增),目的地址是外设寄存器地址(固定)
  • 需要配置外设触发信号(比如UART TX FIFO空触发)
  • 传输宽度必须与外设寄存器宽度匹配
  • 注意外设FIFO深度,避免溢出
// 内存到外设传输配置示例(UART发送)
struct dma_transfer_config cfg;

cfg.src_addr      = (uint32_t)tx_buffer;     // 发送数据缓冲区
cfg.dst_addr      = (uint32_t)UART_DR;       // UART数据寄存器
cfg.transfer_size = tx_length;               // 发送长度
cfg.src_inc       = DMA_ADDR_INCREMENT;      // 源地址递增
cfg.dst_inc       = DMA_ADDR_NO_INCREMENT;   // 目的地址固定
cfg.data_width    = DMA_WIDTH_8BIT;          // UART通常是8位
cfg.transfer_mode = DMA_MODE_MEM2PERIPH;     // 内存到外设模式
cfg.handshake     = DMA_HANDSHAKE_UART_TX;   // UART发送握手信号

dma_start_transfer(dma_handle, &cfg);

注意:外设寄存器地址通常是固定的,不要设置目的地址递增。我曾经见过有人把外设地址也设成递增,结果数据写到了外设地址后面的未知区域,系统直接挂了。

3.3 外设到内存传输

外设到内存,就是把外设接收到的数据存到内存里。比如UART接收数据、ADC采样结果存储、SPI从设备读取数据。

适用场景:

  • UART/SPI/I2C数据接收
  • ADC连续采样
  • 摄像头数据采集
  • 定时器捕获数据

配置要点:

  • 源地址是外设寄存器地址(固定),目的地址是内存地址(递增)
  • 需要配置外设触发信号(比如UART RX FIFO非空触发)
  • 注意环形缓冲区的使用,避免数据覆盖
  • 传输完成中断和半传输中断的合理使用
// 外设到内存传输配置示例(ADC采样)
struct dma_transfer_config cfg;

cfg.src_addr      = (uint32_t)ADC_DR;        // ADC数据寄存器
cfg.dst_addr      = (uint32_t)adc_buffer;    // 采样数据缓冲区
cfg.transfer_size = 1024;                    // 采样1024个点
cfg.src_inc       = DMA_ADDR_NO_INCREMENT;   // 源地址固定
cfg.dst_inc       = DMA_ADDR_INCREMENT;      // 目的地址递增
cfg.data_width    = DMA_WIDTH_16BIT;         // ADC通常是16位
cfg.transfer_mode = DMA_MODE_PERIPH2MEM;     // 外设到内存模式
cfg.handshake     = DMA_HANDSHAKE_ADC;       // ADC转换完成握手信号

dma_start_transfer(dma_handle, &cfg);

我的习惯:做外设到内存传输时,我通常会开启半传输中断和传输完成中断。半传输中断用来处理前半段数据,传输完成中断处理后半段。这样就能实现流水线处理,不丢数据。

3.4 外设到外设传输

外设到外设,就是数据从一个外设直接传到另一个外设,不经过内存。这个用得比较少,但在某些场景下非常有用。

适用场景:

  • 定时器触发ADC采样,ADC结果直接送给DAC输出
  • GPIO电平变化直接触发SPI传输
  • 数据流处理中的外设级联

配置要点:

  • 源地址和目的地址都是外设寄存器地址(都固定)
  • 需要两个外设的握手信号配合
  • 传输宽度必须一致
  • 时序要求严格,需要仔细验证
// 外设到外设传输配置示例(定时器触发ADC→DAC)
struct dma_transfer_config cfg;

cfg.src_addr      = (uint32_t)ADC_DR;        // ADC数据寄存器
cfg.dst_addr      = (uint32_t)DAC_DR;        // DAC数据寄存器
cfg.transfer_size = 1;                       // 每次传输1个数据
cfg.src_inc       = DMA_ADDR_NO_INCREMENT;   // 源地址固定
cfg.dst_inc       = DMA_ADDR_NO_INCREMENT;   // 目的地址固定
cfg.data_width    = DMA_WIDTH_16BIT;         // 16位数据
cfg.transfer_mode = DMA_MODE_PERIPH2PERIPH;  // 外设到外设模式
cfg.handshake     = DMA_HANDSHAKE_TIM_ADC;   // 定时器触发ADC

dma_start_transfer(dma_handle, &cfg);

我曾经踩过的坑:外设到外设传输对时序要求极高。有一次我做定时器触发ADC到DAC的直通,结果DAC输出的波形一直有毛刺。查了两天才发现,ADC的转换完成信号和DMA的请求信号之间有时序偏差,需要加一个FIFO做缓冲。

3.5 四种传输类型对比总结

传输类型 源地址 目的地址 触发方式 典型应用 复杂度
内存→内存 内存(递增) 内存(递增) 软件触发 数据拷贝、缓冲切换 低
内存→外设 内存(递增) 外设(固定) 外设请求 UART发送、DAC输出 中
外设→内存 外设(固定) 内存(递增) 外设请求 ADC采样、UART接收 中
外设→外设 外设(固定) 外设(固定) 外设请求 数据流直通、级联处理 高

核心要点:选择哪种传输类型,关键看数据流向。源和目的都是内存→内存到内存;源是内存、目的是外设→内存到外设;源是外设、目的是内存→外设到内存;源和目的都是外设→外设到外设。就这么简单。

3.6 实际项目中的选择建议

说了这么多理论,咱们聊聊实际项目中怎么选。

我的建议:

  • 能用内存到外设/外设到内存,就别用内存到内存。因为前两种是DMA的强项,后一种其实CPU做memcpy也不慢。
  • 外设到外设传输,除非你非常清楚两个外设的时序关系,否则慎用。我见过太多人在这个上面翻车。
  • 如果外设数据量很小(比如几个字节),用中断处理可能比DMA更高效。DMA的配置开销有时候比传输本身还大。
  • 注意DMA通道的优先级设置。高优先级的外设(比如音频)应该用高优先级通道,低优先级的外设(比如调试串口)用低优先级通道。

我的经验:做项目时,我一般先画数据流图,标清楚数据从哪来到哪去,然后再决定用哪种DMA传输类型。画图花10分钟,能省后面调试的半天时间。