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会:
- 拉高完成信号线
- 如果使能了中断,向CPU发送中断请求
- 状态寄存器置位「传输完成」标志
- 如果是循环模式,自动重载计数值,从头开始
小技巧: 我建议在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在实际游戏机项目里的优化案例,那才是真正有意思的地方。