3. 硬件基础:DMA控制器原理
DMA,全称 Direct Memory Access,直接存储器访问。说白了,就是给CPU配了一个专职的“数据搬运工”。CPU只需要告诉它:“把ADC结果寄存器里的数据,搬到内存数组里,搬满1000次通知我”,然后CPU就可以去干别的事了。
我刚开始做嵌入式时,总觉得DMA是个可有可无的“高级功能”。直到有一次做多通道高速数据采集,CPU光顾着搬数据,中断响应都乱套了。嗯,从那以后,我再也不敢轻视DMA了。
3.1 DMA的核心三要素
任何一个DMA传输,都离不开三个核心参数:源地址、目的地址、传输计数。你想想看,搬东西总得知道从哪搬、搬到哪、搬多少吧?
| 参数 | 含义 | 典型配置(ADC场景) |
|---|---|---|
| 源地址 | 数据从哪里来 | ADC数据寄存器地址(如 &(ADC1->DR)) |
| 目的地址 | 数据要到哪里去 | 用户定义的缓冲区地址(如 adc_buf[1000]) |
| 传输计数 | 总共要搬多少个数据单元 | 缓冲区长度(如 1000) |
这里有个容易踩坑的地方:源地址和目的地址的递增模式。ADC数据寄存器是固定的,所以源地址通常不递增;而内存缓冲区需要连续存放数据,所以目的地址要递增。我见过有人把两个都配成递增,结果数据全写飞了。
关键配置口诀:
- 外设→内存:源地址固定,目的地址递增
- 内存→外设:源地址递增,目的地址固定
- 内存→内存:源地址递增,目的地址递增
3.2 传输模式:单次 vs 循环
DMA传输模式分为两种:单次模式(Normal)和循环模式(Circular)。这个选择直接影响你的软件架构。
单次模式:传输计数减到0后,DMA自动停止。需要软件重新配置才能再次启动。适合“采集一次数据,然后慢慢处理”的场景。
循环模式:传输计数减到0后,自动重载初始值,继续传输。适合“连续采集,永不停止”的场景,比如音频流、连续ADC采样。
我个人习惯:只要ADC是连续转换模式,DMA一定配成循环模式。否则你还要写个定时器去反复触发DMA,麻烦不说,还容易丢数据。
我曾经踩过的坑:循环模式下,传输完成中断仍然会在每次计数归零时触发。如果你在中断里做了“重设缓冲区指针”的操作,反而会破坏DMA的自动循环机制。正确的做法是:用半传输中断和传输完成中断配合双缓冲区切换,而不是手动干预DMA的地址指针。
3.3 数据宽度与对齐
DMA控制器支持不同的数据宽度:字节(8位)、半字(16位)、字(32位)。ADC结果寄存器通常是16位或32位,所以源数据宽度要匹配ADC的精度。
| ADC精度 | 数据宽度 | DMA配置 |
|---|---|---|
| 8位 | 字节 | PSIZE = 8位 |
| 12位/16位 | 半字 | PSIZE = 16位 |
| 24位/32位 | 字 | PSIZE = 32位 |
这里有个对齐问题:源和目的的数据宽度可以不同吗?有些DMA控制器支持,有些不支持。我建议你保持源和目的宽度一致,否则数据会错位。比如ADC是16位,你非要用8位宽度去搬,结果就是高8位数据丢失,低8位重复搬运。
3.4 DMA传输的完整流程
一个典型的DMA传输过程,分为以下几步:
- 配置阶段:设置源地址、目的地址、传输计数、数据宽度、递增模式
- 使能阶段:使能DMA通道,等待触发信号
- 触发阶段:外设(如ADC)产生DMA请求,DMA开始搬运
- 传输阶段:每搬运一个数据单元,传输计数减1,地址按配置递增/不变
- 完成阶段:传输计数减到0,触发完成中断(如果使能了)
如果是循环模式,第5步之后自动回到第3步,传输计数重载,继续等待下一次触发。
小技巧:调试DMA时,可以先配一个很小的传输计数(比如5个),然后在中断里打断点观察缓冲区数据。这样能快速验证地址配置是否正确,避免一次搬1000个错误数据把内存搞乱。
3.5 DMA与中断的协同关系
DMA和中断不是互斥的,而是配合使用的。DMA负责搬数据,中断负责通知CPU“数据搬完了,快来处理”。
常见的协同模式:
- 传输完成中断:整批数据搬完时触发,适合批处理
- 半传输中断:搬完一半时触发,适合双缓冲区切换(乒乓操作)
- 传输错误中断:总线错误或配置错误时触发,用于异常处理
我个人习惯:在ADC连续采集中,只用半传输中断和传输完成中断。半传输中断里处理上半缓冲区,传输完成中断里处理下半缓冲区。这样CPU永远有数据可用,DMA也永远不会停。
3.6 DMA控制器结构图
下面这张图展示了DMA控制器的内部结构和与ADC、内存的关系。我特意把关键路径画出来了,你一看就明白。
3.7 常见配置代码示例
下面是一个STM32系列MCU的DMA配置代码片段。我加了详细的注释,你照着配就行。
// DMA配置结构体
DMA_InitTypeDef DMA_InitStruct;
// 使能DMA时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
// 配置DMA通道1(通常ADC1使用DMA1通道1)
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&(ADC1->DR); // 源地址:ADC数据寄存器
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)adc_buf; // 目的地址:内存缓冲区
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC; // 方向:外设→内存
DMA_InitStruct.DMA_BufferSize = 1000; // 传输计数:1000个数据
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 源地址不递增
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; // 目的地址递增
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; // 16位
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; // 16位
DMA_InitStruct.DMA_Mode = DMA_Mode_Circular; // 循环模式
DMA_InitStruct.DMA_Priority = DMA_Priority_High; // 高优先级
DMA_InitStruct.DMA_M2M = DMA_M2M_Disable; // 非内存到内存
DMA_Init(DMA1_Channel1, &DMA_InitStruct);
// 使能传输完成中断
DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE);
// 使能DMA通道
DMA_Cmd(DMA1_Channel1, ENABLE);
个人经验:配置DMA时,一定要先配完所有参数,最后再使能通道。我曾经在调试时先使能了通道,然后修改参数,结果DMA直接跑飞了。顺序很重要:配置 → 使能中断 → 使能通道。
3.8 避坑指南
最后,我总结几个DMA配置中容易踩的坑,都是我曾经的血泪教训:
- 地址对齐问题:源地址和目的地址必须按数据宽度对齐。16位数据要配16位对齐的地址,32位数据要配32位对齐的地址。不对齐会导致硬件错误。
- 缓冲区溢出:循环模式下,如果CPU处理数据的速度跟不上DMA搬运的速度,缓冲区会被覆盖。解决办法是:用双缓冲区,或者提高CPU处理速度。
- 中断优先级:DMA传输完成中断的优先级要合理设置。太高会抢占其他关键中断,太低会导致数据来不及处理。我一般设成中等偏上。
- DMA请求清除:有些外设(如ADC)在DMA传输完成后,不会自动清除DMA请求标志。需要在中断里手动清除,否则会触发重复中断。