资讯中心

STM32 DMA原理与实战:从外设数据搬运到内存高效传输

📅 2026/7/30 5:58:09
STM32 DMA原理与实战:从外设数据搬运到内存高效传输
1. 从“搬运工”到“甩手掌柜”DMA在STM32中的角色跃迁如果你玩过一阵子STM32肯定对“阻塞”和“中断”这两个词不陌生。想象一下你让CPU中央处理器这个“大管家”亲自去搬一大箱数据从串口USART搬到内存RAM里。在它吭哧吭哧搬运的这几毫秒甚至几十毫秒里它啥也干不了这就是“阻塞”。为了不耽误事我们引入了“中断”数据来一点就打断一下CPU让它赶紧处理一下处理完再回去干原来的活。这就像管家正在算账快递员外设每送一个包裹就按一次门铃管家就得跑去签收一次虽然比一直等在门口强但频繁被打断账也算得七零八落。DMADirect Memory Access直接存储器访问的出现就是为了彻底解放CPU。它就像一个专门雇来的“搬运工”。你只需要告诉这个搬运工货在哪里外设数据寄存器要搬到哪去内存地址一次搬多少数据量然后就可以拍拍屁股走人了。CPU这个“大管家”可以安心地去执行复杂的算法、处理逻辑判断而数据搬运这种“体力活”全权交给DMA。只有当一整批货搬完了DMA才会过来敲敲门说“老板活儿干完了”此时CPU再介入处理这批完整的数据。这种模式我们称之为“甩手掌柜”模式是提升系统效率和实时性的关键。在STM32的项目里尤其是涉及到大量、高速数据吞吐的场景比如高频ADC采集音频信号、摄像头图像数据传输、高速串口通信、SD卡读写等DMA不是“可选项”而是“必选项”。没有DMA你的系统性能瓶颈会非常明显CPU时间被搬运任务大量占用导致响应变慢甚至丢失数据。理解了DMA你才算真正摸到了STM32高效编程的门槛。2. DMA的“工作流程”与“人事架构”通道、流与仲裁很多新手拿到STM32的参考手册看到DMA控制器框图里那些“Stream”、“Channel”、“Arbiter”就开始头疼。别慌我们用人话和场景来拆解。2.1 核心工作流程一次完整的“搬运任务”一次DMA传输绝不是简单地点个开始就完事。它遵循一个严谨的流程这个流程需要你也就是程序员来精心设置任务规划配置阶段这是你的工作。你需要通过写寄存器直接操作或调用HAL/LL库来明确告诉DMA搬运工是谁指定使用哪个DMA控制器DMA1或DMA2下的哪个流Stream。STM32F4/H7等系列有“流”的概念你可以把它理解为一个独立的“搬运工小组”。为谁干活指定通道Channel。通道决定了这个“搬运工小组”服务于哪个“客户”外设。比如USART1_TX 可能对应通道4ADC1 对应通道0。通道号是硬件固定的查数据手册的DMA请求映射表就能知道。货物详情源地址外设寄存器地址如USART1-DR、目标地址内存数组地址如Rx_Buffer、搬运方向外设到内存、内存到外设、内存到内存。搬运方式是一次性搬完单次模式还是搬完自动重装参数循环搬循环模式这决定了是“一次性任务”还是“周期性任务”。货物规格数据宽度8位、16位、32位源和目标地址是否每次搬运后自动递增。完工通知活干完了要不要通知我使能传输完成中断搬一半要不要通知使能半传输中断出错要不要通知使能错误中断持证上岗使能阶段配置好所有参数后将对应流的使能位EN置1。这相当于给这个“搬运工小组”发了开工许可。此时DMA控制器就开始监听其对应“客户”外设的请求信号。等待派单请求与响应当外设比如ADC转换完成、USART收到数据准备好数据/需要数据时它会向DMA控制器发出一个“请求”DMA Request。DMA仲裁器根据优先级批准对应流的请求。执行搬运数据传输DMA控制器接管总线直接从源地址读取数据写入目标地址完全绕过CPU。这个过程通常只需要1-2个AHB总线周期极快。任务更新与汇报每搬运完一个数据单元比如一个字节DMA会自动更新剩余数据量计数器CNDTR并根据配置递增地址。当计数器减到0意味着“合同”规定的量搬完了。如果是单次模式DMA会自动关闭该流EN位清零搬运工下班。如果使能了传输完成中断TCIE则会触发中断你在中断服务函数里处理数据然后重新配置、启动下一次传输。如果是循环模式计数器会自动重载为初始值地址也可能根据配置重置然后立即开始下一轮搬运永不停歇。这在ADC连续采集、DAC波形生成等场景下非常有用。2.2 人事架构解析流、通道与仲裁器流Stream这是执行搬运任务的主体单元。一个DMA控制器如DMA2通常有多个流如8个。每个流是独立的可以配置为不同的传输任务。你可以把每个流看作一个独立的“搬运工小组”。通道Channel每个流在同一时间只能为一个“客户”外设服务。通道号就是用来选择客户的。例如你想用DMA2的Stream0来为ADC1服务就需要将Stream0的通道选择寄存器设置为ADC1对应的通道号比如0。一个流同一时刻只能连接一个通道。仲裁器Arbiter当多个流同时向DMA控制器发出请求时比如USART1和ADC1的数据同时就绪谁先谁后这就由仲裁器根据优先级决定。优先级有两种软件优先级你在配置流时可以设置非常高、高、中、低。这是静态的。硬件优先级通常流编号小的优先级高Stream0 Stream1 ...。当软件优先级相同时看硬件优先级。注意优先级解决的是“同时请求”的竞争问题。对于内存到内存的传输没有外设请求信号一旦使能流传输会立即开始并占用总线直到完成期间会阻塞其他所有总线主设备包括CPU和其他DMA流的访问。因此内存到内存的传输要谨慎使用且不宜传输过大块数据。2.3 一个生动的类比假设你有一个STM32F407芯片它有DMA1和DMA2两个“搬运公司”。每个公司有8个“搬运工小组”Stream0-Stream7。每个小组可以接不同“客户”外设的活但一次只能接一家的活。接哪家的活由“通道选择”Channel Selection这个开关决定。客户有USART1通道4、ADC1通道0、SPI2_TX通道0等等。今天你安排DMA2的Stream5小组高软件优先级接ADC1通道0的活循环模式把ADC数据搬到数组AdcBuf里。DMA1的Stream6小组中软件优先级接USART1_TX通道4的活单次模式把字符串SendData发出去。当ADC转换完成和USART发送寄存器空同时发生时仲裁器会优先处理优先级高的DMA2_Stream5的请求。3. 实战配置从CubeMX到代码打通USART的DMA收发理论说再多不如动手调一遍。我们以STM32F407的USART1为例配置一个“DMA接收不定长数据空闲中断解析”的经典组合拳。这是实现高效串口通信的黄金法则。3.1 CubeMX图形化配置引脚与时钟使能USART1异步模式配置波特率如115200。引脚PA9/PA10会自动分配。确保DMA时钟已开启通常默认开启。DMA配置在USART1的配置页找到“DMA Settings”标签页。点击“Add”添加一个DMA请求。方向选择“Memory To Peripheral”内存到外设这是为了发送。关联的流/通道会自动弹出例如USART1_TX 可能对应 DMA2 Stream7 Channel4。优先级设为“中”。再次点击“Add”添加第二个DMA请求。方向选择“Peripheral To Memory”外设到内存这是为了接收。关联的流/通道也会自动弹出例如USART1_RX 可能对应 DMA2 Stream5 Channel4。优先级设为“高”因为接收不及时会丢数据。参数配置分别点击两个已添加的请求行进行详细设置。Mode对于发送TX通常用“Normal”单次模式发完即止。对于接收RX强烈建议使用“Circular”循环模式形成一个环形缓冲区永不停止地接收防止数据覆盖问题。Increment Address对于发送源地址内存需要递增Yes目标地址外设寄存器不递增No。对于接收源地址外设不递增No目标地址内存需要递增Yes。Data Width根据你的数据格式选择通常都是“Byte”8位。中断配置在NVIC设置中使能USART1的“空闲中断”Idle Interrupt。这是实现不定长接收的关键。使能两个DMA流的“传输完成中断”TCIE特别是接收流的TCIE但注意在循环模式下TCIE会周期性触发。也可以考虑使能DMA的“半传输中断”HTIE和“传输错误中断”TEIE用于更复杂的缓冲管理。生成代码后CubeMX会帮你初始化好USART和DMA并生成相应的huart1、hdma_usart1_tx、hdma_usart1_rx句柄。3.2 代码实现接收不定长数据CubeMX生成的只是底层驱动业务逻辑需要自己写。以下是核心代码逻辑// 定义接收缓冲区 #define RX_BUF_SIZE 256 uint8_t Uart1_RxBuf[RX_BUF_SIZE]; volatile uint16_t Uart1_RxLen 0; // 实际接收到的数据长度 volatile uint8_t Uart1_RxFlag 0; // 接收完成标志 // 在main初始化部分启动DMA接收 HAL_UART_Receive_DMA(huart1, Uart1_RxBuf, RX_BUF_SIZE); // 此时DMA已经开始在后台循环接收数据到Uart1_RxBuf // USART1 空闲中断服务函数 void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清除空闲中断标志 // 关键步骤计算本次接收到的数据长度 // DMA当前写入位置 缓冲区总大小 - DMA剩余未传输数据量 uint16_t temp_len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理数据 if(temp_len 0) { Uart1_RxLen temp_len; Uart1_RxFlag 1; // 设置标志通知主循环处理 // 注意此时数据在 Uart1_RxBuf[0] 到 Uart1_RxBuf[temp_len-1] 中 // 处理完数据后无需重启DMA因为它是循环模式会自动覆盖旧数据。 // 但如果你需要防止处理期间新数据覆盖可以采用双缓冲区策略。 } } HAL_UART_IRQHandler(huart1); } // 主循环中处理数据 while (1) { if(Uart1_RxFlag) { Uart1_RxFlag 0; // 处理 Uart1_RxBuf 中的数据长度为 Uart1_RxLen // 例如解析协议、打印数据等 Process_Uart_Data(Uart1_RxBuf, Uart1_RxLen); } // ... 其他任务 }3.3 为什么是“空闲中断DMA循环模式”这是最优解。传统的“DMA单次模式完成中断”需要为每一帧数据精确指定长度不适合不定长协议。而“串口接收中断字节处理”则会让CPU频繁被打断。“空闲中断DMA循环模式”的巧妙之处在于DMA循环模式像一个永动的传送带把串口数据源源不断地搬进环形缓冲区CPU零干预。空闲中断当一帧数据发送完毕串口总线会有一个字符时间的空闲状态。这个状态触发中断告诉你“这一波数据流暂时停了”。长度计算在空闲中断里通过缓冲区大小 - DMA剩余计数就能精准算出刚刚接收的这一帧数据有多长存放在缓冲区的哪个位置。双缓冲区技巧对于高速数据流为了防止处理数据时被新数据覆盖可以定义两个缓冲区A和B。DMA始终在其中一个比如A接收。当空闲中断触发计算长度后立即修改DMA的目标地址切换到缓冲区B然后在主循环中安心处理缓冲区A的数据。下次空闲中断再切回A。这就是“乒乓缓冲”是高速数据采集的常用手法。踩坑提示__HAL_DMA_GET_COUNTER获取的是剩余数据单元数量不是已传输数量。计算已接收数据长度时一定要用总容量 - 剩余量。另外清除空闲标志__HAL_UART_CLEAR_IDLEFLAG的操作因芯片系列略有不同有些是通过读SR寄存器再读DR寄存器来清除HAL库的宏封装好了直接用就行。4. ADC的多通道连续采集与DMA的环形缓冲策略ADC采集是DMA的另一个主战场。单次采集用中断还行但如果是多通道、连续、高速采集比如音频采样、电源监控DMA是唯一的选择。4.1 多通道扫描与DMA的配合STM32的ADC支持多通道扫描模式。你可以配置一个序列比如通道1, 2, 3, 7ADC会按顺序自动转换这些通道每个通道转换完成后结果都会存放在同一个数据寄存器DR里。问题来了如果不用DMA你怎么知道DR里的数据是哪个通道的你需要在一个非常精确的时间点去读取否则数据就会被下一个通道的覆盖。DMA完美解决了这个问题。你只需要配置DMA在每次ADC转换完成EOC时自动把DR寄存器的值搬运到一个指定的内存数组中。ADC扫完一轮所有通道DMA就帮你按顺序存好一轮数据。4.2 CubeMX配置要点ADC配置使能ADC1设置为“连续转换模式”Continuous Conversion Mode和“扫描模式”Scan Conversion Mode。规则通道配置在“Rank”里添加你需要采集的通道如IN1, IN2, IN3并设置采样时间。DMA配置在ADC的DMA设置中添加一个DMA请求。模式选择“循环模式”Circular这样ADC就能永不停止地采集DMA永不停止地搬运。数据宽度根据ADC分辨率选择12位ADC用半字/16位。触发源如果是软件触发调用HAL_ADC_Start_DMA即可开始。如果是定时器触发用于精确采样率需要配置定时器作为ADC的触发源Trigger Source。4.3 代码实现与数据管理#define ADC_CH_NUM 3 // 3个通道 #define ADC_BUF_DEPTH 100 // 每个通道的缓存深度 uint16_t Adc_DualBuf[2][ADC_CH_NUM * ADC_BUF_DEPTH]; // 双缓冲区 volatile uint8_t Adc_Buf_Ready 0; // 缓冲区就绪标志 volatile uint16_t *Adc_Cur_Proc_Buf; // 当前用于处理的缓冲区指针 // 启动ADC DMA采集目标指向第一个缓冲区 HAL_ADC_Start_DMA(hadc1, (uint32_t*)Adc_DualBuf[0], ADC_CH_NUM * ADC_BUF_DEPTH); // ADC DMA传输完成中断回调函数半传输和完成传输 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 当DMA搬运完整个缓冲区ADC_CH_NUM * ADC_BUF_DEPTH个数据时调用 // 此时Adc_DualBuf[0] 已经被填满了一轮完整的数据 Adc_Buf_Ready 1; // 通知主循环缓冲区0已满 Adc_Cur_Proc_Buf Adc_DualBuf[0]; // 指向缓冲区0供处理 // 注意DMA在循环模式下会自动回到缓冲区开头继续填充所以这里不需要重启 } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 当DMA搬运到一半时调用可选 // 此时Adc_DualBuf[0]的前半部分已满可以处理前半部分 // 这是更高级的“双缓冲”技巧能进一步减少延迟 }4.4 数据排列与解析DMA搬运的数据在内存中是线性排列的。对于3通道扫描深度为100的缓冲区数据排列如下[Ch1_Sample0, Ch2_Sample0, Ch3_Sample0, Ch1_Sample1, Ch2_Sample1, Ch3_Sample1, ..., Ch1_Sample99, Ch2_Sample99, Ch3_Sample99]主循环中当Adc_Buf_Ready标志置位就可以处理Adc_Cur_Proc_Buf指向的数据。你需要按上述规律解析出每个通道的100个采样值。核心技巧对齐与溢出。ADC的DR寄存器是32位的对于F4系列但12位数据通常右对齐。使用DMA传输16位半字时要确保你的目标数组是uint16_t类型并且ADC配置中的数据对齐方式右对齐与DMA的数据宽度匹配。另外计算DMA缓冲区大小时要小心确保通道数 × 采样深度 × 数据宽度字节不超过DMA流一次传输的最大容量通常65535也要防止数组越界。5. 内存到内存传输高效数据搬运的利器与陷阱除了服务外设DMA还可以在两个内存区域之间直接搬运数据比如从一个数组复制到另一个数组或者对一片内存区域进行快速填充Memset。这比用CPU的memcpy或循环赋值要快得多尤其是在数据量大的时候因为DMA不占用CPU指令周期。5.1 配置与启动在CubeMX中你需要手动添加一个DMA流并选择方向为“Memory To Memory”。源地址和目标地址都是内存地址。注意内存到内存传输通常没有外设请求信号一旦使能流HAL_DMA_Start传输会立即开始并且以最快的速度取决于总线带宽和仲裁连续进行直到完成。// 使用HAL库进行内存到内存传输 uint32_t srcArray[1000], dstArray[1000]; // ... 填充 srcArray ... if(HAL_DMA_Start(hdma_memtomem, (uint32_t)srcArray, (uint32_t)dstArray, 1000) ! HAL_OK) { Error_Handler(); } // 等待传输完成 HAL_DMA_PollForTransfer(hdma_memtomem, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY); // 或者使用中断方式在传输完成回调函数中处理5.2 巨大的陷阱总线阻塞这是内存到内存DMA最需要警惕的一点。在传输进行期间DMA控制器会持续占用系统总线通常是AHB总线。这意味着CPU取指受阻CPU无法从Flash或RAM中读取指令程序执行会完全停止直到DMA传输结束。这会导致系统实时性急剧下降。其他总线主设备受阻其他DMA流、以太网MAC、USB等需要访问总线的主设备也会被阻塞。5.3 使用准则与优化建议短数据优先只对小块数据比如几百字节使用内存到内存DMA。对于大块数据几十KB以上需要评估其对系统实时性的影响。有时让CPU分片复制期间可以响应中断可能整体体验更好。利用传输完成中断不要使用轮询等待HAL_DMA_PollForTransfer这会让CPU傻等。配置为中断模式传输完成后触发中断在中断回调里进行后续操作这样在传输期间CPU至少可以处理其他不依赖总线访问的任务但很少。考虑系统架构在一些高性能系列如STM32H7中存在多总线矩阵D1, D2, D3域和多个DMA控制器MDMA, BDMA。可以将内存到内存的传输安排到对实时性影响较小的总线域和DMA控制器上。替代方案对于简单的内存初始化如清零STM32的Cortex-M内核通常有更快的单指令多数据SIMD指令或专用的存储加速器可能比DMA更高效。5.4 一个实际场景LCD显存刷新假设你有一个基于SPI或FSMC接口的LCD其显存是一个内部的FrameBuffer数组。当需要刷新整个屏幕时你需要将FrameBuffer的数据通过SPI发送出去。一种高效的做法是使用内存到外设的DMA将FrameBuffer作为源SPI的数据寄存器作为目标。这样DMA在搬运数据到SPI的同时CPU可以准备下一帧的图像数据实现了流水线操作。这比内存到内存再内存到外设或者纯CPU搬运要高效得多。6. 进阶话题与疑难杂症排查当你基本掌握DMA的使用后会遇到一些更复杂的情况和令人头疼的bug。这里分享几个常见的“坑”和解决思路。6.1 DMA中断与CPU中断的优先级博弈这是一个经典的实时性问题。假设你的系统有一个高速ADC通过DMA循环采集数据DMA传输完成中断TC优先级设为5。一个关键的执行机构控制伺服中断优先级设为6数字越小优先级越高。如果ADC的DMA传输完成中断正在执行搬运数据到缓冲区此时伺服中断发生了。由于伺服中断优先级更高它会抢占ADC的DMA中断。但是DMA传输本身是硬件行为不会停止DMA会继续往缓冲区里写数据。如果ADC的DMA中断服务函数ISR被长时间抢占等它回来继续执行时DMA可能已经写覆盖了之前还未处理的数据导致数据错乱。解决方案合理设置优先级确保数据生产ADC DMA环节的中断优先级不高于数据处理环节的中断优先级。或者将数据处理放在主循环中DMA中断只负责设置标志位。使用双缓冲区如前所述这是解决此问题的根本方法。DMA写缓冲区A时CPU处理缓冲区B两者互不干扰。缩短ISR执行时间DMA中断服务函数里只做最必要的操作如切换缓冲区指针、设置标志繁重的数据处理移到主循环或低优先级任务中。6.2 数据对齐与传输宽度不匹配这是导致数据错位的常见原因。例如ADC是12位数据右对齐存储在32位的DR寄存器中。如果你配置DMA传输宽度为“字节”8位并且源地址不递增那么DMA只会搬运DR寄存器的低8位丢失了高4位数据。黄金法则源外设的数据宽度、DMA传输的数据宽度、目标内存的数据类型三者必须匹配或兼容。ADC 12/16位数据右对齐 - DMA传输宽度设为“半字”16位- 目标数组类型为uint16_t。外设是32位寄存器如某些定时器的CCR- DMA传输宽度设为“字”32位- 目标数组类型为uint32_t。当外设数据宽度小于DMA传输宽度时如8位串口数据用16位DMA搬运要特别注意数据在总线上的对齐方式通常需要选择“字节”宽度。6.3 DMA传输未启动或中途停止时钟未开启检查对应DMA控制器的时钟__HAL_RCC_DMA1_CLK_ENABLE()是否在初始化时使能。流未使能调用HAL_DMA_Start或HAL_UART_Receive_DMA后是否成功返回HAL_OK这些函数内部会检查参数并最终置位流的EN位。外设未启动对于外设到内存的传输你启动了DMA但外设本身没有开始工作。例如你启动了ADC的DMA接收但没有调用HAL_ADC_Start或HAL_ADC_Start_DMA。DMA只在外设发出请求时才行动。传输完成自动关闭在单次模式下传输完成后DMA流会自动禁用EN位清零。如果你需要再次传输必须重新调用启动函数。而循环模式下EN位会一直保持。寄存器访问冲突在DMA传输期间CPU或其他总线主设备尝试访问正在被DMA读写的内存区域可能导致访问失败或数据不一致。确保你的处理逻辑避开了DMA的活跃缓冲区。6.4 调试技巧利用调试器观察DMA寄存器当DMA行为异常时仅看代码很难定位。学会看寄存器是关键SxCR (Stream x Configuration Register)查看流是否使能EN位方向、优先级、传输宽度、循环模式等配置是否正确。SxNDTR (Stream x Number of Data Register)这是剩余数据量计数器。传输过程中它会递减。如果它卡住不变说明DMA没有在搬数据可能是外设没请求或配置错误。如果它变成了0但中断没触发可能是中断配置问题。SxPAR (Stream x Peripheral Address Register)和SxM0AR (Stream x Memory 0 Address Register)检查源地址和目标地址是否正确指向了外设寄存器和你的内存数组。LISR/HISR, LIFR/HIFR (中断标志寄存器)查看是否有传输完成TCIF、半传输HTIF、传输错误TEIF等标志被置起。这些标志是硬件置位的非常可靠。通过结合这些寄存器的状态、你的代码逻辑和调试器的内存观察窗口查看目标数组是否被写入正确数据大部分DMA问题都能被系统地定位和解决。DMA是STM32性能飞跃的翅膀虽然初学时有门槛但一旦掌握你设计的嵌入式系统将变得举重若轻。