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传输过程,分为以下几步:

  1. 配置阶段:设置源地址、目的地址、传输计数、数据宽度、递增模式
  2. 使能阶段:使能DMA通道,等待触发信号
  3. 触发阶段:外设(如ADC)产生DMA请求,DMA开始搬运
  4. 传输阶段:每搬运一个数据单元,传输计数减1,地址按配置递增/不变
  5. 完成阶段:传输计数减到0,触发完成中断(如果使能了)

如果是循环模式,第5步之后自动回到第3步,传输计数重载,继续等待下一次触发。

小技巧:调试DMA时,可以先配一个很小的传输计数(比如5个),然后在中断里打断点观察缓冲区数据。这样能快速验证地址配置是否正确,避免一次搬1000个错误数据把内存搞乱。

3.5 DMA与中断的协同关系

DMA和中断不是互斥的,而是配合使用的。DMA负责搬数据,中断负责通知CPU“数据搬完了,快来处理”。

常见的协同模式:

  • 传输完成中断:整批数据搬完时触发,适合批处理
  • 半传输中断:搬完一半时触发,适合双缓冲区切换(乒乓操作)
  • 传输错误中断:总线错误或配置错误时触发,用于异常处理

我个人习惯:在ADC连续采集中,只用半传输中断和传输完成中断。半传输中断里处理上半缓冲区,传输完成中断里处理下半缓冲区。这样CPU永远有数据可用,DMA也永远不会停。

3.6 DMA控制器结构图

下面这张图展示了DMA控制器的内部结构和与ADC、内存的关系。我特意把关键路径画出来了,你一看就明白。

DMA控制器与ADC、内存协同结构图 DMA控制器与ADC、内存协同结构图 ADC外设 数据寄存器 (源地址固定) DMA控制器 源地址寄存器 目的地址寄存器 传输计数寄存器 内存缓冲区 adc_buf[1000] (目的地址递增) DMA请求 数据搬运 传输完成中断 CPU 说明: 1. ADC完成一次转换后,自动向DMA发送请求 2. DMA从ADC数据寄存器读取数据,写入内存缓冲区 3. 传输计数归零时,DMA向CPU发送中断,通知处理数据

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请求标志。需要在中断里手动清除,否则会触发重复中断。