STM32 DMA原理详解:从工作机制到实战避坑指南
1. 从“搬运工”到“指挥官”:DMA在STM32中的角色定位
如果你刚开始接触STM32,可能已经习惯了用HAL_UART_Transmit发送数据,或者在定时器中断里手动搬运ADC的采样值。这种方式下,CPU就像个勤勤恳恳的“搬运工”,数据每移动一个字节,它都得亲自跑一趟,放下手里的计算任务,去处理这些重复、机械的“体力活”。当数据量一大,比如要发送一帧图像或者高速采集传感器数据时,你就会发现CPU忙得不可开交,主程序卡顿,实时性大打折扣。这时候,你就需要请出那位隐藏在芯片内部的“超级搬运工”——DMA。
DMA,全称Direct Memory Access,直接存储器访问。它的核心思想极其精妙:让数据在内存与外围设备(或内存与内存)之间“自动”搬家,完全绕过CPU的干预。你可以把CPU想象成公司的总经理,DMA则是一位高效、专业的物流主管。总经理(CPU)只需要在项目开始时,给物流主管(DMA)下达一份清晰的“运输任务单”(配置DMA通道、源地址、目标地址、数据量等),然后就可以去处理更重要的战略决策(运行核心算法、响应复杂事件)。物流主管会严格按照任务单,指挥卡车(数据总线)将货物(数据)从仓库A(源地址)运到仓库B(目标地址),并在全部运输完成后,发个消息(触发中断)通知总经理“任务已完成”。在整个运输过程中,总经理完全不用操心每一辆卡车的装卸。
在STM32的世界里,DMA的身影无处不在。无论是串口收发大量数据、ADC多通道扫描转换、SPI/I2C与外部器件通信,还是TIM生成PWM或捕获输入,只要涉及数据的批量移动,DMA几乎都是提升系统效率和实时性的首选方案。它解放了CPU,使得CPU可以专注于非数据搬运的核心任务,这对于电池供电的设备意味着更低的功耗,对于需要快速响应的控制系统则意味着更高的性能。
网上很多教程会直接给你一段Cubemx配置图和代码,告诉你“这样配就能用”。但这就像只给你一张填好的快递单,却不告诉你收件人、地址、物品重量该怎么填。这篇内容,我想和你聊聊,在STM32中玩转DMA,你真正需要弄明白的那几件事:它到底是怎么工作的?各种模式(普通、循环、双缓冲)该怎么选?为什么我的DMA发送完成中断只进了一次?数据对齐的坑在哪里?我们不止步于“配置”,更要深入“原理”和“避坑”,让你能从“照抄代码”变成“心中有数,手中有术”。
2. DMA的核心工作机制:通道、请求与仲裁
要驾驭DMA,首先得理解它的组织架构和工作流程。STM32的DMA控制器就像一个拥有多条专用流水线的物流中心。
2.1 通道与请求映射:谁有资格使用DMA?
不是任何外设都能随时调用DMA。DMA控制器提供了多个独立的通道(Channel)。每个通道在某一时刻只能服务于一个外设的特定请求。例如,在STM32F1系列中,DMA1有7个通道,每个通道可以被映射到多个可能的外设请求上,但需要程序员通过配置寄存器来指定当前服务于哪一个。
关键概念:硬件请求与软件触发
- 硬件请求:这是DMA工作的主要方式。外设在准备好数据(如USART的发送数据寄存器空)或需要数据(如USART的接收数据寄存器非空)时,会向DMA控制器发出一个硬件信号(请求)。DMA控制器检测到这个请求后,如果该通道已使能且优先级最高,就会启动一次数据传输。例如,配置USART1_TX使用DMA,那么每当USART的数据寄存器空(TXE标志置位),就会自动向DMA“要数据”,DMA则响应这个请求,从内存搬一个数据到USART->DR寄存器。
- 软件触发:某些DMA流/通道也支持通过软件置位特定寄存器位来启动传输。这在内存到内存的传输中很常见。
你需要查阅具体的《STM32参考手册》中的“DMA请求映射”表格,这是你的“交通法规”。比如,你想用DMA传输ADC1的规则组数据,查表发现ADC1对应DMA1的通道1。那么你就必须将DMA1的通道1配置给ADC1使用,不能乱分配。
2.2 传输事务的三要素:源、目的和搬运量
配置一次DMA传输,本质上是定义了一个“传输事务”。这个事务包含三个核心要素,理解它们对避免错误至关重要:
- 源地址(Source Address):数据从哪里来。可以是某个外设的数据寄存器地址(如
&USART1->DR),也可以是内存中某个数组的地址(如adc_buffer)。 - 目的地址(Destination Address):数据到哪里去。同样可以是外设寄存器地址或内存地址。
- 传输数据量(Data Amount):通常通过两个参数共同决定:
- 数据宽度(Data Width):每次传输操作移动多少位的数据。可选8位(字节)、16位(半字)、32位(字)。这里有一个大坑:源和目的的数据宽度可以不同,DMA控制器会自动进行打包/解包,但这需要配合外设的数据格式,配置错误会导致数据错乱。例如,ADC是12位分辨率,结果寄存器是16位的(通常右对齐),如果你设置DMA的数据宽度为8位,那么一次ADC转换的结果就会被拆成两次传输,完全错误。通常,源和目的的数据宽度应保持一致。
- 传输次数(Number of Data Items / Data Count):需要执行多少次上述“宽度”的传输。例如,你要传输一个包含100个
uint16_t元素的数组,数据宽度设置为16位,传输次数就设置为100。
DMA控制器内部有两个重要的计数器:当前数据地址寄存器和剩余数据数量寄存器。每成功完成一次传输,源/目的地址会根据你的配置(递增、递减或固定)自动更新,剩余数据数量减1。当剩余数量减到0时,一次传输事务就完成了。
2.3 优先级与仲裁:当多个搬运工抢道时
一个DMA控制器有多个通道,如果多个通道同时发出请求,谁先被服务?这就涉及到优先级仲裁。
- 软件优先级:每个通道可以设置为4个等级:非常高、高、中、低。当请求同时到来时,优先级高的通道先被服务。
- 硬件优先级:如果两个通道软件优先级相同,则通道编号小的拥有更高的硬件优先级(例如通道2优先于通道4)。
仲裁机制保证了在复杂系统中,关键的数据流(如实时音频DAC输出)能获得更及时的DMA服务,避免数据丢失。
注意:DMA传输占用的是系统总线(AHB)。在DMA传输期间,如果CPU也需要访问总线(比如取指令、读写内存),总线仲裁器会介入。高优先级的DMA传输可能会暂时阻塞CPU的访问,导致CPU等待(Stall)。这在极端高性能场景下需要考虑,但对于大多数应用,DMA带来的CPU解放收益远大于偶尔的总线竞争开销。
3. 模式选择:普通、循环与双缓冲,应对不同场景
DMA提供了几种工作模式,应对不同的数据流场景。选对模式,事半功倍;选错模式,调试到头秃。
3.1 普通模式(Normal Mode):一次性的快递任务
这是最直观的模式。你设置好传输总量(比如1000个数据),启动DMA。DMA会忠实地搬运完这1000个数据,然后自动关闭该通道(硬件将通道使能位EN清零),并触发“传输完成中断”(TC中断)。之后,该通道不再响应任何请求,除非你重新配置并启动它。
适用场景:
- 发送一段固定的命令或数据帧。例如,通过SPI DMA发送一屏LCD的初始化命令序列。
- 触发一次性的数据采集。例如,手动触发ADC,通过DMA采集1024个点后停止。
坑点提醒: 在普通模式下,传输完成后通道被禁用。如果你需要在中断服务程序里再次启动传输,务必先清除相应的中断标志位,再重新设置传输数据量(NDTR寄存器)并重新使能通道(EN=1)。HAL库中,HAL_DMA_Start函数通常会帮你做一部分,但理解底层操作很重要。
3.2 循环模式(Circular Mode):永不停止的传送带
在循环模式下,DMA在传输完设定的数据量后,不会停止,而是自动将源/目标地址和传输数据量计数器重置为初始值,然后从头开始新一轮传输。整个过程就像一个环形的传送带,周而复始。
适用场景:
- 持续不断的数据流。例如,实现一个“回声”功能:将USART通过DMA接收到的数据,实时地通过DMA发送回去。
- 周期性数据采集与处理。例如,用ADC通过DMA循环采集模拟信号,填充一个固定大小的缓冲区。你的主程序或另一个DMA(内存到内存)可以在后台处理这个缓冲区中“已经装满”的数据,而ADC的采集永不停止,实现了连续采样。
坑点提醒: 循环模式下,“传输完成中断”(TC)依然会在每一轮传输结束时触发。这为你提供了精确的“缓冲区换帧”时间点。例如,你设置了一个1000元素的ADC采样缓冲区,工作在循环模式。每次TC中断触发,都意味着ADC已经写满了整个缓冲区一次(可能覆盖了旧数据)。你可以在TC中断里,将缓冲区指针切换到一个备份缓冲区进行处理,实现“乒乓操作”的雏形。
3.3 双缓冲模式(Double Buffer Mode):高效的“乒乓操作”硬件实现
双缓冲模式是循环模式的一个高级变种,专门为解决“数据处理速度跟不上采集速度”而设计。它需要两个大小相同的缓冲区:缓冲区0和缓冲区1。
工作原理:
- DMA配置为指向两个缓冲区(
M0AR和M1AR寄存器分别存储两个内存地址),并工作在双缓冲模式。 - 启动后,DMA首先使用缓冲区0进行传输。
- 当缓冲区0传输满(达到一半数据量或全部,取决于配置)时,DMA自动切换到使用缓冲区1,同时可以触发一个“半传输完成中断”(HT中断)。此时,CPU可以安全地处理已经填满的缓冲区0的数据,因为DMA正在操作缓冲区1。
- 当缓冲区1也传输满时,DMA再次切换回缓冲区0,并触发“传输完成中断”(TC中断)。此时CPU可以处理缓冲区1的数据。 如此往复,DMA在缓冲区0和1之间来回切换,形成“乒乓”操作。CPU总有一个完整的、未被DMA写入的缓冲区可供处理,完美避免了处理数据时缓冲区被覆盖的风险。
适用场景:
- 音频流处理:一边通过I2S DMA接收音频数据填充缓冲区A,一边处理之前已满的缓冲区B的数据(如添加音效),然后输出。
- 摄像头数据流:连续接收一帧图像数据,双缓冲可以确保在处理上一帧时,下一帧的接收不受影响。
- 任何需要连续、实时处理大数据流的场景。
配置关键: 在HAL库或标准库中,配置双缓冲模式需要正确设置DMA_InitStruct.Mode为双缓冲模式,并分别给M0AR和M1AR赋值。同时,要充分利用HT和TC中断来管理缓冲区指针。一个常见的编程模式是:
// 在DMA中断服务函数中 if (hdma->Instance->ISR & DMA_FLAG_HT1) { // 半传输完成(缓冲区0满) // 处理 buffer0 的数据 __HAL_DMA_CLEAR_FLAG(hdma, DMA_FLAG_HT1); } if (hdma->Instance->ISR & DMA_FLAG_TC1) { // 传输完成(缓冲区1满) // 处理 buffer1 的数据 __HAL_DMA_CLEAR_FLAG(hdma, DMA_FLAG_TC1); }4. 实战拆解:以USART DMA发送与接收为例
理论说得再多,不如一行代码。我们以最常用的USART DMA传输为例,看看如何配置,并深入那些容易出错的细节。这里以STM32F4系列和HAL库为例,但原理通用。
4.1 USART TX DMA:如何可靠地发送一帧数据?
假设我们要通过USART1发送一个字符串"Hello, DMA!\r\n"。
步骤一:Cubemx图形化配置
- 使能USART1,模式选择“Asynchronous”。
- 在DMA设置选项卡,为USART1_TX添加一个DMA流(Stream)。选择通道(通常自动匹配)。方向设为“Memory To Peripheral”。
- 模式(Mode):根据需求选择“Normal”(发一次)或“Circular”(循环发,慎用)。
- 优先级(Priority):默认“Low”即可,除非有更高实时性要求。
- 数据宽度(Data Width):这是关键。USART数据寄存器是8位或9位的(取决于字长)。如果我们发送的是
char数组,这里选“Byte”。如果内存中是uint16_t数组但想以两个字节发送,也选“Byte”,DMA会分两次搬运一个16位数据。 - 使能“Memory Increment”(内存地址递增),外设地址不递增。
步骤二:生成代码与关键API分析Cubemx会生成MX_DMA_Init()和MX_USART1_UART_Init()。发送数据的核心函数是HAL_UART_Transmit_DMA()。
char tx_data[] = "Hello, DMA!\r\n"; HAL_UART_Transmit_DMA(&huart1, (uint8_t*)tx_data, strlen(tx_data));这个函数内部做了几件事:
- 将目标数据地址和长度装载到DMA寄存器。
- 使能USART的DMA发送请求(
USART_CR3寄存器中的DMAT位)。 - 使能DMA流。
步骤三:判断发送完成与中断处理调用HAL_UART_Transmit_DMA后,函数立即返回,数据发送在后台由DMA进行。如何知道发送完了?
- 轮询方式:不推荐,会阻塞。但可以用
HAL_DMA_PollForTransfer或检查__HAL_DMA_GET_FLAG(hdma, DMA_FLAG_TCx)。 - 中断方式(推荐):使能DMA流的“传输完成中断”(TCIE)。在生成的代码中,Cubemx可能已经帮我们使能了全局中断,但我们需要自己编写中断回调函数。
在stm32f4xx_it.c中,找到DMAx_Streamy_IRQHandler中断服务函数,它已经调用了HAL_DMA_IRQHandler。我们需要在用户文件中重写弱定义的传输完成回调函数:
// 在 main.c 或其它用户文件中 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // USART1 DMA 发送完成,可以在这里进行下一步操作,例如点亮一个LED,或者准备下一包数据 // 注意:在Normal模式下,发送完成后DMA通道已自动禁用。 } }一个经典问题:“DMA发送完成中断”到底在什么时候触发?这个问题在搜索热词里高频出现。触发点不是USART物理引脚上的最后一个比特发送完毕,而是DMA控制器将最后一个数据从内存搬运到USART->DR寄存器的时刻。也就是说,当DMA把最后一个字节塞进串口的发送数据寄存器后,它就认为任务完成,触发TC中断。此时,这个字节可能还在串口的发送移位寄存器中,尚未完全发出到TX引脚。 如果你需要在最后一个比特也真正发送出去后才进行某些操作(例如切换IO方向),就需要再等待USART本身的“发送完成”(TC)标志位。可以结合使用:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { // 等待最后一个字节从移位寄存器发出 while((huart->Instance->SR & USART_SR_TC) == 0) {}; // 现在可以安全地进行后续操作了,比如关闭使能等 }4.2 USART RX DMA:连续接收不定长数据(IDLE中断法)
接收不定长数据是串口通信的常见需求。DMA+串口空闲中断(IDLE)是最高效的方案之一。
原理:
- 将USART RX配置为DMA循环接收模式,指向一个足够大的缓冲区(如
rx_buffer[256])。 - 使能USART的IDLE中断(当串口总线上一段时间没有收到数据时产生)。
- 启动DMA接收:
HAL_UART_Receive_DMA(&huart1, rx_buffer, 256)。 - 当一帧数据到来,DMA会自动将数据搬运到
rx_buffer。 - 数据发送完毕后,总线空闲,触发USART的IDLE中断。
- 在IDLE中断服务程序中,我们可以计算出本次接收到的数据长度:
数据长度 = 预设缓冲区长度 - DMA当前剩余未传输次数。然后处理这一帧数据,并重置DMA(重新设置数据长度和缓冲区地址,以准备接收下一帧)。
配置与代码实现:
- Cubemx中使能USART全局中断(NVIC),并在代码中手动使能IDLE中断:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); - 在USART中断服务函数中处理IDLE事件:
void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清除IDLE标志位 // 计算接收到的数据长度 uint16_t rx_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 处理 rx_buffer 中前 rx_len 个字节的数据 process_data(rx_buffer, rx_len); // 重新启动DMA接收,指向缓冲区开头,准备下一次接收 // 需要先禁用再使能,或者使用HAL库提供的重启函数 HAL_UART_DMAStop(&huart1); HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE); } HAL_UART_IRQHandler(&huart1); // 调用HAL库默认中断处理 }
重要提示:
HAL_UART_Receive_DMA在循环模式下,如果缓冲区满了,DMA会从缓冲区头部重新开始覆盖写入(根据指针是否递增)。因此,你的处理速度必须快于数据接收速度,或者缓冲区要足够大,否则会丢失数据。IDLE中断法完美解决了“不定长”和“实时处理”的问题。
5. 进阶话题与避坑指南
掌握了基本操作,我们来看看那些容易让人栽跟头的进阶问题和细节。
5.1 数据对齐与传输宽度不匹配的陷阱
这是DMA错误中最隐蔽的一类。假设你的源数据是内存中的一个uint32_t数组(32位),而目标外设(比如一个32位的数据寄存器)也期望32位数据。如果你错误地将DMA的数据宽度配置为8位(Byte),会发生什么?
DMA会认为你要传输4倍数量的8位数据。例如,你想传输1个uint32_t数据(值为0x12345678)。如果配置为32位宽度,一次传输即可完成。如果配置为8位宽度,DMA会执行4次传输:第一次送0x78到目标地址,第二次送0x56,第三次送0x34,第四次送0x12。如果你的外设寄存器是32位只写寄存器,那么只有最后一次写入(0x12)是有效的,前三次写入可能被忽略或覆盖,最终寄存器里只有0x12,数据完全错误。
黄金法则:在配置DMA时,务必确保源地址、目的地址以及对应的数据宽度与实际的数据类型、外设寄存器宽度相匹配。如果不确定,查阅芯片数据手册中外设寄存器的描述。
5.2 内存到内存传输:加速数据搬运的利器
DMA不仅可以服务外设,还可以在两个内存区域之间快速搬运数据,且不消耗CPU周期。这在图像处理、缓冲区拷贝、数据重排等场景下非常有用。
配置要点:
- 方向设置为“Memory To Memory”。
- 通常需要使能
MEM2MEM模式(某些系列是独立的模式位)。 - 源和目的的内存地址递增都需要根据情况使能。
- 触发方式为软件触发(调用
HAL_DMA_Start或类似函数)。
示例:使用DMA快速初始化一片内存区域
// 将常量数组 const_data 拷贝到目标数组 target_array HAL_DMA_Start(&hdma_memtomem_dma2_stream0, (uint32_t)const_data, (uint32_t)target_array, DATA_SIZE); // 等待传输完成 HAL_DMA_PollForTransfer(&hdma_memtomem_dma2_stream0, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY);注意,内存到内存传输会占用大量总线带宽,可能会暂时影响CPU和其它外设的访问性能。
5.3 DMA与Cache的一致性难题(针对Cortex-M7等带Cache内核)
对于STM32F7/H7等使用Cortex-M7内核的系列,它们有数据缓存(D-Cache)。这引入了一个复杂问题:CPU和DMA看到的内存可能不是同一份。
- CPU写,DMA读的场景:CPU处理完的数据放在缓存里,还没来得及写回内存(Write-Back策略)。此时启动DMA去发送这片内存的数据,DMA会直接从内存(而非缓存)读取,读到的就是旧数据。
- DMA写,CPU读的场景:DMA从外设接收数据,直接写入内存。但CPU读取数据时,可能先访问缓存,缓存里是旧数据,导致CPU看不到DMA刚写入的新数据。
解决方案:
- 使用非缓存内存区域:在链接脚本中定义一块SRAM区域,并通过MPU配置为“Non-Cacheable”。将DMA缓冲区放在这块内存中。这是最彻底的方法。
- 手动维护缓存一致性:在关键操作前后,使用SCB(系统控制块)提供的缓存维护指令:
- DMA传输开始前(CPU写内存后):调用
SCB_CleanDCache_by_Addr(),确保CPU缓存中的数据写回内存。 - DMA传输结束后(CPU读内存前):调用
SCB_InvalidateDCache_by_Addr(),使CPU缓存失效,强制从内存重新加载数据。
- DMA传输开始前(CPU写内存后):调用
5.4 调试技巧:当DMA不工作时如何排查
- 检查时钟:DMA控制器和外设的时钟是否都已使能?(
__HAL_RCC_DMAx_CLK_ENABLE()和__HAL_RCC_USARTx_CLK_ENABLE())。 - 检查通道映射:确认DMA流/通道与外设请求的映射关系是否正确。查参考手册的“DMA请求映射表”。
- 检查NVIC中断:如果使用了中断,是否在NVIC中使能了对应的DMA流中断?优先级配置是否合理?
- 检查传输完成标志:在调试器中查看DMA寄存器,如
ISR(中断状态寄存器)中的TCIFx(传输完成中断标志)是否置位?NDTR(剩余数据计数)寄存器是否在递减? - 检查外设的DMA使能位:例如,USART的
CR3寄存器中的DMAT(发送DMA使能)或DMAR(接收DMA使能)位是否置1? - 简化测试:先尝试最简单的内存到内存传输,排除外设配置的影响。再逐步增加外设和中断逻辑。
DMA是STM32单片机性能飞跃的关键组件。从生疏到熟练,必然要经历一些调试和踩坑的过程。但一旦你掌握了它,就能设计出响应更及时、运行更高效、功耗更低的应用。它让你从繁琐的数据搬运中解脱出来,真正专注于产品的核心逻辑。希望这篇内容能帮你建立起对DMA清晰而深入的理解,在项目中放心大胆地使用这个强大的工具。
