STM32 DMA原理与HAL库实战:从核心概念到串口不定长接收

📅 2026/8/18 22:41:48
STM32 DMA原理与HAL库实战:从核心概念到串口不定长接收
1. 项目概述为什么DMA是STM32开发者的必修课如果你玩过一阵子STM32肯定对“CPU占用率”和“数据搬运效率”这两个词不陌生。想象一下你正在做一个数据采集项目单片机需要一边通过ADC读取传感器数据一边通过串口把数据打包发送给上位机。如果让CPU亲自去干“搬运数据”这个脏活累活——也就是用轮询或者中断的方式一个字节一个字节地从ADC数据寄存器搬到内存再从内存搬到串口发送寄存器——那CPU基本就别想干其他事了全耗在“搬砖”上了。这时候DMADirect Memory Access直接存储器访问就像你雇来的一个“专职搬运工”。你只需要告诉它从A地点外设寄存器或内存搬多少东西搬到B地点内存或外设寄存器然后就可以放手让它去干CPU则腾出手来处理更复杂的逻辑计算比如数据滤波、协议解析或者状态机控制。这种“解放CPU”的能力是提升嵌入式系统整体性能和实时性的关键。而STM32的HAL库则为我们操作DMA这个强大硬件提供了统一、便捷的软件接口虽然初学时觉得抽象但用熟了会发现它能极大提升开发效率尤其是在项目复杂、外设繁多时。这篇文章我就结合自己踩过的坑和实战经验带你彻底搞懂HAL库下的DMA让你从“知道有这么个东西”变成“能在项目里熟练用它”。2. DMA核心概念与HAL库设计思想解析2.1 DMA到底在干什么一个生活化的比喻让我们彻底抛开数据手册里那些晦涩的术语。你可以把整个STM32芯片想象成一个大型物流仓库内存和多个忙碌的加工车间外设如ADC、串口、SPI等。CPU是仓库的总调度员聪明但精力有限。无DMA模式CPU搬运ADC车间生产出一箱货数据打电话给调度员CPU。CPU放下手头的账目计算核心算法跑到ADC车间搬起这箱货穿过整个仓库放到串口车间的发货区然后再跑回去继续算账。如果货很多CPU就整天在跑腿账也算不完系统就“卡”了。DMA模式专职搬运工CPU这个总调度员招聘了一个叫DMA的专职搬运工。CPU只需要在一开始给DMA写一张“搬运工单”配置DMA从ADC车间的3号出货口外设地址搬货每次搬一箱数据宽度连续搬100箱数据量搬到仓库的A区1号货架内存地址。写完后CPU就可以去专心算他的账了。DMA搬运工自己会盯着ADC车间的“货已备好”指示灯外设触发信号灯一亮就去搬一箱放到指定货架直到100箱搬完然后跑去拍拍CPU的肩膀说“老板活干完了”触发传输完成中断。整个搬运过程完全不占用CPU的“思考时间”。在STM32中这个“搬运工”DMA是一个独立的硬件模块它通过专门的总线AHB直接在“外设”和“内存”之间搬运数据效率极高。2.2 HAL库如何封装DMA理解其“句柄-初始化-回调”三板斧ST的HAL库追求的是跨STM32系列芯片的通用性。为了达到这个目的它采用了一种面向对象的思想尽管是用C语言实现的。理解这套模式是熟练使用HAL库的关键。句柄Handle是核心每个使用DMA的外设如UART、ADC都有一个对应的DMA句柄结构体比如UART_HandleTypeDef里的hdmatx发送DMA句柄和hdmarx接收DMA句柄。这个句柄结构体例如DMA_HandleTypeDef是你与DMA硬件打交道的“遥控器”里面包含了该DMA数据流/通道的所有配置信息源地址、目标地址、数据长度、传输模式等以及当前状态。初始化Init是配置在使用前你必须填充一个初始化结构体DMA_InitTypeDef设定好搬运的规则然后调用HAL_DMA_Init()将这个配置写入硬件寄存器。这相当于给DMA这个“搬运工”做上岗培训告诉他工作流程。回调Callback是通知HAL库采用“回调函数”机制来通知你DMA传输的状态。它不是让你直接在中断服务函数里写业务代码而是要求你实现几个特定的弱函数。当传输完成、半传输完成或发生错误时HAL库的中断服务程序会自动调用你实现的这些回调函数。这是一种更清晰、更模块化的设计。HAL_DMA_XferCpltCallback(): 传输全部完成时调用。HAL_DMA_XferHalfCpltCallback(): 传输完成一半时调用在循环模式或双缓冲模式下非常有用。HAL_DMA_XferErrorCallback(): 传输发生错误时调用。这种设计的好处是业务逻辑与底层驱动分离代码更易维护缺点是对于初学者找不到回调函数在哪、为什么没被调用常常是第一个遇到的“拦路虎”。注意很多初学者会困惑“我明明开启了中断为什么回调函数没执行” 90%的原因是你没有“实现”Override这些弱函数。编译器链接时如果你没写就会链接库里面那个空的弱函数自然什么都不会发生。你必须在自己的main.c或者某个驱动文件里重新实现这些函数。3. 核心细节解析与实操要点3.1 关键参数配置不止是填地址那么简单使用CubeMX生成代码时它会帮你填充大部分参数但你必须理解这些参数的含义才能在出问题时进行调试或者进行高级优化。数据流与通道Stream Channel这是DMA资源寻址的两级结构。STM32的DMA控制器有多个数据流Stream每个数据流可以映射到多个外设通道Channel。例如DMA2的Stream0可以配置为Channel4对应UART1_RX也可以配置为Channel0对应其他外设。一个硬性规则一个数据流在同一时间只能用于一个外设的一个方向发送或接收。在CubeMX中这个映射关系是自动完成的但你心里要有数。数据传输方向Direction这是最容易弄混的地方之一。MEMORY_TO_MEMORY内存到内存。这是唯一需要软件触发调用HAL_DMA_Start的模式常用于大数据块拷贝效率远超CPU的memcpy。MEMORY_TO_PERIPH内存到外设。例如从内存的数组发送数据到串口的发送数据寄存器TDR。此时源地址是内存数组地址目标地址是huart1.Instance-TDR。PERIPH_TO_MEMORY外设到内存。例如从串口的接收数据寄存器RDR读取数据到内存数组。此时源地址是huart1.Instance-RDR目标地址是内存数组地址。踩坑记录曾经把方向配反了调试了半天发现数据死活不对。记住站在DMA的角度看“源Source”是数据的出发点“目标Destination”是数据的终点。对于串口发送数据从内存“出发”“终点”是串口外设所以是MEMORY_TO_PERIPH。数据宽度Data Width源和目标的宽度可以不同但通常设置为一致如都是Byte或都是HalfWord。DMA会自动处理打包和解包。例如源是32位Word目标是8位ByteDMA会分4次每次搬移一个字节。要点数据宽度会影响地址的自动递增。如果你设置数据宽度为Word4字节那么地址递增的步长也是4。地址递增模式Increment这是实现搬运数组或缓冲区的关键。如果源是内存数组你需要开启源地址递增这样DMA每搬完一个数据就会自动指向数组的下一个元素。同样如果目标是内存数组就需要开启目标地址递增。但是外设寄存器地址绝对不能递增例如串口的发送数据寄存器TDR地址是固定的如果你开启了目标地址递增DMA第二次就会把数据写到一个错误的、未定义的地址导致程序崩溃。循环模式Circular Mode与普通模式Normal Mode普通模式DMA搬运完指定的数据量Data Length后就自动停止需要软件重新启动。适用于单次、定长的数据传输。循环模式DMA搬运完指定数据量后会自动重置计数器从头开始循环搬运。这是实现“双缓冲”Double Buffer或“连续采集”的基石。例如在ADC连续采集音频信号时开启循环模式DMA就会在两个缓冲区或一个环形缓冲区间无缝切换持续不断地将ADC数据搬到内存CPU只需要在“半传输完成”和“传输完成”回调函数中处理已经填满的半个或整个缓冲区即可实现了数据流的“乒乓操作”毫无间隙。3.2 外设与DMA的联动谁说了算配置好DMA只是第一步更重要的是让外设知道DMA的存在并在合适的时机触发DMA请求。外设使能DMA请求以串口为例在初始化UART后你需要调用HAL_UART_Transmit_DMA()或HAL_UART_Receive_DMA()。这些函数内部做了三件关键事 a. 配置并启动DMA根据你之前初始化好的句柄。 b.使能外设的DMA发送/接收请求例如设置UART的CR3寄存器的DMAT或DMAR位。这是告诉串口“你准备好数据/有空闲时别叫CPU直接叫DMA来搬。” c. 使能DMA传输完成中断如果你在CubeMX里勾选了的话。触发时机对于发送MEMORY_TO_PERIPH当外设的“发送数据寄存器空”TXE标志置位时表示它已经准备好接收新数据了此时外设会向DMA控制器发出一个请求Request。DMA收到请求后立即搬运一个数据宽度由配置决定到该寄存器。寄存器被填充后TXE标志清零外设开始发送这个数据发送即将完成时TXE再次置位触发下一次DMA请求……如此循环直到DMA计数器归零。对于接收PERIPH_TO_MEMORY当外设收到一个完整数据并放入“接收数据寄存器”RDR后会置位“接收数据寄存器非空”RXNE标志并向DMA发出请求。DMA随即将该数据搬走搬运后RXNE标志清零。这个过程是完全由硬件自动协调的只要初始化配置正确数据流就会像流水线一样自动运转CPU无需干预。4. 实战案例串口DMA不定长数据接收与空闲中断这是最经典、最实用的DMA案例之一。传统的中断接收每来一个字节都要进一次中断在高速或大数据量时中断频率太高消耗CPU资源。而“DMA空闲中断Idle Interrupt”方案可以完美解决。4.1 方案原理DMA接收我们配置串口在DMA循环模式下接收数据目标是一个足够大的线性缓冲区比如uint8_t rx_buffer[256]。空闲中断使能串口的“空闲线路检测中断”。当串口总线RX线在收到一帧数据后持续保持高电平空闲状态超过一个字符传输时间时硬件会置位“空闲中断标志”。协同工作平时数据源源不断地通过DMA自动存入rx_bufferDMA的写指针CNDTR寄存器表示剩余数据量不断递减。当一帧数据发送完毕总线进入空闲状态触发空闲中断。数据处理在空闲中断服务函数中我们可以通过计算DMA总数据长度 - 当前CNDTR值得到本次接收到的数据长度。然后将这部分数据取出处理并重置DMA的计数器CNDTR和内存目标地址为接收下一帧数据做好准备。4.2 详细步骤与代码剖析假设我们使用UART1DMA接收流已配置为循环模式。步骤一初始化与启动// 在main函数初始化部分 UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; // CubeMX生成的初始化代码会配置好huart1和hdma_usart1_rx的关联 // ... // 启动DMA接收指向一个循环缓冲区 uint8_t rx_dma_buffer[256]; if (HAL_UART_Receive_DMA(huart1, rx_dma_buffer, 256) ! HAL_OK) { Error_Handler(); } // 使能串口空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);步骤二编写空闲中断处理函数我们不能直接在中断服务函数里写复杂逻辑。HAL库的做法是在串口中断服务程序USART1_IRQHandler()由CubeMX生成中会调用HAL_UART_IRQHandler(huart1)。这个函数会检测各种中断标志包括空闲中断。当检测到空闲中断时它会调用一个名为UART_DMAAbortOnError的函数但不会调用一个现成的“空闲中断回调”。因此我们需要稍微“绕一下”在stm32fxx_it.c中找到USART1_IRQHandler()。在调用HAL_UART_IRQHandler(huart1);之后添加我们自己的空闲中断检测和处理逻辑。// 在 stm32fxx_it.c 中 void USART1_IRQHandler(void) { /* 调用HAL库的通用中断处理 */ HAL_UART_IRQHandler(huart1); /* 自定义空闲中断处理 */ if((__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET)) { // 1. 清除空闲中断标志通过读SR寄存器再读DR寄存器 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 2. 计算本次接收到的数据长度 // DMA接收数据长度初始化时设定的值 uint16_t dma_buffer_size 256; // 当前DMA剩余未传输数据量CNDTR寄存器 uint16_t remain_data __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 已接收数据长度 总长度 - 剩余长度 uint16_t received_len dma_buffer_size - remain_data; // 3. 处理数据 (例如将数据拷贝到另一个处理缓冲区) if(received_len 0) { // 注意由于是循环DMA我们需要计算数据的起始位置。 // 写指针位置 缓冲区总大小 - 剩余数据量 uint16_t write_index dma_buffer_size - remain_data; uint8_t temp_buffer[256]; // 情况1数据没有跨过缓冲区末尾 if(write_index received_len) { memcpy(temp_buffer, rx_dma_buffer[write_index - received_len], received_len); } // 情况2数据跨过了缓冲区末尾环形缓冲区的特性 else { uint16_t first_part_len received_len - write_index; memcpy(temp_buffer, rx_dma_buffer[dma_buffer_size - first_part_len], first_part_len); memcpy(temp_buffer[first_part_len], rx_dma_buffer, write_index); } // 4. 将处理好的数据发送给任务队列或直接处理 // process_received_data(temp_buffer, received_len); } // 5. 可选重新启动DMA接收清除状态。对于循环模式通常不需要。 // 如果是普通模式则需要HAL_UART_Receive_DMA(huart1, rx_dma_buffer, 256); } }实操心得计算接收长度和拷贝数据是这里的难点尤其是处理循环缓冲区数据“环绕”的情况。一定要画图理解缓冲区的读写指针变化。在实际项目中我通常会封装一个独立的环形缓冲区Ring Buffer模块来处理DMA接收的数据逻辑会更清晰也便于多任务访问。5. 常见问题与排查技巧实录5.1 DMA传输不启动或数据错误这是最让人头疼的问题。请按照以下清单逐项核对99%的问题都能定位。问题现象可能原因排查方法调用HAL_XXX_Transmit_DMA后毫无反应1. DMA或外设时钟未使能。2. DMA句柄或外设句柄初始化失败返回值未检查。3. 外设的DMA请求未使能函数内部已使能检查是否被其他代码关闭。4. 源/目标地址配置错误。1. 在main()初始化早期使用__HAL_RCC_DMA1_CLK_ENABLE()等函数确认时钟已开。2. 检查所有HAL_Init、HAL_XXX_Init、HAL_DMA_Init的返回值。3. 调试时在外设寄存器视图如USART1-CR3中查看DMAT/DMAR位是否为1。4. 单步调试查看DMA控制寄存器如SxPAR, SxM0AR的地址值是否正确。数据只传输了一部分就停止1. DMA传输模式为“普通模式”Normal传输完指定长度后自动停止。2. 传输过程中发生错误如FIFO错误、传输错误DMA自动停止。1. 检查CubeMX配置或初始化代码确认模式是“普通模式”还是“循环模式”。2. 实现并检查HAL_DMA_XferErrorCallback回调函数查看错误标志位hdma-ErrorCode。接收到的数据全是0或乱码1.数据方向配置错误最常见。2. 源/目标地址递增模式配置错误外设寄存器地址被递增。3. 数据宽度不匹配如外设是8位内存按16位访问。4. 内存缓冲区未定义为全局变量或静态变量函数结束后被释放。1.反复检查Direction参数画图确认数据流方向。2. 确认外设寄存器地址如huart1.Instance-TDR对应的Increment为DISABLE。3. 确保外设数据寄存器宽度与DMA配置宽度一致。4. 确保DMA使用的缓冲区数组定义在函数体外或使用static关键字。使用DMA发送时最后一个/几个字节发不出去1. 在DMA传输完成中断回调函数中过早地关闭了串口或DMA。2. 串口TC传输完成标志未有效等待。DMA只负责把数据搬到TDR寄存器串口外设需要时间将数据一位位发出。1. 不要在HAL_UART_TxCpltCallback中立即禁用DMA或串口。发送完成仅代表数据已全部交给DMA并搬到了TDR不代表已从TX引脚发出。2. 如果需要精确知道所有比特都已发出可以在回调函数中等待TC标志while(__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET);。5.2 内存对齐与Cache一致性问题针对Cortex-M7/H7等高阶芯片当你使用带有数据缓存D-Cache的MCU如STM32H7时一个“幽灵”问题会出现CPU写入缓冲区的数据实际上还留在Cache里没有及时更新到真正的内存SRAM中。如果此时DMA直接从内存读取数据发送它读到的是旧数据反之DMA接收数据直接写入内存但CPU读缓冲区时可能读到的是Cache里的旧数据。解决方案对于DMA发送CPU写DMA读在启动DMA传输前需要将缓存的数据“写回”内存。// 假设发送缓冲区是 send_buf SCB_CleanDCache_by_Addr((uint32_t*)send_buf, sizeof(send_buf)); HAL_UART_Transmit_DMA(huart1, send_buf, len);对于DMA接收DMA写CPU读在CPU读取DMA接收到的数据之前需要“无效化”对应内存区域的Cache让CPU从真实内存重新加载。// 在空闲中断中处理数据前 SCB_InvalidateDCache_by_Addr((uint32_t*)rx_dma_buffer, received_len); // 然后再进行 memcpy 或数据处理个人踩坑在STM32H743上用DMA传输ADC数据数据时对时错折腾了一天。最后发现是Cache问题。对于高性能MCU这是必须检查的一环。5.3 中断优先级与冲突DMA传输完成、半传输完成、错误中断以及外设如串口的中断都有优先级。如果安排不当可能导致中断丢失或响应不及时。建议将DMA传输完成中断的优先级设置为比外设中断稍低。例如串口数据接收的DMA传输完成中断优先级可以低于串口本身的错误中断。但具体需根据业务逻辑调整。关键点确保中断服务函数以及回调函数执行时间尽可能短。不要在中断里进行复杂计算、延时或等待标志。正确的做法是在中断中设置标志、释放信号量或发送消息给任务如果用了RTOS让具体的处理逻辑在任务中执行。6. 进阶应用DMA双缓冲与内存到内存传输6.1 DMA双缓冲模式实现“乒乓操作”循环模式已经很好但双缓冲Double Buffer更进一步。它允许你指定两个缓冲区BUF0和BUF1。DMA在BUF0填满后自动切换到BUF1继续填充并触发“传输完成中断”针对BUF0在BUF1填满后又切回BUF0再次触发中断。这样CPU几乎总是有一个完整的、稳定的缓冲区可供处理避免了处理数据时缓冲区被覆盖的风险。在HAL库中可以通过HAL_DMAEx_MultiBufferStart_IT()等函数来启动双缓冲传输。配置的关键在于正确设置内存地址0M0AR和内存地址1M1AR。应用场景ADC高速连续采集、摄像头数据接收、音频流处理。它能将数据搬运的“时间片”与数据处理的“时间片”完全解耦是实现高性能实时流处理的关键技术。6.2 内存到内存M2M传输被低估的加速器很多人只把DMA用于外设数据搬运却忽略了它的M2M模式。这是芯片内部SRAM之间高速数据拷贝的利器。// 初始化DMA为M2M模式 hdma_m2m.Instance DMA2_Stream0; hdma_m2m.Init.Direction DMA_MEMORY_TO_MEMORY; hdma_m2m.Init.PeriphInc DMA_PINC_ENABLE; // 源地址递增 hdma_m2m.Init.MemInc DMA_MINC_ENABLE; // 目标地址递增 hdma_m2m.Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma_m2m.Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma_m2m.Init.Mode DMA_NORMAL; // 或 DMA_CIRCULAR // ... 其他配置 HAL_DMA_Init(hdma_m2m); // 启动传输 uint32_t src[100], dst[100]; // 填充src... HAL_DMA_Start_IT(hdma_m2m, (uint32_t)src, (uint32_t)dst, 100); // 等待传输完成回调或使用 HAL_DMA_PollForTransfer性能对比实测在STM32F407168MHz上拷贝1KB数据使用CPU的memcpy大约需要20微秒而使用DMA M2M仅需约5微秒并且这5微秒内CPU是完全自由的。在需要搬运大量数据如图像缓冲区、音频样本块时收益巨大。注意事项M2M传输的触发源是软件HAL_DMA_Start而不是外设事件。它不占用任何外设请求通道只要DMA数据流空闲即可使用。7. 调试技巧如何直观地观察DMA状态调试DMA问题时光看代码不行必须借助调试器。查看寄存器在IDE如STM32CubeIDE的寄存器视图中找到对应的DMA控制器如DMA1。重点关注SxCR控制寄存器查看EN使能位、DIR方向、CIRC循环模式等是否配置正确。SxNDTR剩余数据量计数器。这是动态变化的在传输过程中会递减。这是判断DMA是否在工作的最直接证据。SxPAR和SxM0AR外设地址和内存地址。确认它们指向正确的地址。使用逻辑分析仪或示波器对于发送可以直接测量TX引脚波形看数据是否按预期发出。对于接收可以模拟发送数据看MCU的响应。善用断点和变量观察在DMA传输完成回调函数HAL_DMA_XferCpltCallback中设置断点。如果断点从未触发说明传输未完成或中断未正确使能/响应。观察作为DMA目标地址的数组内存看数据是否被写入。检查中断标志在调试器的“中断”视图或寄存器中查看DMA和对应外设的中断标志位如DMA_ISR寄存器中的TCIFxUSART_ISR中的IDLE是否被置位。这能帮你确定硬件是否真的产生了中断事件。最后DMA是一个“配置大于编码”的模块。大部分问题都出在初始化的参数配置上。耐心、细致地对照数据手册和CubeMX的配置理解每一个参数的意义是成功使用DMA的唯一捷径。当你熟悉之后它将成为你嵌入式开发武器库中最得力的工具之一能轻松应对高速数据流、低功耗应用等复杂场景。