3. DMA传输类型:内存到内存、内存到外设、外设到内存、外设到外设传输的差异与配置
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分钟,能省后面调试的半天时间。