资讯详情

STM32 DMA从原理到实战:解放CPU的数据搬运指南

📅 2026/10/14 19:54:49 | 华诺云谱 👁 阅读
STM32 DMA从原理到实战:解放CPU的数据搬运指南
1. DMA到底解决了什么问题先把结论放在前面DMA就是让数据搬运这件事不再占用CPU的资源。嵌入式开发里CPU的时间是最值钱的而大量场景中CPU偏偏消耗在纯粹的数据拷贝上——比如从ADC数据寄存器读一个16位结果、把串口收到的字节搬到缓冲区、把一块图像数据从一个数组拷到另一个数组。这些操作本身没有技术含量却要CPU一条指令一条指令地执行。DMADirect Memory Access直接存储器访问出现之后这些搬运工作就交给了一个独立于CPU的总线控制器CPU只需要在开始的时候告诉它“从哪搬、搬到哪、搬多少”然后就可以去处理别的事搬完了DMA会通过中断告诉CPU“活干完了”。我最初接触DMA是在学习STM32F103系列的时候。那会儿刚会点灯、会用串口发送字符串觉得CPU什么事都能干不太理解为什么还要专门搞一个偷懒的硬件来帮忙搬数据。后来做一个数据采集的小项目——ADC以10kHz采样每采完一次产生一次中断中断里我要把12位的数据左对齐之后拷贝到环形缓冲区再判断是否存到SD卡。算下来CPU有将近三分之一的时间都在做拷贝和等待。这个时候才真正意识到DMA不是为了省代码是为了省时间尤其在高频率、大批量、连续性的数据通路里DMA几乎是刚需。这篇文章的教学定位是给那些已经跑通STM32基础外设GPIO、串口、定时器、中断但对DMA只闻其名、没用过的人。如果你已经会配置串口和ADC那DMA其实只是多加了三五行代码的事。不要把它想成一个多高深的外设它本质上就是“一个能自己搬砖的搬运工”搬运工归你管但干活的时候不用你搭手。2. 核心概念拆解DMA控制器到底是怎么工作的2.1 通道、请求和仲裁谁在指挥搬运工STM32F103的DMA控制器有1个包含7个通道DMA1的Channel1到Channel7每个通道对应一组外设请求映射。比如Channel1可能同时挂串口TX和ADC1Channel5挂串口RX和定时器更新等。映射关系在参考手册的DMA请求表里有明确说明。这里必须提醒第一次用的人每个通道可以服务多个外设但同一时刻只能为一个外设工作外设的DMA请求是通过寄存器配置的不是自动分配的。DMA工作的时序大概是这样外设产生一个请求信号比如串口收到一个字节RXNE置位这个请求被送到DMA控制器DMA控制器经过仲裁之后如果对应通道的优先级最高、且使能了那就发起一次总线传输把外设数据寄存器的值搬到你在内存里定义的缓冲区。搬完一次计数器CNDTR减1目标地址根据配置决定是自增还是固定。当CNDTR减到0就产生传输完成中断如果你开了的话。仲裁机制很容易被忽略。多个通道可能同时请求DMA谁先谁后取决于两件事硬件优先级和软件优先级。硬件优先级就是通道编号小的优先软件优先级是你在DMA_CCR寄存器里配的优先级。两者都起作用软件优先级相同才比较硬件优先级。我在实际项目中遇到过通道之间互相抢占导致数据延迟的情况后面会专门讲这个坑。2.2 三个关键参数方向、地址、数量配置DMA的时候有三个参数决定了一次搬运的本质传输方向、地址增量、传输数据量。传输方向只有两两组合从内存到内存M2M、从外设到内存P2M、从内存到外设M2P。F103的DMA寄存器里有一个MINC和PINC位分别控制内存地址和外设地址是否自增。自增的意思很简单——搬运完一个数据地址自动加上数据宽度对应的字节数。比如你从外设数据寄存器往一个数组搬外设地址每次固定PINC0内存地址每次自增MINC1搬完8次数组里就是8个连续数据。传输数据量就是CNDTR寄存器的值范围是0到65535。这个值在传输过程中会递减所以读它就能知道还剩多少没搬。传输结束时CNDTR归零。很多人踩过一个坑DMA一旦传输完成如果没有重装CNDTR通道是不会自动开始第二次传输的。要重新开始必须先把通道关闭EN位清0重新写入CNDTR再使能。后面实验部分会演示这个标准重启流程。数据宽度也要保持匹配。外设寄存器、内存缓冲区、DMA传输宽度三者的关系很容易配错。ADC的12位结果存在16位的数据寄存器里你如果用字节宽度8位去搬运高字节就丢了。反过来你把串口收到的8位数据用16位宽度搬运到内存数据就错乱了。基本原则是外设的寄存器宽度是多少DMA的数据宽度就配多少内存缓冲区宽度和DMA保持一致。2.3 普通模式与循环模式的差别DMA有两种运行模式普通模式Normal和循环模式Circular。普通模式下CNDTR减到0之后传输停止通道自动关闭需要软件重新配置才能再来。循环模式下CNDTR减到0之后自动重装初始值传输永不停止地址也自动回到初始位置非常适合连续采样的场景。这两个模式的选择直接影响代码逻辑。做ADC连续采样我建议用循环模式配合传输过半中断和传输完成中断双缓冲处理数据做串口接收不定长数据普通模式配合空闲中断IDLE是更常见的方案。循环模式听起来省事但要注意一个隐患如果CPU处理数据的速度跟不上DMA搬运的速度缓冲区还没来得及读就被覆盖了。这也是开发DMA程序时最典型的风险点。3. 动手实验一内存到内存搬运建立DMA的基本直觉3.1 为什么先做这个实验内存到内存M2M是所有DMA实验里最简单、最容易观测的。因为不涉及外设时序不需要配置触发源只要在代码里启动DMA传输就能看到数据从源数组“自己到了”目标数组。这个实验帮我建立了对DMA控制流程的直觉配置、使能、等待标志位、完成后处理。而且调试完全不需要示波器逻辑分析仪一个串口就能验证结果。F103的DMA配置为M2M模式时有一个特殊之处DMA_CCR寄存器的EN位置1之后传输会立刻开始不需要等待外设请求。把MEM2MEM位置1就是告诉控制器“你自己发起搬运别等外设信号”。这种方式最适合用来理解CNDTR、地址增量和传输完成中断的工作机制。3.2 完整配置与代码实现用标准外设库来写个人觉得比HAL库更容易理解DMA的本质。HAL库封得太严你想看寄存器状态反而要多绕几层。下面是M2M传输的初始化代码void DMA_M2M_Init(void) { RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)src_buf; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)dst_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 外设作为源端 DMA_InitStructure.DMA_BufferSize 64; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Enable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Word; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Word; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Enable; DMA_Init(DMA1_Channel1, DMA_InitStructure); // 使能传输完成中断 DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); // 配置NVIC NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel DMA1_Channel1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }这里有一个容易混淆的点DMA_DIR这个位从外设寄存器名字上看是“DIR”但它代表的不是数据的绝对方向而是“外设端是源还是目标”。上面配置DMA_DIR_PeripheralSRC含义是外设端作为数据传输的源Peripheral as SRC也就是把src_buf当作源地址往dst_buf搬。这块我刚开始反了好几次不敢说英文注释一定比中文好但库里那个宏的名字确实更具误导性。启动传输的代码void DMA_M2M_Start(void) { DMA_SetCurrDataCounter(DMA1_Channel1, 64); // 重装CNDTR DMA_Cmd(DMA1_Channel1, ENABLE); // 使能通道立刻开始传输 } void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); transfer_done_flag 1; } }注意DMA_SetCurrDataCounter只在通道关闭时有效传输进行中写CNDTR是不生效的。第一次写代码如果忘了关闭通道就重装计数器会发现无论怎么重新启动数据量还是上一次的值传完就停。这个细节非常值得记下来。3.3 实验结果与结论代码下载到板子之后主循环里调用DMA_M2M_Start()然后轮询transfer_done_flag。等标志位置1用串口打印dst_buf里的内容和src_buf比对完全一致。我为了看得更直观在源数组里填的是0到63的递增数目标数组初始化为0传输完成后打印目标数组就能看到64个数整整齐齐地搬过去了。这个实验验证了三件事一是DMA能在没有CPU参与的情况下完成内存拷贝二是CNDTR确实在递减传输完成后归零三是DMA传输完成中断能正确触发标志位和寄存器判断都同步更新。有了这个基础后面做ADC和串口的DMA实验就不再是黑盒操作了因为你知道DMA搬运的核心流程就是“设置源地址目标地址数量使能等完成”。4. 动手实验二ADC连续采样配合循环模式4.1 为什么ADC必须配DMA很多初学者学ADC的时候都是用一个定时器触发一次转换进中断读一次数据。低速场景没问题但到了高频连续采样就有麻烦了。中断是有开销的压栈、跳转、读数据、出栈这些时间在高频采样时会占掉可观的CPU负载。DMA的优势在于ADC转换结束时硬件会自动把数据寄存器里的值搬到内存地址CPU全程不用管。这一步不仅省了中断开销还保证了采样数据的连续性——因为不再依赖软件是否能及时响应中断而是由硬件在转换完成瞬间完成搬运。对时间敏感的数据采集系统来说这是质的提升。4.2 双缓冲设计处理“边采边处理”的典型方案我在实验里用了循环模式加传输过半中断也就是经典的双缓冲也叫乒乓缓冲思路。把缓冲区分为前后两半前半部分填满后触发传输过半中断CPU去读前半部分的数据与此同时DMA继续往后半部分搬运。等到后半部分填满触发完成中断CPU去处理后一半。这样两边轮流工作互不干扰数据不会丢。具体配置ADC1使用规则通道采样CH0采样时间55.5周期10kHz的触发频率由定时器2产生。DMA1_Channel1和ADC1建立映射方向是外设到内存外设地址固定为ADC1-DR内存地址自增数据宽度16位模式为循环。缓冲区大小用40个半字前半20个后半20个。#define ADC_BUF_SIZE 40 uint16_t adc_buf[ADC_BUF_SIZE]; void ADC_DMA_Init(void) { // 配置DMA1 Channel1 接ADC1 DMA_DeInit(DMA1_Channel1); DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize ADC_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_ITConfig(DMA1_Channel1, DMA_IT_HT | DMA_IT_TC, ENABLE); // 使能ADC1的DMA请求 ADC_DMACmd(ADC1, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); }中断处理函数里根据标志区分前半和后半void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { DMA_ClearITPendingBit(DMA1_IT_HT1); process_adc_data(0, ADC_BUF_SIZE / 2); } if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); process_adc_data(ADC_BUF_SIZE / 2, ADC_BUF_SIZE / 2); } }4.3 实测性能对比与体会同样的采样任务先实现一个纯中断版本定时器10kHz触发ADC中断里读取数据存入数组。用定时器统计CPU占用率实测中断方式在10kHz采样率下CPU占用接近15%而DMA方式下CPU占用几乎为0——因为一次中断都不进CPU只是偶尔去处理一下已完成缓冲区的数据。随着采样率提高差距会进一步拉大。双缓冲的坑主要在边界我在第一次实验时判断前半后半用的是奇偶标志后来发现用DMA_GetITStatus来判断HT和TC更可靠。因为循环模式每个周期都会先触发HT再触发TC两次中断之间的顺序是固定的用标志判断反而容易因为中断延迟出现误判。另外处理函数里的数据读取要快如果处理时间超过了采样周期的一半下一轮数据就会被覆盖。这种问题用调试器很难抓最好的办法是加一个计数器在缓冲区被覆盖前检测到增量异常。5. 动手实验三串口不定长接收的好搭档5.1 串口接收为什么要DMA串口接收数据有两种痛一是接收不定长数据时你根本不知道对方什么时候发完中断方式每来一个字节进一次中断非常浪费二是大块数据接收时比如一次传512字节中断方式每字节都要CPU响应而DMA可以在后台全部收完再通知CPU。DMA加串口的经典方案是开启DMA接收通道配循环模式同时开启串口的空闲IDLE中断。平时数据源源不断进来DMA把它们搬入缓冲区CPU完全不管当串口在一段时间内没有新数据也就是总线空闲硬件会自动触发IDLE中断在中断里根据当前DMA计数器和上次的差值计算出这次收到的数据长度。这个方案收到了多少数据、什么时候处理都由硬件自动指示既省CPU又省事。5.2 结合CubeMX的快速配置这里我用HAL库来讲因为配串口DMA时CubeMX生成的代码已经很规范手写反而容易漏。CubeMX里的配置步骤串口设置为异步模式开启全局中断DMA设置里添加USART1_RX优先级设为中等模式循环数据宽度字节添加USART1_TX模式普通。生成代码之后启动接收用HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE)。收发都配好之后一个常见的坑是发送完一次数据后第二次发送发现没有输出因为发送DMA完成后通道自动关闭了。HAL库里处理这个问题的办法是重写发送完成回调在回调里重新调用一次HAL_UART_Transmit_DMA。我在项目中直接在外层做一个调用封装每次发送前检查DMA发送通道是否忙这样逻辑更清晰void UART_Send_DMA(uint8_t *data, uint16_t len) { while (__HAL_DMA_GET_FLAG(hdma_usart1_tx, DMA_FLAG_TC1) RESET); HAL_UART_Transmit_DMA(huart1, data, len); }5.3 空闲中断实现不定长接收接收侧的代码关键在于IDLE中断。HAL库中串口中断处理函数HAL_UART_IRQHandler里不会主动处理IDLE事件需要自己判断和清理。我在串口中断服务函数里增加对IDLE标志的检查void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (RESET ! __HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remaining __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received RX_BUF_SIZE - remaining; handle_rx_data(rx_buf, received); // 重启DMA接收 HAL_UART_DMAStop(huart1); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } }这段代码的思路DMA计数器CNDTR在接收过程中递减用缓冲区总大小减去剩余值就是新收到的字节数。处理完数据后必须先把DMA停掉再重新开启接收通道否则下一次IDLE中断会误触发或者数据错位。这个“停止—重启”的顺序不要去省略很多网上代码只重启不停止实测偶发性数据异常原因就是DMA通道状态没有被彻底复位。5.4 实测要点粘包和半包怎么处理实际运行时IDLE中断不一定恰好在一个完整数据包的边界上。如果对方发数据时中间有短暂的停顿就会把一个完整的逻辑包拆成两段各触发一次IDLE。通用做法是引入简单的帧协议比如帧头加长度字段接收完成后拼接或丢弃不完整的帧。我自己的经验是DMA加IDLE适合单包小块数据的场景几十到几百字节如果数据流是持续的断开包逻辑还是交给上层协议处理更稳妥。另外缓冲区大小一定要比最大单包长度大否则包头被覆盖或循环写入会造成数据损坏这是DMA接收很难排查的一种问题。6. 常见问题与调试心得6.1 DMA配置不生效的第一检查点很多人在第一次调试DMA时遇到“数据没有搬”的问题。我建议按这个顺序排查先确认时钟有没有开再确认通道和外设的映射关系对不对再确认CNDTR是否写入了预期值。如果是外设触发型DMAM2M之外的模式还要确认外设对应的DMA请求是否使能了——比如ADC1要ADC_DMACmd(ADC1, ENABLE)串口要在USART_CR3里置位DMAR。漏一个都不行。6.2 数据错位和乱码的原因梳理数据错位的常见根源有三类数据宽度不匹配8位和16位混用、地址增量方向配置反了外设端自增内存端固定搬出来一串乱码、搬运过程中源数据被改写。最后一类在M2M场景特别容易发生如果配置了多个DMA通道优先级更高的通道抢占了总线优先级低的通道就会间歇性暂停如果源缓冲区恰好被CPU或另一个外设更新就会读到半新的数据。性能要求高的设计里建议用双缓冲区加标志位让数据源更新和DMA搬运严格错开。6.3 循环模式的停止与重启时序循环模式永远在跑停止要注意先把通道EN清零再操作其他寄存器。我遇到过一次奇怪的现象直接调用DMA_Cmd(DMA1_Channel1, DISABLE)关闭通道后重新使能并重装CNDTR数据却从地址中间开始而不是从头开始。原因是没有等在途的一次传输结束就关闭了通道导致当前地址停在了奇怪的位置。正确的重置时序是先调用DMA_Cmd(DMA1_Channel1, DISABLE)禁能通道再调用DMA_SetCurrDataCounter重装计数器如果内存端地址有偏移就重新指定DMA_MemoryBaseAddr最后再DMA_Cmd(..., ENABLE)。不要试图在循环模式运行中直接修改CNDTR或者地址F1系列的DMA没有F4系列那些高级的自动重载能力老老实实按停——重装——启动的顺序来最稳。6.4 中断标志的处理禁忌DMA中断里有两个容易踩的地方一是多通道共用中断向量时不要用一个DMA_GetITStatus去判断所有通道必须分别判断各通道的IT标志二是清除标志一定要用对应的DMA_ClearITPendingBit如果用清外设中断标志的方式来清DMA标志会造成中断反复进入、程序卡死在中断里的现象。另一种常见问题是中断优先级配置不当。DMA传输完成中断、外设中断、CPU主循环抢同一个资源时优先级设置不合理会导致低优先级任务长时间得不到执行。我的习惯是DMA相关中断优先级设置成1只比系统节拍低一点数据处理的优先级设置在DMA之上但不要高过实时性需求更高的外设。具体数值因项目而异但要明白一点DMA中断优先级太低缓冲区就会被下一轮DMA传输覆盖数据悄无声息地丢。6.5 总线仲裁带来的隐藏性能坑DMA不是独立于总线之外的设备它要和CPU、其他外设抢总线带宽。F103的DMA虽然能搬运数据但它跑的是系统总线和CPU取指、访问SRAM是共享带宽的。大批量搬运时CPU的执行速度会略有下降。如果同时跑了多个DMA通道带宽分配就变得更敏感。测量方法很简单用定时器计一个固定任务的执行周期分别测量DMA开启前后的时间。我在实验中发现单个DMA通道搬运数据时CPU任务时间大概增加5%到10%两个通道同时满负荷搬运时能达到20%以上。这不算DMA的问题但设计时要知道DMA只是把CPU从拷贝中解放出来并不是给总线免费升了级。高吞吐场景要尽量减少内存拷贝的次数处理直接放在缓冲区里做。7. 从F1到F4的扩展知识如果用的不是F103而是F407等M4内核的芯片DMA的配置会有不少差异。F4有两个DMA控制器每个控制器有8个通道还多了流Stream的概念搬运粒度更大、支持FIFO、支持突发传输Burst。F4的DMA在内存端可以由硬件自动管理双缓冲就有专门的寄存器切换内存地址代码会比F1简洁很多。F4还多了DMA中断的多标志机制对应DMA_HALF_TRANSFER等事件处理逻辑更灵活。但核心思路还是那几个参数源地址、目标地址、数据量、方向、模式和优先级。把F1的这套流程吃透换到F4只是换了一套API的事情。Cortex-M7比如H7系列则要留意Cache一致性问题。因为DMA绕过CPU缓存直接访问内存当CPU写了一份数据然后让DMA搬走很可能搬的数据还是缓存里的旧数据。解决方法是做Cache Clean搬运前把缓存刷到内存和Cache Invalidate读取前让缓存失效这属于进阶话题但作为知识储备提前知道遇到DMA数据不对时有个排查方向。我个人这几年的习惯是凡是涉及“高频”“连续”“大批量”的数据通路一律上DMA凡是低速、零星、突发性强的数据保持中断方式更快。没有必要为了用DMA而用DMA毕竟任何技术都有学习成本和调试成本选择最匹配项目的方案才是长期做嵌入式该有的判断力。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑