1. 项目概述:为什么DMA是STM32开发的“效率倍增器”?
如果你正在用STM32做项目,尤其是涉及到大量数据搬运的场景——比如通过串口接收一长串传感器数据、用ADC连续采集音频信号、或者驱动TFT屏幕刷图——那你肯定对CPU被“绑死”在搬运数据上,导致主程序卡顿的体验深有感触。这时候,DMA(Direct Memory Access,直接存储器访问)就是你必须掌握的核心技能。它就像一个独立于CPU的“专职搬运工”,能在不打扰CPU“思考”(执行主程序)的情况下,自动完成内存与外设之间、或者内存与内存之间的数据转移。
我最初接触DMA时,觉得它配置繁琐,不如直接for循环操作寄存器来得直观。但踩过几次坑后才发现,用好DMA,整个系统的实时性和效率是质的飞跃。CPU被解放出来,可以更从容地处理算法、逻辑判断和用户交互,系统响应变得丝滑。无论是做物联网终端频繁上报数据,还是做电机控制需要精确定时处理PWM,DMA都是底层优化的关键。
这篇笔记,我就结合自己从标准库到HAL库,在多个实际项目(如智能台灯调光、电机驱动、串口高速通信)中应用DMA的经验,把它从原理到踩坑,系统地梳理一遍。目标很明确:让你看完就能在自己的STM32板子上把DMA用起来,并且知道怎么避开那些新手常掉的“坑”。
2. DMA核心原理与STM32中的实现架构
2.1 DMA到底在干什么?一个生动的类比
想象一下,你(CPU)在厨房(系统)里要做一顿大餐(执行主程序)。食谱要求你从冰箱(外设,如ADC)里拿10个鸡蛋(数据)到碗里(内存)。最笨的方法是:打开冰箱,拿一个鸡蛋,走到碗边放下,再回去拿第二个……如此反复十次。这个过程你(CPU)全程参与,没法同时去切菜或炒菜。
DMA的做法是:你雇佣了一个助手(DMA控制器)。你只需要告诉助手:“从冰箱里拿10个鸡蛋,放到那个蓝色的碗里。” 然后你就可以转身去切菜了(CPU执行其他任务)。助手会自己打开冰箱门,一次拿一个或几个鸡蛋(取决于它的“搬运能力”),整齐地放到碗里。等所有鸡蛋都搬完了,助手可能会过来拍拍你的肩膀说:“老板,活儿干完了!”(触发一个中断)。你检查一下碗,鸡蛋已经到位,可以继续下一步烹饪了。
在STM32里,这个“助手”就是DMA控制器。它通过专用的硬件总线,连接着内存(SRAM)和各种外设(如USART、ADC、SPI、I2S、TIM等)的数据寄存器。CPU通过配置一系列寄存器(描述搬运的源、目标、数量、模式等),给DMA控制器下达一个“搬运任务”。一旦启动,DMA控制器就独立工作,CPU几乎可以完全不管,直到搬运完成或出错。
2.2 STM32 DMA的硬件架构与通道概念
不同系列的STM32,其DMA控制器的数量和设计略有差异。以最常见的STM32F1和STM32F4/H7为例:
STM32F1系列:通常有1个或2个DMA控制器(DMA1, DMA2)。每个控制器有若干条通道(Channel)。例如DMA1有7个通道。每个通道在硬件上被固定地分配给一个或几个特定的外设。比如,DMA1的Channel1可能被分配给ADC1,Channel4被分配给USART1_TX,Channel5被分配给USART1_RX。这意味着,如果你要用DMA把ADC1的数据搬到内存,你必须使用DMA1的Channel1。这种设计简单直接,但灵活性稍差,需要查数据手册的“DMA请求映射表”。
STM32F4/H7等系列:引入了更灵活的流(Stream)和通道(Channel)概念。以STM32F4的DMA2为例,它有8个流(Stream0~Stream7),每个流可以映射到多达8个不同的通道(Channel0~Channel7),而每个通道对应一个外设请求。比如,你可以配置DMA2_Stream0映射到Channel4,而Channel4对应USART1_RX。这样,只要硬件不冲突,你可以更自由地分配DMA资源。显然,F4/H7的DMA更强大,但也更复杂。
注意:无论哪种架构,核心思想都是“外设向DMA控制器发出请求,DMA控制器响应请求执行搬运”。对于发送(TX)操作,通常是外设准备好发送数据寄存器(空)时请求DMA填充数据;对于接收(RX)操作,是外设收到数据并填满接收数据寄存器时请求DMA取走数据。
2.3 关键工作模式解析
普通模式(Normal Mode):
- 行为:DMA在完成指定数据数量的搬运后,自动停止。通道使能位会被硬件清除。
- 应用场景:单次、定长的数据传输。例如,通过USART发送一段固定的命令报文;从内存中读取一块固定大小的数据通过SPI发送给屏幕。
- 要点:每次传输前都需要重新配置数据数量并开启DMA通道。
循环模式(Circular Mode):
- 行为:DMA在完成一轮搬运后,自动将数据计数器重置为初始值,并从头开始新一轮搬运,永不停止(除非手动关闭)。
- 应用场景:连续、不间断的数据流。这是最常用的模式之一。
- ADC连续扫描:ADC以固定频率采样,DMA循环将转换结果搬运到内存中的一个数组,形成连续的数据缓冲区。
- 双缓冲(Double Buffer):结合循环模式和内存地址自增,可以实现“乒乓操作”。例如,设置两个大小相等的缓冲区A和B。DMA先填满A,此时产生“半传输完成”或“传输完成”中断,CPU可以处理A中的数据,同时DMA继续向B中填充数据。当B填满时,DMA又切换回A。这样实现了数据采集和处理的并行,几乎没有延迟。
- 要点:这是实现“后台”数据采集的关键。需要小心处理缓冲区指针和中断,避免数据覆盖或读取错误。
存储器到存储器模式(Memory-to-Memory):
- 行为:数据从内存的一个区域搬运到另一个区域。此模式下没有外设参与,因此也不需要外设请求。
- 应用场景:大数据块复制、图像处理中的数据搬移、缓存管理等。
- 要点:在F1系列中,只有DMA2的部分通道支持此模式。在F4/H7中,需要将流的通道选择配置为“软件”(Software)或特定的内存通道。此模式由软件触发(使能流),一旦启动会连续搬完所有数据。
2.4 数据宽度、增量与对齐陷阱
这是DMA配置中最容易出错的地方之一,直接关系到数据是否被正确搬运。
数据宽度(Data Width):指单次DMA请求搬运的数据单位大小。常见有字节(8位)、半字(16位)、字(32位)。
- 源端和目标端的宽度可以不同,但DMA控制器会自动进行打包或解包。例如,源是8位(外设数据寄存器),目标是32位(内存),且地址增量开启。如果外设数据寄存器是16位(比如ADC的DR寄存器是16位,但数据只有12位有效),你配置为8位宽度,就会读取出错。
- 黄金法则:源和目标的数据宽度,必须与外设数据寄存器及内存中变量的实际大小匹配。比如ADC是12位,结果放在16位的
uint16_t数组中,那么DMA的数据宽度应设为半字(16位)。
地址增量(Increment):
- 外设地址:通常不增量。因为DMA总是从一个固定的外设数据寄存器(如
USART1->DR)读或写。 - 存储器地址:通常需要增量。因为我们希望数据被依次存放到内存数组中连续的位置,或者从数组连续位置读取数据发送。
- 外设地址:通常不增量。因为DMA总是从一个固定的外设数据寄存器(如
对齐问题(Alignment):
- 这是一个隐藏的坑。如果内存地址(指针)没有按照数据宽度对齐,在某些情况下(尤其是使能了D-Cache的Cortex-M7内核,如STM32H743)会导致数据错误或DMA传输失败。
- 例如:你定义了一个
uint32_t buffer[100],其首地址通常是4字节对齐的,用DMA以字(32位)宽度访问没问题。但如果你用一个uint8_t*指针偏移后指向这个数组的某个非4字节对齐的地址,并把它作为DMA的存储器地址,就可能出问题。 - 避坑技巧:
- 使用编译器指令确保缓冲区对齐。例如在GCC/ARMCC中:
uint32_t buffer[100] __attribute__((aligned(4)));。 - 对于STM32H7系列,要特别注意D-Cache(数据缓存)的一致性。当DMA直接写入SRAM(被Cache缓存的内存区域)后,CPU去读这个内存时,可能读到的是Cache里的旧数据,而不是DMA刚写入的新数据。解决方法是在DMA写入后、CPU读取前,对相应的内存区域执行**缓存无效化(Cache Invalidate)**操作。HAL库提供了
SCB_InvalidateDCache_by_Addr()函数。
- 使用编译器指令确保缓冲区对齐。例如在GCC/ARMCC中:
3. 从零开始:CubeMX配置DMA的实操指南
理论说再多,不如动手配一遍。我们以STM32F407为例,使用STM32CubeMX和HAL库,配置一个最经典的场景:USART1使用DMA接收不定长数据。这是物联网节点、调试器、与上位机通信的基石。
3.1 CubeMX图形化配置步骤
打开CubeMX,选择芯片,配置好系统时钟(RCC),保证主频正确。
配置USART1:
- 在左侧“Pinout & Configuration”标签页,找到
USART1。 - 将模式(Mode)设置为“Asynchronous”(异步通信)。
- 配置波特率(Baud Rate)、字长(Word Length)、停止位(Stop Bits)、校验位(Parity)等,与你的通信对象匹配。
- 在左侧“Pinout & Configuration”标签页,找到
关键步骤:启用DMA:
- 在USART1的配置界面,切换到“DMA Settings”标签页。
- 点击“Add”添加一个DMA请求。
- 在“DMA Request”中,选择“USART1_RX”。CubeMX会自动为你分配一个可用的流(Stream)和通道(Channel),比如
DMA2 Stream2 / Channel4。 - 配置DMA参数:
- Direction:
Peripheral To Memory(外设到存储器,即接收)。 - Priority: 根据系统实时性要求选择,通常
Medium即可。如果这是最高优先级的任务,可选High。 - Mode:
Circular(循环模式)。这是我们实现不定长接收的基础。 - Increment Address:
Peripheral:Disable(外设地址固定为USART1->DR)。Memory:Enable(内存地址递增,数据依次存入数组)。
- Data Width:
Peripheral:Byte(USART数据寄存器是8位的)。Memory:Byte(我们用一个uint8_t数组来存)。
- Direction:
- 同样地,可以再添加一个
USART1_TX的DMA请求,用于发送。其方向为Memory To Peripheral,模式根据需求选Normal(发固定包)或Circular(连续发流数据)。
配置NVIC(中断控制器):
- 在“NVIC Settings”标签页,找到刚刚为USART1_RX分配的DMA流的中断(如
DMA2 stream2 global interrupt),勾选启用(Enabled)。 - 同时,也建议启用
USART1 global interrupt。我们将结合DMA和串口空闲中断来实现不定长数据帧的识别。
- 在“NVIC Settings”标签页,找到刚刚为USART1_RX分配的DMA流的中断(如
生成代码:点击“Project Manager”设置好工程名、路径、IDE(如Keil MDK或STM32CubeIDE),然后点击“GENERATE CODE”。
3.2 生成的代码分析与关键函数解读
CubeMX生成的代码在main.c中初始化了DMA和USART,并在stm32f4xx_it.c中为我们生成了DMA和USART的中断服务函数框架。我们的主要工作是在用户代码区添加逻辑。
关键HAL库函数:
HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)- 作用:启动UART的DMA接收。
- 参数:
huart串口句柄,pData接收缓冲区指针,Size期望接收的数据量。 - 重要特性:在循环模式下,即使接收满了
Size个数据,DMA也不会停止,而是从头(pData指向的地址)开始继续覆盖接收。所以,我们不能依赖这个函数来判断一帧数据是否接收完成。
HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)- 启动UART的DMA发送。在普通模式下,发送完
Size个数据后停止,并可能触发发送完成中断。
- 启动UART的DMA发送。在普通模式下,发送完
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)- 这是一个宏,用于使能串口的空闲中断。当串口总线上一段时间(约1个字符时间)没有新数据时,就会产生此中断。这是我们检测一帧数据接收完成的关键。
3.3 实现串口DMA不定长接收的完整代码逻辑
思路:DMA循环接收 + 串口空闲中断。
定义缓冲区与变量:
#define RX_BUF_SIZE 256 // 接收缓冲区大小 uint8_t uart1_rx_buf[RX_BUF_SIZE]; // DMA循环接收缓冲区 volatile uint16_t uart1_rx_len = 0; // 接收到的数据长度 volatile uint8_t uart1_rx_flag = 0; // 接收完成标志在
main()函数初始化后,启动DMA接收并开启空闲中断:// 启动DMA循环接收,指向我们定义的缓冲区 HAL_UART_Receive_DMA(&huart1, uart1_rx_buf, RX_BUF_SIZE); // 使能串口1的空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);重写串口中断服务函数(在
stm32f4xx_it.c中找到USART1_IRQHandler):void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ // 判断是否是空闲中断 if((__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清除空闲中断标志(重要!) // 暂时关闭DMA,安全地读取数据 HAL_UART_DMAStop(&huart1); // 计算本次接收到的数据长度 // DMA当前存储器的地址 - 缓冲区起始地址 = 已存数据长度 // 因为DMA是循环的,所以需要处理缓冲区“卷绕”的情况 uint16_t dma_remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 获取DMA剩余未传输数据量 uart1_rx_len = RX_BUF_SIZE - dma_remaining; // 已传输的数据量 // 设置接收完成标志 uart1_rx_flag = 1; // 重新启动DMA接收,准备接收下一帧数据 HAL_UART_Receive_DMA(&huart1, uart1_rx_buf, RX_BUF_SIZE); } /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(&huart1); /* USER CODE BEGIN USART1_IRQn 1 */ /* USER CODE END USART1_IRQn 1 */ }在主循环中处理接收到的数据:
while (1) { if(uart1_rx_flag) { uart1_rx_flag = 0; // 清除标志 // 此时,uart1_rx_buf 中前 uart1_rx_len 个字节是刚刚收到的一帧数据 // 你可以在这里进行数据解析、处理、转发等操作 process_uart_data(uart1_rx_buf, uart1_rx_len); // 注意:处理数据要快,因为DMA已经在后台接收新数据了。 // 如果处理太慢,新数据可能会覆盖尚未处理完的旧数据。 // 对于大数据量或复杂处理,建议使用双缓冲机制。 } // ... 其他任务 }
实操心得:
__HAL_DMA_GET_COUNTER这个宏是计算长度的关键。在停止DMA后立即读取,确保数值准确。清除空闲中断标志UART_FLAG_IDLE的操作,不同系列可能略有不同,F4是读SR寄存器再读DR寄存器,而HAL库的__HAL_UART_CLEAR_IDLEFLAG宏帮我们封装了,直接用最安全。
4. 进阶应用:DMA在ADC、PWM等场景中的实战
4.1 ADC多通道扫描与DMA传输
这是DMA最经典的应用之一,用于连续采集多个传感器的模拟信号。
场景:需要同时采集电池电压(ADC1_IN0)、温度传感器(ADC1_IN1)、光照强度(ADC1_IN2)三个通道的数据,并以1kHz的频率更新。
CubeMX配置要点:
- 在ADC配置中,启用“Scan Conversion Mode”(扫描模式)和“Continuous Conversion Mode”(连续转换模式)。
- 在“Rank”中添加需要转换的通道(IN0, IN1, IN2),并设置采样时间。
- 在ADC的DMA Settings中,添加DMA请求。方向是
Peripheral To Memory,模式为Circular。数据宽度根据ADC分辨率设置(12位ADC用Half Word,对应uint16_t)。 - 内存地址增量
Enable。
代码实现:
#define ADC_CHANNEL_NUM 3 uint16_t adc_values[ADC_CHANNEL_NUM]; // 存放转换结果的数组 // 在初始化后启动ADC的DMA转换 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_values, ADC_CHANNEL_NUM);启动后,ADC会自动按照配置的顺序循环转换IN0, IN1, IN2,并通过DMA将结果依次存入adc_values[0],[1],[2],然后周而复始。你的主程序可以直接读取这个数组来获取最新的传感器数据,完全无需CPU干预转换过程。
避坑技巧:
- 数据对齐:确保
adc_values数组是半字(2字节)对齐的。 - DMA缓冲区大小:如果你配置了ADC的过采样或注入通道,需要计算清楚DMA一次传输的完整数据量。
- 中断使用:可以启用DMA传输完成中断或半传输中断,在中断中处理数据或切换缓冲区,实现更精确的时序控制。
4.2 使用DMA控制PWM输出复杂波形
你想用STM32的定时器输出PWM,但波形不是固定的占空比,而是一个预先计算好的序列(如正弦波、SPWM、自定义波形)。用CPU实时更新比较寄存器(CCR)会消耗大量资源且难以精确定时。此时,DMA+定时器是绝配。
原理:将波形数据表存放在内存数组中。配置定时器为PWM输出模式,并使其在每次更新事件(Update Event)时触发一次DMA请求。DMA则将内存数组中的数据依次搬运到定时器的捕获/比较寄存器(CCR)中。这样就能自动输出整个波形序列。
CubeMX配置(以TIM1_CH1为例):
- 配置TIM1为PWM Generation CH1,设置合适的ARR(周期)和初始Pulse(占空比)。
- 关键:在TIM1的“DMA Settings”中,添加一个DMA请求。
DMA Request:TIM1_CH1(或TIM1_UP,取决于你想在什么事件时更新CCR)。Direction:Memory To Peripheral。Mode:Normal(输出一个完整波形) 或Circular(循环输出波形)。Data Width: 根据CCR寄存器大小选择,通常是Half Word(16位)或Word(32位)。
代码实现:
// 定义一个正弦波表,值为CCR寄存器的值 #define SINE_WAVE_TABLE_SIZE 100 uint16_t sine_wave_table[SINE_WAVE_TABLE_SIZE]; // 填充波形表(示例:生成一个满幅度的正弦波) for(int i=0; i<SINE_WAVE_TABLE_SIZE; i++) { sine_wave_table[i] = (uint16_t)((sin(2 * M_PI * i / SINE_WAVE_TABLE_SIZE) + 1) * (TIM1->ARR / 2)); } // 启动DMA传输,将波形表数据搬运到TIM1的CCR1寄存器 HAL_TIM_PWM_Start_DMA(&htim1, TIM_CHANNEL_1, (uint32_t*)sine_wave_table, SINE_WAVE_TABLE_SIZE);执行后,TIM1_CH1引脚就会自动输出一个完整的正弦波PWM。如果DMA配置为循环模式,则会持续输出。
注意事项:确保DMA的传输节奏(由TIM1的更新频率控制)与你期望的波形输出频率匹配。波形点数(数组大小)和定时器ARR共同决定了输出波形的频率分辨率。
4.3 内存到内存的数据搬运优化
当需要高速复制或处理内存中的数据块时,DMA的存储器到存储器模式比CPU用memcpy要快得多,尤其是在CPU主频不高或者需要同时处理其他任务时。
配置要点:
- 在CubeMX中,添加一个DMA流,
Direction选择Memory To Memory。 - 触发模式选择
Software(软件触发)。 - 源和目标地址增量都
Enable。
代码示例:
// 假设hdma_memtomem是已配置好的存储器到存储器DMA句柄 uint32_t src_array[1000], dst_array[1000]; // ... 填充src_array ... // 配置DMA传输 hdma_memtomem.Init.SrcInc = DMA_SINC_INCREMENT; hdma_memtomem.Init.DstInc = DMA_DINC_INCREMENT; hdma_memtomem.Init.SrcDataWidth = DMA_SRC_DATAWIDTH_WORD; hdma_memtomem.Init.DstDataWidth = DMA_DST_DATAWIDTH_WORD; // ... 其他配置初始化 ... // 启动传输 HAL_DMA_Start(&hdma_memtomem, (uint32_t)src_array, (uint32_t)dst_array, 1000); // 等待传输完成(或者使用中断) HAL_DMA_PollForTransfer(&hdma_memtomem, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY);性能对比:对于大块数据(如几千字节的图像数据),DMA搬运可以节省大量CPU时间。但要注意,DMA搬运本身也需要占用总线带宽,在数据量不大(如几十字节)时,优势不明显,且DMA配置本身有开销。
5. 调试技巧与常见问题排查实录
DMA的调试往往比普通程序更棘手,因为它是“静默”工作的,错误可能不会立即导致程序崩溃,而是表现为数据错乱、丢失或系统偶尔卡顿。
5.1 调试工具与方法
- 逻辑分析仪/示波器:这是最直观的工具。可以观察触发DMA的外设信号(如USART的RX引脚)、DMA传输完成中断信号等,确认时序是否正确。
- Keil MDK/STM32CubeIDE的调试器与实时变量查看:
- 查看DMA寄存器:在调试模式下,打开“Register”窗口,找到DMA相关的寄存器(如
DMAx_SxNDTR当前剩余数据量,DMAx_SxPAR外设地址,DMAx_SxM0AR内存地址)。观察它们在运行时的变化,可以判断DMA是否在工作、是否在搬运数据。 - 查看内存内容:在“Memory”窗口中,输入你的DMA接收缓冲区地址,观察数据是否被正确写入。可以配合串口发送固定数据包来验证。
- 查看DMA寄存器:在调试模式下,打开“Register”窗口,找到DMA相关的寄存器(如
- 断点与中断:在DMA传输完成中断(TC)、半传输中断(HT)或错误中断的回调函数里设置断点,看是否能正常进入。这能帮你判断DMA的配置和中断是否生效。
5.2 常见问题排查清单
| 现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| DMA根本不动,数据不搬运 | 1. DMA时钟未开启。 2. DMA通道/流未使能。 3. 外设未发出DMA请求。 4. 存储器地址或外设地址配置错误。 | 1. 检查__HAL_RCC_DMAx_CLK_ENABLE()是否被调用。2. 检查 hdma.Init结构体配置,特别是Direction,Mode,并确认HAL_DMA_Init成功。3. 确认外设已正确初始化并处于可产生请求的状态(如UART已使能接收)。 4. 在调试器里查看 DMAx_SxPAR和DMAx_SxM0AR寄存器,确认地址值是否正确指向了外设DR寄存器和内存缓冲区。 |
| 数据搬运错位或乱码 | 1. 数据宽度(Data Width)配置错误。 2. 地址增量(Increment)配置错误。 3. 内存缓冲区对齐问题。 4. 对于STM32H7,D-Cache一致性问题。 | 1. 核对源和目标的数据宽度是否与外设寄存器及变量类型匹配(字节/半字/字)。 2. 检查 SrcInc和DstInc。外设地址通常不增量,内存地址通常增量。3. 确保缓冲区地址按数据宽度对齐。使用对齐属性定义数组。 4. 在DMA写入和CPU读取之间,对缓冲区调用 SCB_CleanInvalidateDCache_by_Addr。 |
| 只能接收/发送一次数据 | DMA模式配置为Normal,且完成后未重新启动。 | 对于需要连续传输的场景,将模式改为Circular。或者在Normal模式传输完成中断中,重新配置数据长度并启动下一次传输。 |
| 不定长接收时,帧长度计算错误 | 1. 计算长度时DMA仍在运行,计数器不稳定。 2. 未处理缓冲区“卷绕”(循环模式下,DMA写指针回到起点)。 3. 空闲中断未正确清除。 | 1. 在计算长度前,先HAL_UART_DMAStop暂停DMA。2. 使用公式: 已接收长度 = 缓冲区总大小 - __HAL_DMA_GET_COUNTER()。这个公式在循环模式下自动处理了卷绕。3. 确保在空闲中断服务函数中正确清除了空闲标志。 |
| 使用DMA发送时,最后一两个字节发不出去 | DMA传输完成中断触发过早,此时最后一个数据可能还未从DMA FIFO完全移动到外设。 | 在DMA发送完成中断回调函数中,不要立即关闭串口或进行其他操作。可以等待一下串口发送完成标志TC(Transmission Complete)置位,或者简单延时几个微秒。HAL库的HAL_UART_TxCpltCallback被调用时,数据通常已进入发送移位寄存器,但更稳妥的做法是再检查USART_SR的TC位。 |
| 系统偶尔卡死或无响应 | 1. DMA中断优先级设置不当,导致中断嵌套或响应不及时。 2. DMA与CPU或其他DMA通道访问同一内存区域或外设,产生总线冲突。 | 1. 在CubeMX的NVIC配置中,合理设置DMA中断的抢占优先级和子优先级。对于实时性要求高的DMA,优先级应设高。 2. 分析系统总线架构,避免资源竞争。例如,DMA1和CPU同时访问SRAM,如果频繁发生,可能需优化数据布局或降低DMA带宽。 |
5.3 一个真实的踩坑案例:STM32H743的D-Cache问题
我在一个基于STM32H743的项目中使用DMA从ADC搬运数据到内存。代码在F4上运行完美,但在H7上,CPU读到的ADC数据全是零或旧数据。
排查过程:
- 用调试器查看DMA寄存器,确认NDTR在递减,PAR/MAR地址正确,说明DMA在正常工作。
- 查看目标内存地址(
adc_values数组),发现其值始终不变。 - 意识到H7有独立的D-Cache。CPU读取
adc_values时,实际上是从Cache中读取,而DMA是直接写入物理内存(SRAM),绕过了Cache,导致Cache和内存数据不一致。 - 在DMA启动前,对
adc_values数组对应的内存区域执行**缓存清理(Clean)操作(如果CPU修改过该区域,需要写回内存)。在DMA传输完成后、CPU读取前,执行缓存无效化(Invalidate)**操作,让CPU下次读取时从物理内存重新加载数据。
解决方案:
// 定义缓冲区时强制对齐并指定段(可选,但建议) uint16_t adc_values[ADC_CHANNEL_NUM] __attribute__((section(".RAM_D2"))); // 放到不被Cache的内存区是另一种方案 // 在DMA传输完成中断回调函数中 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 使指定内存区域的Cache无效,强制CPU从物理内存读取最新数据 SCB_InvalidateDCache_by_Addr((uint32_t*)adc_values, sizeof(adc_values)); // 此时再使用adc_values中的数据 process_adc_data(adc_values); }这个坑让我深刻体会到,在性能更强大的MCU上,缓存一致性是必须考虑的问题。
6. 性能优化与高级话题探讨
6.1 DMA与CPU的带宽平衡
DMA虽然解放了CPU,但它和CPU共享系统总线(如AHB总线)。当两者同时高频率访问同一块内存或外设时,会产生总线竞争,可能互相拖慢速度。
优化策略:
- 使用多块SRAM:像STM32F4/H7有多块SRAM(如CCM RAM, SRAM1, SRAM2)。可以将DMA的缓冲区放在一块RAM(如SRAM2),而CPU频繁操作的数据放在另一块(如CCM RAM),从物理上减少冲突。
- 优化DMA传输粒度:对于大量数据,尽量让DMA一次传输较大的数据块,而不是频繁发起多次小数据量传输。因为每次DMA传输都有初始化的开销。
- 合理设置DMA优先级:在CubeMX中,可以设置DMA流的优先级(Very High, High, Medium, Low)。对于实时性要求极高的数据流(如音频I2S),应设为最高优先级。
6.2 双缓冲与环形缓冲区
这是处理连续数据流的两种高级数据结构,常与DMA循环模式结合使用。
- 双缓冲(Double Buffer):如前所述,使用两个大小相等的缓冲区。DMA通过半传输完成(HT)和传输完成(TC)中断,在A/B缓冲区之间切换。CPU始终处理DMA未在写入的那个缓冲区。实现了近乎零延迟的数据交换。
- 环形缓冲区(Ring Buffer/Circular Buffer):这是一个逻辑上的“环”。有一个写指针(由DMA更新)和一个读指针(由CPU控制)。CPU可以随时从读指针开始读取数据,只要追不上写指针即可。这种方式更灵活,缓冲区利用率高,但需要自己管理指针和判断缓冲区空/满状态。DMA循环模式本质上就是在向一个物理上的线性数组进行环形写入。
选择建议:如果数据是固定大小的“帧”,双缓冲简单高效。如果数据是持续不断的“流”,且处理速度不稳定,环形缓冲区更合适。
6.3 不同STM32系列的DMA特性差异
- STM32F0/F1:DMA功能基础,通道与外设固定绑定,模式相对简单。学习成本低,适合入门理解概念。
- STM32F4/F7:引入流(Stream)和通道(Channel)概念,灵活性大增。支持FIFO,可以缓冲数据、打包传输,提升效率。是应用最广泛的系列。
- STM32H7:在F7基础上进一步增强,DMA控制器(称为DMA2D,用于图形处理,和通用的BDMA/DMA)功能更强,时钟更高。但引入了Cache一致性、TCM(紧耦合内存)等新概念,调试复杂度上升。
- STM32G0/G4:在保持易用性的同时,提供了不错的DMA性能,性价比高。
核心建议:开始一个新项目时,花半小时仔细阅读参考手册中关于DMA的章节,特别是“DMA请求映射表”和“流/通道配置寄存器”的描述,这能帮你避开很多配置上的坑。
DMA不是魔法,它是一把精密的瑞士军刀。理解其原理,掌握其配置,善用其中断,你就能让STM32的效能提升一个档次。从串口收发到ADC采集,从PWM波形生成到内存大数据搬运,DMA的身影无处不在。希望这篇结合了大量实战和踩坑经验的笔记,能帮你把这把刀磨得更亮,用得更顺手。