2、DMA工作原理:DMA控制器结构、DMA传输流程(请求-响应-传输-结束)、DMA传输模式(单次/突发/循环)

好,咱们直接切入正题。DMA这东西,说白了就是给CPU请的一个「数据搬运工」。CPU本来又要算数又要搬砖,累得半死。有了DMA,CPU只管发号施令,搬砖的活儿全交给DMA干。

我个人习惯把DMA理解成一个「小号CPU」——它有自己的寄存器、自己的状态机,甚至有自己的总线控制权。只不过它不会做复杂运算,只会一件事:把数据从A搬到B。但就这一件事,它做得比CPU快得多。

2.1 DMA控制器结构:拆开看看里面有什么

一个典型的DMA控制器,内部大概长这样。我画个简化的框图给你看:

┌─────────────────────────────────────┐
│          DMA 控制器内部结构           │
├─────────────────────────────────────┤
│  ┌─────────┐  ┌─────────┐          │
│  │ 控制寄存器 │  │ 状态寄存器 │          │
│  ├─────────┤  ├─────────┤          │
│  │ 源地址寄存器│  │ 目的地址寄存器│          │
│  ├─────────┤  ├─────────┤          │
│  │ 传输计数寄存器│  │ 模式配置寄存器│          │
│  └─────────┘  └─────────┘          │
│  ┌─────────────────────────────┐    │
│  │        DMA 状态机 (FSM)       │    │
│  │  空闲 → 请求 → 响应 → 传输 → 结束  │    │
│  └─────────────────────────────┘    │
│  ┌─────────────────────────────┐    │
│  │       总线仲裁接口            │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

核心部件就这几个:

  • 源地址寄存器:存数据从哪来。可以是内存地址,也可以是外设地址。
  • 目的地址寄存器:存数据搬到哪去。同样支持内存或外设。
  • 传输计数寄存器:还剩多少字节没搬完。每搬一次就减一,减到零就触发中断。
  • 控制/状态寄存器:配置传输方向、模式、是否循环等。状态位告诉你DMA现在忙不忙。
  • 总线仲裁接口:这是关键。DMA要抢总线的控制权,得靠这个模块跟CPU「商量」。

重要: 源地址和目的地址的递增方式是可以独立配置的。比如从外设读数据时,源地址可以固定(外设寄存器就一个地址),目的地址递增(写入内存缓冲区)。这个细节很多新手会忽略。

2.2 DMA传输流程:请求-响应-传输-结束

整个流程分四步走。我拿一个实际场景举例:ADC采集数据,通过DMA搬到内存。

第一步:请求阶段

ADC转换完成,数据已经就绪。ADC硬件会拉高一个信号线——这就是DMA请求。DMA控制器检测到这个请求后,会进入响应阶段。

你想想看,如果CPU正在执行关键任务,DMA突然要抢总线,怎么办?这就涉及到总线仲裁了。

第二步:响应阶段

DMA控制器向总线仲裁器发出「我要用总线」的信号。仲裁器看当前总线谁在用:

  • 如果CPU空闲,直接放行
  • 如果CPU正在访问内存,会让CPU暂停一个总线周期(这叫「周期窃取」)
  • 如果CPU正在执行不可打断的操作(比如中断响应),DMA就得等

我曾经在一个项目里遇到过坑:DMA优先级设得太高,结果CPU连中断都响应不了,系统直接卡死。嗯,这里要注意——DMA优先级不是越高越好,得跟系统其他中断协调好。

第三步:传输阶段

拿到总线控制权后,DMA开始干活。它从源地址读数据,写到目的地址。每次传输完成后:

  • 源地址根据配置递增/递减/不变
  • 目的地址同样处理
  • 传输计数寄存器减1

传输完成后,DMA会释放总线控制权,把总线还给CPU。整个过程CPU完全不知道发生了什么——它只知道自己执行指令时,内存里的数据突然就变了。

第四步:结束阶段

当传输计数寄存器减到0,DMA会:

  1. 拉高完成信号线
  2. 如果使能了中断,向CPU发送中断请求
  3. 状态寄存器置位「传输完成」标志
  4. 如果是循环模式,自动重载计数值,从头开始

小技巧: 我建议在DMA完成中断里,不要做太多事情。只做两件事:清标志位、设置一个软件标志。真正的数据处理放到主循环里做。否则中断里处理数据,DMA又触发中断,容易嵌套出问题。

2.3 DMA传输模式:单次/突发/循环

这三种模式,我分别说说它们的应用场景和坑。

单次传输模式

每次DMA请求只传输一个数据单元(字节/半字/字)。传输完就停,等下一次请求。

适用场景: 外设数据产生速度很慢,比如按键扫描、温度传感器。来一个数据搬一个,不浪费总线带宽。

代码配置示例:

// 伪代码 - 单次传输模式配置
DMA_InitTypeDef dma_config;
dma_config.Mode = DMA_MODE_SINGLE;   // 单次模式
dma_config.SourceAddr = (uint32_t)&ADC->DR;
dma_config.DestAddr = (uint32_t)adc_buffer;
dma_config.TransferSize = 100;       // 总共搬100次
dma_config.SourceInc = DMA_INC_FIXED; // 外设地址不变
dma_config.DestInc = DMA_INC_INCREMENT; // 内存地址递增
DMA_Init(DMA1_Channel1, &dma_config);

突发传输模式

一次DMA请求,连续传输多个数据单元(比如4个、8个、16个)。传输期间一直占用总线。

适用场景: 高速外设,比如UART接收大量数据、SPI传输显示缓冲区。一次请求搬一批数据,效率高。

警告: 突发传输时,DMA会长时间占用总线。如果系统里有对实时性要求高的任务(比如电机控制PWM更新),可能会被延迟。我曾经在一个无人机飞控项目里,DMA突发传输导致PWM更新抖动,飞机差点炸了。后来改成单次传输+双缓冲才解决。

循环传输模式

传输完成后,自动重载计数值和地址,从头开始新一轮传输。无限循环,直到你手动停止。

适用场景: 音频播放、数据采集环形缓冲区、示波器波形刷新。这些场景需要连续不断地搬运数据。

环形缓冲区实现示例:

// 伪代码 - 循环模式 + 环形缓冲区
#define BUFFER_SIZE 256
uint16_t audio_buffer[BUFFER_SIZE];

DMA_InitTypeDef dma_config;
dma_config.Mode = DMA_MODE_CIRCULAR;  // 循环模式
dma_config.SourceAddr = (uint32_t)&I2S->DR;
dma_config.DestAddr = (uint32_t)audio_buffer;
dma_config.TransferSize = BUFFER_SIZE;
dma_config.SourceInc = DMA_INC_FIXED;
dma_config.DestInc = DMA_INC_INCREMENT;
DMA_Init(DMA1_Channel2, &dma_config);

// 启动后,DMA会一直往audio_buffer里写数据
// 写满256个后自动回到开头继续写
// CPU只需要在后台处理数据即可

关键点: 循环模式下,你要处理好「半传输中断」和「完成中断」。半传输中断时处理前半缓冲区,完成中断时处理后一半。这样CPU和DMA就能并行工作,互不干扰。这就是经典的双缓冲(ping-pong buffer)思路。

2.4 三种模式对比总结

模式 每次请求传输量 总线占用时间 典型应用 注意事项
单次传输 1个数据单元 低速外设、按键、传感器 频繁请求,CPU介入次数多
突发传输 多个数据单元(4/8/16) 较长 高速外设、UART、SPI 注意总线抢占对实时任务的影响
循环传输 自动重载,无限循环 持续占用(间歇) 音频、环形缓冲区、数据采集 必须配合双缓冲或半传输中断

最后说一句:DMA不是万能的。数据量很小的时候(比如几十个字节),CPU自己搬可能更快——因为DMA初始化本身也有开销。我一般有个经验值:超过64字节的搬运,才值得用DMA。小于这个数,CPU直接搬反而更省事。

嗯,DMA的工作原理就这些。下一章咱们聊聊DMA在实际游戏机项目里的优化案例,那才是真正有意思的地方。