
1. 从“CPU搬运工”到“性能解放者”DMA到底是什么如果你玩过STM32肯定遇到过这样的场景串口接收数据时CPU得一个字节一个字节地从串口数据寄存器搬到内存数组里ADC采集时CPU又得守在ADC数据寄存器旁边一有结果就赶紧读走。这种“CPU当搬运工”的模式在低速、小数据量时还行一旦数据流变大或者频率变高CPU就彻底被这些琐碎的搬运任务绑死了啥正经事都干不了系统效率直线下降。这时候就该DMADirect Memory Access直接存储器访问登场了。简单来说DMA就是一个硬件“数据搬运专员”。它独立于CPU可以在存储器和外设之间或者存储器与存储器之间直接搬运数据。整个过程不需要CPU干预CPU只需要在搬运开始前配置好DMA告诉它从哪搬、搬到哪、搬多少搬运完成后DMA发个中断通知一下CPU即可。在此期间CPU可以腾出手来执行其他更复杂的算法、业务逻辑或者干脆进入低功耗模式睡觉。对于STM32这种资源有限的微控制器用好DMA是提升系统实时性、降低CPU负载、实现高效数据流处理的关键。我最初接触DMA时觉得它配置寄存器一堆概念又抽象远不如调个GPIO点灯来得直观。但真正在项目里用上之后才发现它是性能优化的“大杀器”。比如做一个音频播放器需要从SD卡读取PCM数据源源不断地送到I2S接口没有DMA的话CPU光搬数据就卡死了更别提解码和用户交互。又或者做电机控制需要高速、精确的ADC采样电流电压DMA能确保采样点均匀不受其他中断干扰这是实现高性能FOC算法的基石。所以理解并掌握DMA是从STM32新手迈向进阶开发者的重要一步。2. STM32 DMA架构核心通道、流与仲裁机制STM32的DMA控制器设计得相当灵活但也因此带来了初学时的复杂性。不同系列的STM32如F1, F4, H7其DMA架构略有不同我们以最常见的STM32F4系列及类似架构的F7/H7为例来拆解它的设计理念代表了更现代、更强大的DMA思想。2.1 核心概念流Stream与通道Channel这是STM32 DMA中最容易混淆的一对概念。你可以把DMA控制器想象成一个物流中心。流Stream就是这个物流中心里的一条独立的传输流水线。STM32F4通常有2个DMA控制器DMA1和DMA2每个控制器有8条流Stream 0~7。每条流都是完全独立的可以同时进行不同的数据传输任务。比如Stream0可以忙着从ADC搬数据到内存Stream1同时在从内存搬数据到串口。通道Channel这条流水线可以为哪个“客户”外设服务的选择器。每条流都配有一个多路选择器可以连接到多个外设请求源这个选择器就是通道。例如DMA2的Stream0其通道0可能对应ADC1通道4可能对应SPI3_RX。你需要根据数据源或目的地外设为流分配合适的通道。关键理解你配置的不是“ADC的DMA”而是“选择一条流如DMA2_Stream0并将其通道设置为ADC1对应的通道然后让这条流为ADC1服务”。流是执行者通道决定了它听命于哪个外设。2.2 仲裁器当多条流“抢车道”时既然有这么多条流如果它们同时想访问同一个目的地比如内存岂不是要“撞车”这就是仲裁器Arbitter的作用。每个DMA控制器都有一个仲裁器用来管理其下属8条流对总线访问的优先级。优先级分为两个层面软件优先级你在配置流时设置的优先级Very High, High, Medium, Low。当多条流同时发出请求时优先级高的先被服务。硬件优先级如果软件优先级相同则流编号小的拥有更高的硬件优先级Stream0 Stream1 ... Stream7。注意这里的优先级解决的是“访问总线”的冲突而不是“响应外设请求”的速度。外设一旦发出DMA请求比如ADC转换完成对应的流只要没有被更高优先级的流完全霸占总线就会尽快响应。2.3 传输方向与数据宽度这是配置时最容易出错的地方之一需要仔细匹配。传输方向决定了数据从哪里搬到哪里。主要有三种模式外设到存储器Peripheral-to-memory最常见如ADC采集数据到数组、串口接收数据到缓冲区。存储器到外设Memory-to-peripheral也很常见如从数组发送串口数据、从内存送数据到DAC输出。存储器到存储器Memory-to-memoryF4及之后的系列支持可以在两个内存区域间搬运数据比如快速初始化数组、复制数据缓冲区。注意此模式需要占用两条流一条读一条写不对这里是个常见误区。实际上存储器到存储器模式通常需要手动开启流的传输而不是由外设触发并且只能使用DMA2的部分流具体需查数据手册。数据宽度Data Width需要分别设置源Source和目的Destination的数据宽度Byte, HalfWord, Word。核心原则必须与外设数据寄存器的宽度以及你定义的内存缓冲区变量类型匹配。匹配错误示例ADC是12位分辨率结果放在一个16位寄存器里通常左对齐或右对齐。如果你将DMA的数据宽度设置为8位Byte那么一次传输只能拿到寄存器的低8位数据就错乱了。正确的做法是设置为16位HalfWord。自动计算当数据宽度设置为Word4字节时DMA的地址指针无论是外设地址还是内存地址会自动按4字节递增。如果你定义的是一个uint32_t数组那就非常合适。2.4 循环模式与双缓冲区这是实现连续、无间断数据传输的高级技巧。循环模式Circular Mode这是DMA的“灵魂模式”之一。使能后当一次设定的数据量传输次数完成后DMA会自动将地址和计数器重置为初始值然后从头开始新一轮传输周而复始永不停止。应用场景ADC连续采集、I2S音频流播放/录制、串口持续通信。你只需要分配一个固定大小的缓冲区BufferDMA就会像“旋转木马”一样循环地向里面填充或从里面取出数据。CPU只需要在缓冲区半满或全满时通过中断去处理数据即可实现了生产者和消费者的解耦。双缓冲区模式Double Buffer Mode这是循环模式的“增强版”。DMA会维护两个大小相同的缓冲区Buffer0和Buffer1。当DMA正在向Buffer0写数据时CPU可以安全地读取处理Buffer1中的数据当Buffer0写满DMA会自动切换到Buffer1进行写入同时CPU可以去处理Buffer0。如此交替彻底避免了CPU处理数据速度跟不上DMA写入速度而导致的缓冲区覆盖Overrun问题。实现方式在HAL库中通常通过配置DMA为循环模式并在中断如半传输完成HT、传输完成TC中切换CPU当前操作的缓冲区指针来实现双缓冲逻辑。有些系列如STM32H7的DMA硬件直接支持双缓冲区地址的自动切换配置更简单。3. HAL库实战配置DMA驱动ADC实现精确采样光说不练假把式我们用一个最经典的案例——DMA配合ADC实现多通道连续扫描采样来把上面的概念串起来。目标是用DMA循环模式自动将ADC1的3个通道CH1, CH2, CH3的转换结果搬运到一个内存数组中CPU几乎零开销。3.1 环境准备与CubeMX配置假设我们使用STM32F407开发环境是STM32CubeIDE。引脚配置在CubeMX的Pinout视图使能ADC1并将通道1、2、3分配到具体的GPIO引脚如PA1, PA2, PA3。ADC配置Mode选择“Independent mode”。Scan Conversion ModeEnable。这是多通道采样的关键使能后ADC会按序列自动扫描所有使能的通道。Continuous Conversion ModeEnable。使能连续转换模式ADC在一次扫描结束后立即开始下一次扫描实现不间断采样。DMA Continuous RequestsEnable。这个选项非常重要它意味着在一次DMA传输结束后DMA请求会持续保持有效ADC会持续触发DMA从而实现真正的后台连续搬运。如果禁用DMA传输完设定的次数后就会停止需要手动重启。End Of Conversion Selection选择“EOC after each conversion”或“EOC after sequence”根据中断需求定这里DMA搬运可以选后者。Rank在“ADC1 ADC_Regular_ConversionMode”下添加Rank1,2,3分别选择Channel1,2,3设置采样时间如15 Cycles。DMA配置在“DMA Settings”标签页点击Add选择“ADC1”。Stream选择一条可用的流比如DMA2 Stream0ADC1通常对应DMA2。不同外设的DMA请求映射到固定的流和通道需查阅数据手册的“DMA请求映射表”。CubeMX通常会帮你过滤出可用的选项。ChannelCubeMX会根据你选的Stream自动列出可用通道选择对应的那个如Channel 0。DirectionPeripheral To Memory。Priority根据系统实时性要求设置这里设为High。ModeCircular循环模式。Increment AddressPeripheralDisable。ADC的数据寄存器地址是固定的ADC1-DR不能递增。MemoryEnable。我们需要把数据依次存放到数组的不同位置。Data WidthPeripheralHalf Word。因为ADC数据寄存器DR是16位的对于12位ADC结果。MemoryHalf Word。因为我们打算用uint16_t类型的数组来存放数据。Memory Burst / Peripheral Burst通常禁用单次传输。3.2 代码实现与解析生成代码后我们来看关键部分。// 在全局变量区定义缓冲区 #define ADC_BUFF_SIZE 1024 // 缓冲区大小必须是通道数的整数倍 uint16_t adc_dma_buffer[ADC_BUFF_SIZE]; // DMA搬运的目的地 // 在main函数初始化部分之后启动ADC和DMA HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFF_SIZE);这一行HAL_ADC_Start_DMA是核心。它做了三件事启动ADC的DMA请求功能。配置DMA源地址是ADC1-DR目的地址是adc_dma_buffer传输数据量是ADC_BUFF_SIZE个半字Half-Word。启动DMA流使其处于等待ADC触发请求的状态。由于ADC我们配置了连续转换和连续DMA请求且DMA是循环模式因此一旦启动ADC就会以最快的速度受采样时间和转换时间限制循环扫描3个通道并将结果通过DMA自动、循环地填入adc_dma_buffer数组中。数据在缓冲区中的排列顺序这是一个关键点。由于我们使能了扫描模式3个通道DMA的每次请求对应ADC的一次转换完成。因此缓冲区中的数据排列将是严格的[CH1, CH2, CH3, CH1, CH2, CH3, ...]。如果你需要同时处理三个通道的数据可以这样读取// 假设我们想获取最新一轮的采样值 uint16_t latest_ch1_val adc_dma_buffer[adc_index]; // 当前DMA写入位置的前第2个位置不我们需要计算。 // 更稳健的方法是利用DMA当前剩余传输次数CNDTR寄存器计算出当前写入位置。 // HAL库提供了一个更安全的方式使用传输完成中断TC或半传输完成中断HT。 // 更好的实践使用DMA传输完成中断 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 当DMA传输完整个缓冲区ADC_BUFF_SIZE个数据时进入此回调 // 此时缓冲区充满了规整的数据可以安全地进行批量处理 process_adc_data(adc_dma_buffer, ADC_BUFF_SIZE); }重要提示在循环模式下直接像访问普通数组一样访问adc_dma_buffer是危险的因为你读取的时候DMA可能正在写入导致读到“半新半旧”的数据。正确的做法是使用双缓冲区索引或利用DMA传输完成/半传输完成中断来划定安全的数据处理区域。例如使能DMA的“半传输完成中断”和“传输完成中断”在HAL_ADC_ConvHalfCpltCallback中处理前半缓冲区在HAL_ADC_ConvCpltCallback中处理后半缓冲区这是经典的“乒乓缓冲”策略能保证CPU处理的数据一定是DMA已经完整写完的一个连续块。3.3 避坑指南时钟、对齐与中断冲突时钟使能DMA控制器DMA1/DMA2的时钟默认是不开的。CubeMX生成的代码通常在HAL_ADC_MspInit里会调用__HAL_RCC_DMA2_CLK_ENABLE()。务必检查如果自己手写初始化千万别忘了开DMA时钟。数据对齐ADC结果在数据寄存器中可能有左对齐或右对齐。我们配置DMA为半字传输取的是完整的16位寄存器值。在软件处理时可能需要根据数据手册的说明进行移位和掩码操作来获取实际的12位转换值real_value adc_raw_data 0xFFF;。中断优先级如果系统中有多个中断如定时器中断、串口中断需要合理配置NVIC优先级。DMA传输完成中断的优先级通常不需要设得很高因为它只是通知你数据准备好了晚几微秒处理通常没问题。但要小心别让一个高优先级的中断处理函数执行时间过长阻塞了DMA中断导致缓冲区溢出。缓冲区大小与内存确保DMA缓冲区放在合适的内存区域。对于高速、大量的数据传输可以考虑将缓冲区放到CCM RAM如果芯片有或者使用__attribute__((section(.dma_buffer)))将其分配到特定的RAM段以避免与CPU访问普通RAM产生总线冲突提升性能。4. 进阶应用DMA驱动串口实现高效收发串口是调试和通信的命脉其DMA应用同样广泛且极具价值。目标是实现串口接收不定长数据以及发送大量数据时解放CPU。4.1 串口接收不定长数据IDLE中断 DMA串口经典难题如何知道一帧数据什么时候结束对于定长帧可以用DMA定长接收但对于不定长帧如Modbus以静默时间判断帧结束就需要用到串口的IDLE空闲中断。原理配置DMA在循环模式下接收串口数据到缓冲区。同时使能串口的IDLE中断。当串口接收到一字节数据DMA就将其搬到缓冲区并移动指针。如果串口线上持续一段时间具体时间取决于波特率通常是1个字节的传输时间没有新数据硬件就会产生IDLE中断。在IDLE中断服务函数中我们就可以知道从上次处理完数据到现在DMA又接收了一段新数据这段数据的长度 当前DMA缓冲区总大小 - DMA当前剩余未传输数据量__HAL_DMA_GET_COUNTER。CubeMX配置关键串口配置使能UART全局中断和DMA接收请求。DMA配置接收Direction: Peripheral To MemoryMode: Circular (循环模式保证永不溢出)Peripheral Inc: DisableMemory Inc: EnableData Width: Byte (串口数据是8位的)在代码中额外使能串口IDLE中断__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);代码逻辑片段#define UART_RX_BUF_SIZE 256 uint8_t uart_rx_dma_buffer[UART_RX_BUF_SIZE]; // 启动串口DMA接收 HAL_UART_Receive_DMA(huart1, uart_rx_dma_buffer, UART_RX_BUF_SIZE); // 在stm32f4xx_it.c的USART1_IRQHandler中或自定义的UART中断回调里 void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清除IDLE标志位非常重要 // 计算接收到的数据长度 uint16_t rx_len UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if(rx_len 0) { // 处理 uart_rx_dma_buffer 中前 rx_len 个字节的数据 process_uart_data(uart_rx_dma_buffer, rx_len); } // 注意由于DMA是循环模式缓冲区会被持续覆盖。 // 如果处理速度慢于接收速度需要更大的缓冲区或使用双缓冲策略。 } HAL_UART_IRQHandler(huart1); }4.2 串口DMA发送与__HAL_LOCK机制串口发送使用DMA同样简单但有一个HAL库的“坑”需要特别注意。uint8_t tx_data[] Hello, World via DMA!\r\n; HAL_UART_Transmit_DMA(huart1, tx_data, sizeof(tx_data) - 1);这行代码会将数据通过DMA发送出去函数立即返回CPU无需等待。但是如果你在DMA发送完成前再次调用HAL_UART_Transmit_DMA发送第二包数据会导致错误。因为HAL库内部有一个状态锁__HAL_LOCK用来保证同一时间只有一个发送操作。正确做法等待上一次DMA发送完成后再发起下一次发送。有两种方式阻塞等待使用while(__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET);等待发送完成标志。但这会浪费CPU时间。中断回调推荐在DMA发送完成中断回调函数中设置一个标志位主循环检测到这个标志位后再启动下一次发送。这样CPU利用率最高。volatile uint8_t uart_tx_done 1; // 初始为1表示空闲 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart-Instance USART1) { uart_tx_done 1; // 发送完成释放标志 } } // 在主循环或某个任务中 if(new_data_ready uart_tx_done) { uart_tx_done 0; HAL_UART_Transmit_DMA(huart1, new_tx_buffer, new_tx_len); }5. 存储器到存储器模式加速数据搬运的利器前面用的都是外设和存储器之间的DMASTM32F4及之后的系列还支持存储器到存储器M2M模式。这个模式非常实用比如快速初始化大片内存、复制图像缓冲区、计算前搬移数据等。配置特点触发源M2M模式没有外设硬件触发。一旦启动DMA会立即开始传输传输速率由DMA的带宽决定。流限制并非所有流都支持M2M模式需要查数据手册。通常DMA2的某些流支持。配置示例以内存复制为例// 假设我们要将数组src[1000]复制到数组dst[1000] uint32_t src[1000], dst[1000]; // ... 填充src ... // 配置DMA这里以HAL库风格描述关键参数 hdma_m2m.Init.Channel DMA_CHANNEL_0; // M2M模式通常用特定通道也可能是MEMORY 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; // 普通模式传输一次就停止 hdma_m2m.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_m2m); HAL_DMA_Start(hdma_m2m, (uint32_t)src, (uint32_t)dst, 1000); // 等待传输完成 HAL_DMA_PollForTransfer(hdma_m2m, HAL_DMA_FULL_TRANSFER, HAL_MAX_DELAY);性能对比用CPU的memcpy函数复制1KB数据可能需要上千个时钟周期而DMA M2M模式在后台进行仅占用少量总线时间CPU可以并行做其他事整体系统吞吐量更高。对于大块数据搬运优势非常明显。6. 调试技巧与常见问题排查DMA的调试不像GPIO点灯那样直观数据在后台“悄悄”流动。出了问题往往现象是数据不对、中断不触发、程序卡死。掌握以下调试手段至关重要。6.1 调试手段逻辑分析仪/示波器这是最直接的硬件工具。可以抓取外设如UART的TX/RX引脚、SPI的CLK/MOSI的波形确认数据是否真的在按预期收发。如果波形正确但内存数据不对问题很可能出在DMA配置或内存访问上。内存观察窗口在IDE如Keil, IAR, CubeIDE的调试模式下实时观察DMA目标缓冲区的内存内容。你可以看到数据是否被写入、写入的顺序和值是否正确。在DMA传输过程中设置内存访问断点要小心可能会影响DMA操作。寄存器查看重点查看以下寄存器DMA_SxCR流的配置寄存器确认通道、方向、优先级、循环模式等是否配置正确。DMA_SxNDTR当前剩余待传输数据量。这是动态变化的在调试时暂停CPU查看这个值可以知道DMA传输的进度。DMA_SxPAR / DMA_SxM0AR外设地址和内存地址寄存器确认地址是否正确。外设状态寄存器如ADC的SR寄存器查看EOC标志、UART的SR/ISR寄存器查看TC、RXNE、IDLE标志。中断调试在DMA传输完成中断TC、半传输中断HT或外设相关中断如UART IDLE的服务函数入口打一个断点或者翻转一个GPIO引脚用示波器测量其电平变化可以直观判断中断是否被触发以及触发的频率。6.2 常见问题与解决方案数据错位或乱码检查数据宽度Data Width这是最常见的原因。确保外设数据寄存器宽度、DMA配置的数据宽度、内存缓冲区变量类型三者完全匹配。例如UART是8位就设为ByteADC 12位结果放在16位寄存器就设为HalfWord。检查地址递增Increment Address源和目的地址的递增设置是否正确。外设寄存器地址固定通常不递增Disable内存地址需要递增Enable。检查缓冲区对齐确保内存缓冲区地址没有对齐问题。例如配置为Word传输时最好保证缓冲区地址是4字节对齐的。使用__attribute__((aligned(4)))来修饰缓冲区数组。DMA传输不启动或只传输一次检查触发源对于外设触发模式确认外设是否已正确使能并产生了DMA请求。例如ADC是否开始了转换UART是否进入了接收状态检查DMA_CxCR.EN位确保DMA流已使能。HAL_DMA_Start或HAL_XXX_Start_DMA函数会设置此位。检查循环模式如果希望持续传输是否配置了Circular模式如果是Normal模式传输完设定次数后就会自动停止。检查DMA Continuous Requests对于ADC等如果希望ADC连续转换并触发DMA这个标志必须使能。中断不触发检查NVIC配置DMA流的中断、外设的中断如UART IDLE是否在NVIC中使能检查中断标志清除在中断服务函数中是否清除了相应的中断标志位例如UART的IDLE标志需要手动清除__HAL_UART_CLEAR_IDLEFLAG而DMA的传输完成标志通常在HAL库的中断处理中自动清除。优先级冲突是否被更高优先级的中断长时间阻塞系统卡死或数据损坏内存冲突DMA正在写入的内存区域是否同时被CPU访问如果CPU比如中断函数和DMA同时读写同一块内存且没有保护机制会导致数据竞争可能读出错误数据甚至导致总线锁死。解决方案使用双缓冲区或者确保CPU只在DMA传输完成中断的安全期内访问“旧”缓冲区。总线仲裁如果DMA以最高优先级疯狂搬运数据可能会长时间占用总线导致CPU取指或访问其他外设时“饿死”。这时需要合理设置DMA的优先级或者使用带AXI总线和多端口RAM的高端型号如STM32H7来缓解瓶颈。Cache一致性STM32H7等带Cache的系列这是终极深坑。如果DMA的目的地是CPU带Cache的内存区域如DTCMDMA直接写入物理RAM后Cache里的数据还是旧的CPU读到的就是旧数据。反之如果CPU写了Cache但没刷回内存DMA读到的也是旧数据。必须手动维护Cache一致性在DMA传输前如果内存是DMA要写入的应使用SCB_InvalidateDCache_by_Addr无效化Cache如果内存是DMA要读取的应使用SCB_CleanDCache_by_Addr清理Cache。很多H7用户遇到的“DMA延时很久才生效”的问题根源就在于此。