免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

STM32 DMA实战指南:从原理到应用,解放CPU实现高效数据传输

STM32 DMA实战指南:从原理到应用,解放CPU实现高效数据传输 1. 项目概述为什么DMA是STM32开发者的必修课如果你玩过一阵子STM32肯定遇到过这样的场景串口接收数据一快CPU就被中断占满了主程序卡得动不了或者ADC以1MHz的采样率疯狂采集你一边要处理数据一边还要忙着把数据从外设寄存器搬到内存手忙脚乱。这时候你就需要请出那位“幕后英雄”——DMA。DMA全称Direct Memory Access直接存储器访问。这名字听起来就很高大上但说白了它就是一个“数据搬运工”。不过这个搬运工可了不得它不归CPU管自己有一套独立的“运输系统”。当外设比如ADC、串口、SPI产生数据或者内存里有一大块数据要发送给外设时你只需要给DMA下个指令“从A地点搬N个东西到B地点”它就能在后台默默地把活干了。在此期间你的CPU可以喝茶看报执行其他任务完全不用操心数据搬运的琐事。我刚开始接触DMA时总觉得它配置复杂寄存器一堆不如直接CPU搬运来得直观。但踩过几次坑、做过几个对实时性要求高的项目后我才彻底明白用好DMA是区分“单片机玩具级应用”和“嵌入式系统级设计”的一道关键门槛。它直接关系到系统的效率、功耗和响应能力。无论是做高速数据采集、图像处理、音频播放还是复杂的通信协议栈DMA都是提升性能、降低CPU负载的利器。这篇笔记就是我这些年折腾STM32 DMA的实战总结。我不会照本宣科地罗列寄存器而是从一个实际开发者的角度带你理解DMA的核心思想、掌握CubeMXHAL库的配置套路、啃下那些容易出错的骨头最后再分享几个我实战中总结的“骚操作”和避坑指南。目标是让你看完后不仅能配置出能跑的DMA更能理解为什么这么配出了问题知道往哪儿查。2. DMA核心思想与STM32的实现架构2.1 DMA的本质解放CPU的专用数据通道要理解DMA我们先打个比方。假设CPU是公司老板外设UART、ADC是生产车间内存是仓库。没有DMACPU搬运模式车间生产出一个产品一个字节数据就得打电话给老板“老板货好了”老板放下手头重要的战略会议主循环开车去车间亲手把货搬回仓库然后再回去开会。车间生产速度稍微快一点老板就变成专职搬运工了公司根本没法运转。有DMADMA搬运模式老板雇了一个专业的物流团队DMA。他只需要在项目开始时给物流主管DMA控制器下达一份工单从车间源地址到仓库目标地址每次搬1箱数据宽度总共搬100箱数据量。之后车间每生产好一箱就给物流团队发个信号物流团队就自动完成搬运完全不用打扰老板。老板可以专心开他的战略会议。在STM32中这个“物流团队”就是DMA控制器。它挂在系统总线上能够直接访问内存和所有外设的数据寄存器。它的工作流程核心就是三点源、目标、搬运规则。2.2 STM32的DMA架构与核心概念不同系列的STM32DMA控制器设计略有不同。比如F1/F4系列是基本的DMA1/DMA2而H7系列则升级为了更强大的DMAMDMA、BDMA等。但核心概念是相通的我们以最常见的STM32F4系列为例来拆解。一个DMA控制器包含多个流Stream每个流又包含多个通道Channel。你可以把流想象成一条条独立的“传输流水线”而通道则决定了这条流水线为哪个“客户”外设服务。流Stream 一条完整的传输路径。每个流有独立的配置寄存器源地址、目标地址、数据量等和状态寄存器。F4的DMA1有8个流Stream0-7DMA2也有8个流。通道Channel 将流与外设请求绑定。每个流可以映射到多个可能的通道之一。比如DMA1的Stream5其Channel4可能对应UART1的RX请求Channel0可能对应ADC1的请求。你选择哪个通道就决定了这个流听命于哪个外设。仲裁器Arbiter 当多个流同时发出传输请求时由仲裁器根据优先级软件可配置决定谁先使用总线。FIFO 每个流都有一个4字Word的FIFO缓冲区。它的作用是解耦外设数据吞吐率和内存访问速率。比如外设可能以字节Byte为单位产生数据而内存希望以字Word为单位写入以提高效率。FIFO可以暂存数据凑够一个字再写入内存或者做相反的操作。很多传输错误和效率问题都跟FIFO配置不当有关。关键配置参数解析数据传输方向 这是最基本的一项决定了“搬”的动作。Memory-to-Memory: 内存到内存。常用于数据块复制、填充如memset的加速实现。Memory-to-Peripheral: 内存到外设。比如把一段音频数据缓冲区通过DMA发送到I2S接口。Peripheral-to-Memory: 外设到内存。比如ADC采集数据直接存入数组。Peripheral-to-Peripheral: 外设到外设。较少用某些特定场景下可用。数据宽度与对齐 源和目标的“箱子”大小可以不同但需要小心对齐。Byte,HalfWord(16-bit),Word(32-bit)。可以配置源和目标是不同宽度。对齐警告 如果源是字节目标是字DMA会读4次源每次1字节凑成一个字再写入目标。这隐含了源地址必须是4字节对齐的要求否则可能导致硬件错误。这是新手常踩的坑。地址增量模式 搬完一个数据后地址要不要自动往前走Increment 地址递增。比如从数组buffer[0]搬到buffer[1]。Fixed 地址固定。比如始终从一个外设数据寄存器地址不变读数或向一个固定的GPIO端口寄存器写同一个值实现位带操作或PWM模拟。循环模式Circular Mode 这是实现“双缓冲”、“乒乓缓冲”等高级操作的基础。Disable 普通模式。传输完指定的数据量NDTR寄存器值后传输停止需要重新使能。Enable 循环模式。传输完指定数据量后NDTR自动重载源/目标地址如果开启了增量也自动回到初始值传输永不停止。对于连续数据流如音频、持续ADC采集是必选项。传输完成中断 在传输完成一半Half Transfer Complete, HT或全部完成Transfer Complete, TC时产生中断。这是协调DMA和CPU工作的关键“信号灯”。注意 在配置DMA时脑子里一定要有清晰的“数据流图”数据从哪里来源到哪里去目标以什么形式宽度怎么走方向、增量走多久数据量、循环走完了怎么通知我中断。把这几个问题想清楚配置就成功了一大半。3. 实战配置以UART和ADC为例详解CubeMX与HAL库理论说再多不如动手配一遍。我们以最常用的两个场景UART DMA收发和ADC DMA采集为例看看在STM32CubeMX和HAL库环境下如何一步步实现。3.1 场景一UART的DMA收发不定长数据接收的优雅方案UART通信中CPU轮询效率最低中断接收在高速或大数据量时又会让CPU疲于奔命。DMA是终极解决方案。3.1.1 CubeMX图形化配置使能UART 在Connectivity中选择你的UART如USART1模式选择Asynchronous。波特率、字长、停止位、校验位按需设置。使能DMA 在UART的配置页找到DMA Settings标签页。添加DMA请求 点击Add分别添加USART1_RX和USART1_TX。配置RX流Peripheral-to-MemoryDirection:Peripheral To MemoryPriority:Medium(可根据需要调整)Mode:Circular(关键)为了实现循环接收缓冲区。Increment Address:Memory端选EnablePeripheral端选Disable。Data Width: 通常Peripheral和Memory都选Byte与UART字长匹配。配置TX流Memory-to-PeripheralDirection:Memory To PeripheralMode:Normal(发送完一段就停止)。Increment Address:Memory端选EnablePeripheral端选Disable。3.1.2 代码实现与解析生成代码后我们重点关注应用层的逻辑。// 定义接收缓冲区 #define RX_BUFFER_SIZE 256 uint8_t uart_rx_buffer[RX_BUFFER_SIZE]; // 在main初始化后启动UART的DMA接收 HAL_UART_Receive_DMA(huart1, uart_rx_buffer, RX_BUFFER_SIZE);这行代码启动了循环DMA接收。DMA会默默地把USART1数据寄存器DR里的数据一个字节一个字节地搬到uart_rx_buffer数组中从头到尾再到头无限循环。那么问题来了我怎么知道收到了新数据收到了多少这就是DMA编程的艺术所在。我们不能像中断那样每收一个字节都知道。常用的方法是**“空闲中断Idle Interrupt” DMA**。使能串口空闲中断 在CubeMX中UART配置的NVIC Settings里勾选USART1 global interrupt然后在代码中专门使能空闲中断。// 在UART初始化函数MX_USART1_UART_Init末尾添加 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);编写中断服务程序 在stm32f4xx_it.c的USART1_IRQHandler函数中处理。void USART1_IRQHandler(void) { // ... 其他中断处理 if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清除空闲中断标志 // 计算本次接收到的数据长度 // 当前DMA写到了哪个位置NDTR寄存器存储的是剩余未传输的数据量。 uint16_t remain_size __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 获取剩余未传输数 uint16_t received_size RX_BUFFER_SIZE - remain_size; // 已接收数 总大小 - 剩余数 // 计算本次“帧”的起始位置相对缓冲区 // 我们需要一个变量记录上一次处理到的位置 static uint16_t last_pos 0; uint16_t current_pos RX_BUFFER_SIZE - remain_size; // 当前DMA写入位置 uint16_t frame_len 0; if(current_pos last_pos) { frame_len current_pos - last_pos; } else { // 发生了缓冲区回绕因为循环模式 frame_len RX_BUFFER_SIZE - last_pos current_pos; } // 现在frame_len就是自上次空闲中断以来新收到的数据长度 // 数据位于 uart_rx_buffer[last_pos] 开始的 frame_len 个字节中 // 你可以在这里处理这一帧数据比如拷贝到另一个解析缓冲区 process_uart_frame(uart_rx_buffer[last_pos], frame_len); // 更新last_pos准备接收下一帧 last_pos current_pos; } }这个方案完美解决了UART不定长数据接收的难题CPU只在收到一帧完整数据由空闲时间界定后才被中断一次效率极高。3.1.3 DMA发送的注意事项发送相对简单但要注意同步。uint8_t tx_data[] Hello, DMA!\r\n; // 非阻塞发送函数立即返回DMA在后台发送 HAL_UART_Transmit_DMA(huart1, tx_data, sizeof(tx_data) - 1); // 如果你需要等待发送完成再做别的事可以轮询标志位或使用中断 while(__HAL_DMA_GET_FLAG(hdma_usart1_tx, __HAL_DMA_GET_TC_FLAG_INDEX(hdma_usart1_tx)) 0) { // 等待发送完成 } // 或者在发送完成中断回调函数 HAL_UART_TxCpltCallback 里处理后续逻辑实操心得 不要在DMA发送还未完成时修改发送缓冲区tx_data的内容因为DMA可能还在从那个内存区域读取数据。要么等发送完成要么使用双缓冲区一个给DMA用一个给你准备下一包数据用。3.2 场景二ADC的DMA采集多通道与连续转换ADC采集是DMA的另一个主战场尤其是多通道扫描和连续转换模式没有DMA简直无法想象。3.2.1 CubeMX配置要点配置ADC 在Analog-ADC中配置。Resolution: 分辨率如12位。Scan Conversion Mode:Enabled(如果你要采集多个通道)。Continuous Conversion Mode:Enabled(如果你想持续采集)。DMA Continuous Requests:Enabled(非常重要)这保证了在循环DMA模式下ADC转换完成一次就会自动请求DMA搬运实现无缝连续采集。End Of Conversion Selection: 选择EOC after each conversion每转换一个通道产生EOC或EOC after sequence所有序列转换完产生一个EOC。根据你的中断需求来。配置ADC通道 在Rank里添加你要采集的通道如IN1,IN2设置采样时间。配置DMA 在ADC的DMA Settings添加请求。Direction:Peripheral To MemoryMode:Circular(连续采集必备)。Increment Address:Memory端EnablePeripheral端Disable。Data Width: 这里要小心ADC数据寄存器如DR是32位或16位的取决于型号但数据可能只有12位。通常Peripheral和Memory都设置为Word或HalfWord以匹配ADC数据对齐方式右对齐或左对齐。务必查阅数据手册3.2.2 代码实现与数据读取#define ADC_BUFFER_SIZE 1024 // 假设采集1024个点 uint32_t adc_buffer[ADC_BUFFER_SIZE]; // 缓冲区类型与DMA数据宽度匹配 // 启动ADC的DMA采集 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);这一句之后ADC就会按照你设定的序列比如通道1通道2通道1通道2...开始连续转换每个转换结果都通过DMA自动存入adc_buffer。因为是循环模式缓冲区满了之后会从头开始覆盖。如何获取和处理数据同样我们依赖中断。通常使用“DMA传输完成一半中断HT”和“传输完成中断TC”来实现双缓冲这是处理连续流数据的黄金法则。在CubeMX中使能DMA的这两个中断在DMA配置流那里勾选HT和TC。编写回调函数// 当DMA搬运了半缓冲区的数据时触发 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { // 此时adc_buffer[0] ~ adc_buffer[ADC_BUFFER_SIZE/2 -1] 是稳定的新数据 // 可以安全地处理这前半部分数据而DMA正在向后半部分写入 process_adc_data(adc_buffer, ADC_BUFFER_SIZE/2, 0); } // 当DMA搬运完整个缓冲区的数据时触发 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 此时adc_buffer[ADC_BUFFER_SIZE/2] ~ adc_buffer[ADC_BUFFER_SIZE -1] 是稳定的新数据 // 可以安全地处理后半部分数据而DMA已经回到缓冲区开头开始写入 process_adc_data(adc_buffer ADC_BUFFER_SIZE/2, ADC_BUFFER_SIZE/2, 1); }这样process_adc_data函数永远在处理“稳定”的、DMA已经写完的半个缓冲区而DMA则在同时写入另外半个缓冲区。实现了生产和消费的并行没有数据竞争没有丢失CPU利用率高。注意事项 ADC的DMA请求和UART略有不同。ADC的DMA请求通常与转换结束EOC信号绑定。确保DMA Continuous Requests使能否则在循环模式下DMA传输完一轮后ADC不会自动发起新的请求导致采集停止。这是HAL库的一个易错点。4. 进阶技巧与避坑指南掌握了基本操作我们来看看那些能让你的DMA用得更溜、更稳的进阶技巧和常见大坑。4.1 内存管理与对齐问题这是DMA出错的重灾区尤其是涉及到不同数据宽度和内存地址时。地址对齐 DMA对访问的地址有对齐要求。例如如果数据宽度是Word32位那么访问的地址必须是4字节对齐的。对于在堆栈上定义的局部数组编译器可能不会保证其对齐到4字节这会导致DMA传输错误TEIF标志置位。解决方案是使用对齐修饰符// GCC/ARMCC编译器 __attribute__((aligned(4))) uint8_t buffer[1024]; // 或者使用C11标准 _Alignas(4) uint8_t buffer[1024]; // 在HAL库中也可以使用其宏 ALIGN_32BYTES(uint32_t buffer[256]); // 对齐到32字节对于Cache操作尤其重要缓存一致性Cache Coherency 对于带有数据缓存D-Cache的高性能MCU如STM32H7这是必须面对的魔鬼。CPU和DMA共享内存但CPU访问的是缓存中的数据副本DMA访问的是实际内存。如果你在CPU里准备好数据后启动DMA发送必须确保数据已经从CPU的缓存写回了内存Clean操作。同样DMA接收数据到内存后你必须让CPU的缓存失效Invalidate操作否则CPU读到的还是旧数据。// STM32H7 示例 #include “stm32h7xx_hal.h” // 准备发送数据 memcpy(tx_buffer, data, size); // 清理缓存确保数据已写入内存 SCB_CleanDCache_by_Addr((uint32_t*)tx_buffer, size); // 启动DMA发送 HAL_UART_Transmit_DMA(huart1, tx_buffer, size); // 在DMA接收完成中断回调中 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 使缓存失效确保CPU读取到DMA刚写入内存的新数据 SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, size); process_data(rx_buffer, size); }忽略缓存一致性会导致数据莫名其妙错误且极难调试。4.2 高效数据流模式双缓冲与链表传输双缓冲Double Buffer 前面ADC例子已经展示了。其核心思想是准备两个缓冲区A和B。当DMA向A写数据时CPU处理B当DMA写满A切换去写B时CPU切换去处理A。如此往复实现零等待。这在音频处理、图形显示等场景下是标准做法。链表传输Linked List或 DMA突发传输 在一些高端型号如STM32H7的DMA中支持更复杂的传输模式。你可以预先在内存中定义一个“描述符”链表每个描述符包含下一块数据的源地址、目标地址、长度等信息。DMA完成当前传输后能自动从链表加载下一个描述符并继续传输无需CPU干预。这非常适合处理分散-聚集Scatter-Gather型数据比如网络数据包。4.3 调试DMA当传输不工作时怎么办DMA出错时往往没有直观现象。掌握调试方法至关重要。检查初始化顺序 一定要先初始化DMA再启动外设的DMA请求。顺序反了可能导致第一次请求丢失。监视DMA状态寄存器 在调试器中查看DMAx Streamy的CR控制寄存器、NDTR剩余数据计数、PAR外设地址、M0AR内存0地址等寄存器确认配置是否正确加载。检查中断标志 查看ISR寄存器。TEIF传输错误是最常见的原因包括地址对齐错误、访问非法内存等。DMEIF直接模式错误可能与FIFO配置有关。FEIF是FIFO错误。使用断点和变量监视 在DMA传输完成中断回调函数里设置断点看是否能进入。监视你的数据缓冲区看是否有数据被写入。如果没有检查DMA请求是否真的产生了比如UART是否真的收到了数据ADC转换是否被触发了。简化测试 先尝试最简单的Memory-to-Memory传输验证DMA基本功能是否正常。再逐步增加复杂度切换到外设模式。注意外设的DMA使能位 很多外设除了全局的DMA使能自身还有一个DMA发送/接收使能位。例如UART的CR3寄存器中的DMAT和DMAR位。HAL库通常在启动函数里设置但如果你直接操作寄存器千万别忘了。4.4 HAL库的“坑”与应对HAL库简化了配置但也隐藏了一些细节容易导致问题。HAL_UART_Receive_DMA的重复调用 这个函数不仅启动DMA还会重新设置DMA的NDTR计数器和内存地址。如果你在DMA传输中途再次调用它会打断当前传输。对于循环接收只需在初始化时调用一次。DMA句柄状态管理 HAL库用hdma-State来管理DMA流的状态READY,BUSY,ERROR等。某些操作如修改配置要求流处于READY状态。在操作前可以调用HAL_DMA_Abort()来中止当前传输并使状态回归READY。中断回调的上下文 DMA传输完成中断回调函数如HAL_UART_TxCpltCallback是在中断上下文被调用的。务必保持其短小精悍不要在里面做耗时操作如HAL_Delay, 复杂的计算。通常只设置一个标志位通知主循环或其他任务来处理。5. 从项目角度规划DMA资源在一个复杂的项目中多个外设可能都需要DMA。如何规划和分配有限的DMA流资源避免冲突查阅数据手册的DMA请求映射表 这是你的“作战地图”。它会明确告诉你USART1_RX请求可以映射到DMA1的Stream2 Channel4或者DMA2的Stream5 Channel4等等。你需要为每个外设选择一个不冲突的流。优先级设置 每个流可以设置4个优先级Very High,High,Medium,Low。为实时性要求最高的数据流如ADC采集、电机PWM更新设置高优先级。考虑总线矩阵瓶颈 DMA和CPU都要通过总线访问内存和外设。如果它们同时访问同一个存储体如SRAM会产生冲突和等待。对于高性能应用可以考虑将DMA的源/目标缓冲区放在不同的SRAM块中如果MCU支持多块SRAM如STM32F4的CCM RAM。调整CPU的指令预取或缓存策略减少总线争用。使用内存到内存传输加速数据操作 除了服务外设DMA也可以用来加速纯内存操作。比如用DMA来实现快速的memcpy或memset尤其是在初始化大片内存或搬运显示缓冲区的场景下能显著减轻CPU负担。我个人在多个以数据为中心的项目如振动信号分析仪、多路同步数据记录器中DMA都是系统的基石。它让我能将CPU从繁重的数据搬运中解脱出来去运行更复杂的算法和业务逻辑。最初学习时的那些配置繁琐、调试痛苦的经历在项目成功运行、系统流畅稳定后都变成了宝贵的经验。记住DMA不是洪水猛兽它是一个强大的工具。理解其原理谨慎配置善用调试工具你就能驾驭它让你的STM32项目性能飞升。最后一个小建议为你常用的DMA配置如UART RX循环、ADC双缓冲封装成独立的、健壮的驱动模块以后的项目直接复用会大大提升开发效率。
返回列表