news 2026/10/19 5:58:13

STM32 DMA实战:从串口中断到高效数据搬运

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 DMA实战:从串口中断到高效数据搬运

调试一块通信板的时候,我把波特率从9600提到115200,主循环里的业务逻辑就开始随缘丢帧。查了半天协议没发现问题,最后用调试器一看,CPU大部分时间都耗在串口接收中断里——每来一个字节就打断一次,中断里又要读数据寄存器又要判断帧头帧尾,任务根本抢不到执行时间。把接收改成STM32的DMA直接存储器访问之后,系统瞬间安静了,CPU占用率肉眼可见地降下来。这篇文章就围绕DMA把原理、配置、代码和踩坑记录都梳理一遍,适合正在学STM32、或者被串口/ADC高频数据搬运折磨的开发者,看完可以直接照着改自己的工程。

1. 先搞清楚DMA到底解决了什么问题:一次串口调试引发的思考

1.1 每字节一个中断的模式为什么走不远

初学者接触串口接收,最直觉的写法就是:一个字节进一次接收中断,在中断里把数据存到数组里,然后判断一帧是否收完。波特率低的时候没事,但数据量一上来问题就暴露了。115200波特率意味着每秒约11520个字节,平均一个字节间隔不到87微秒,每隔这么短时间CPU就要进一次中断,保存现场、读取数据寄存器、判断逻辑、恢复现场。如果主循环里还有屏幕刷新、电机控制、算法运算,这几件事就会互相抢占时间。

更关键的是,串口中断的优先级往往被设得比较高,因为怕丢数据。这一高不要紧,低优先级的任务被长时间挂起,表现为通信没问题,但业务逻辑"卡"了。我在那块板子上遇到的问题就是这样:协议解析层一切正常,串口也不丢字节,但主循环里的状态机老是超时。本质原因就是CPU把时间大量花在"把数据从寄存器搬到内存"这种没技术含量的动作上,而这个动作恰恰可以由DMA代劳。

1.2 DMA的本质是"专职搬运",不是"更快的搬运"

DMA的全称是Direct Memory Access,直接存储器访问。它不是一个让总线跑得更快的魔法,而是一个独立于CPU的硬件搬运工。CPU只需要告诉它三件事:从哪里搬、搬到哪、搬多少。接下来DMA就自己一趟一趟地把数据搬完,搬完了再通过中断通知CPU。整个过程中,CPU可以去干别的事情,只有在最后收到一个"搬运完成"的信号时,才需要停下来处理一下。

我用快递打个比方:没有DMA的时候,CPU就像一个快递员,每送一个包裹就要回仓库取下一件,大量时间耗在路上;有了DMA,相当于专门安排了一支转运车队,一次性把一车货拉到目的地,快递员只需要在车队出发前交代一下目的地,到货后签收一下就行。

不过这里要澄清一个很多人会误解的点:DMA省的是CPU的指令周期,不代表它不占用总线带宽。DMA和CPU都挂在系统总线上,DMA搬运数据时依然要占用总线周期。所以"用了DMA系统更快"这个说法要打折扣——更准确地说,是"DMA把CPU从重复搬运中解放出来,让CPU去跑真正的业务逻辑"。在实时性要求高的场景里,这个区别非常重要。

2. DMA传输机制拆解:方向、映射、宽度与循环模式

2.1 三种传输方向,对应三类典型场景

DMA的传输方向本质上是"谁发起、数据往哪流",STM32的DMA支持三种方向。很多初学的人觉得这个很简单,但实际上不同场景下选择的方向直接决定了初始化代码怎么写。

传输方向方向含义典型场景
外设到内存数据从外设数据寄存器搬到内存串口接收、ADC采样、SPI接收
内存到外设数据从内存搬到外设发送寄存器串口发送、DAC输出、SPI发送
内存到内存数据在内存区域之间搬运缓冲区分块拷贝、图像数据处理

外设到内存是最常用的,比如串口接收,外设地址就是USART的数据寄存器,内存地址就是我们定义的接收缓冲区,DMA收到一个字节就自动把它搬到缓冲区,反复执行,直到达到设定数量。反过来内存到外设就是发送,内存到内存则用于拷贝大块数据,这个场景对某些型号有特殊限制,F1系列的DMA1不支持内存到内存,F4系列则可以。我在F1上想用DMA做内存拷贝时发现不生效,查手册才发现根本不支持,这个后面展开讲。

2.2 通道映射是新手最容易忽略的"查表"环节

STM32的DMA控制器里有很多通道,每个通道的一侧是DMA控制器,另一侧是各种外设的请求信号。关键点在于:不是每个外设都可以随便接任意通道,而是每个外设的DMA请求有固定的映射关系。

F1系列为例,DMA1有7个通道,DMA2有5个通道。比如USART1_RX只能使用DMA1的通道5,ADC1也只能用DMA1的通道1。用错通道的表现很迷惑:初始化都成功了,但DMA就是不动。我在一次移植代码时,把原来F103的ADC采集代码直接搬到另一款F103芯片上,结果数据死活不刷新,最后查映射表发现ADC的DMA通道依赖的引脚复用配置变了,请求信号根本没到DMA。

F4系列更复杂一些:DMA控制器里有8个数据流,每个数据流下面又有8个通道,配置时既要选择数据流编号,还要选择通道编号,两层映射都要和外设对应。用CubeMX生成代码时它会自动匹配好,但手写寄存器或者抄网上的代码时,这一步非常容易错。所以不管用什么型号,第一件事永远是打开参考手册里的"DMA请求映射表",对着表格确认一遍。

2.3 数据宽度、地址增量和循环模式里的细节

数据宽度是指一次搬运多少位。串口数据寄存器是8位,ADC数据寄存器是16位,DMA的外设侧和内存侧的宽度要分别配置,还要注意两侧是否一致。如果ADC是16位数据,DMA却配置成8位宽度搬运,会把每个16位数据拆成高低两个字节存到两个位置,后面取数全乱套。

地址增量决定了搬运的地址变化方式。典型配置是外设地址固定(因为寄存器就一个),内存地址递增(因为要按顺序填缓冲区)。如果用串口接收,外设地址永远指向USART->DR,内存地址从buf[0]逐步走到buf[N-1]。

循环模式(Circular Mode)是做连续采样的大杀器。普通模式搬完设定的数量就停下来了,而循环模式搬完一块缓冲区后,自动把地址指针重置回起始地址,继续下一轮搬运。地址计数器也会自动重装载。这种模式下,缓冲区相当于一个循环队列,DMA一直在往里写数据,CPU只需要在适当的时机读取处理。但要注意:正因为是循环覆盖,如果CPU不及时处理数据,旧数据会被新数据覆盖,后面讲ADC采样时我会详细说这个问题。

3. 串口空闲中断+DMA接收:一套可直接用的工程模板

3.1 为什么非得用"空闲中断"来做帧结束判断

处理不定长串口数据帧,最大的问题是什么时候算"一帧结束"。如果固定长度,DMA传输完成中断就够用了;但通信协议里更多是不定长帧。最常见的方案有两种:按字符间隔超时判断,或者用串口的空闲中断(IDLE)。

空闲中断的含义是:接收线上已经空闲了整整一个字节的时间,说明后面大概率没有紧跟的数据了。这个时机正好可以作为"一帧数据已结束"的标志。配合DMA就是黄金组合:DMA负责把字节不断搬进缓冲区,空闲中断告诉CPU"到帧尾了,来取数据吧"。CPU不用管每个字节,只需要在一帧结束时处理一次,效率提升非常明显。

我的经验是:如果总线波特率较高、两帧之间的间隔较长,空闲中断用起来很舒服。但如果两个设备之间发送背靠背的数据帧,帧间间隔非常短,空闲中断很容易判断不出帧边界,此时就得在应用层协议里设计帧头帧尾,配合状态机解析,或者再加一个定时器做超时判断。这个要看你具体的协议设计。

3.2 配置和代码逐步拆解

先看初始化部分,用HAL库配置串口DMA接收:

#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; void uart_dma_rx_init(void) { // 启动DMA循环接收,缓冲区为256字节 HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); // 开启串口空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }

接着在串口中断处理函数里处理空闲中断:

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { // 清除空闲标志 __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 读取DMA当前剩余未传输的数量 uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 已经搬运进缓冲区的字节数 uint16_t len = RX_BUF_SIZE - remain; // 把这一帧数据交给协议解析函数 process_frame(rx_buf, len); // DMA一直处于循环接收模式,无需重新启动 } HAL_UART_IRQHandler(&huart1); }

这段代码里有个关键点:DMA工作在半满/全满中断和空闲中断是独立的两套机制。DMA的传输完成中断是在缓冲区填满256字节时触发,而空闲中断是每当总线空闲就触发。也就是说,如果一帧数据只有20字节,空闲中断立刻触发,CPU取走20字节;如果数据量刚好凑到256字节,DMA的传输完成中断也会触发。两者实际并存,大多数情况下我们只用空闲中断去取不定长帧,DMA完成中断则用于定长数据块的场景。

另一个细节:清空闲中断标志要直接给IDLE位写操作,HAL库的做法就是上面这行__HAL_UART_CLEAR_IDLEFLAG,不要在中断里长时间处理数据。处理一帧数据如果耗时太久,下一帧数据可能已经开始往缓冲区写了,会出现数据覆盖。尽量把process_frame做成"拷贝数据到临时数组"或者"标记一个标志位,然后在主循环里处理",不要在中断上下文里做协议解析这种重活。

3.3 定长和不定长报文各自的处理方式

定长报文处理其实更简单。不用开空闲中断,只需要把DMA配置成普通模式(非循环),设置好传输长度,等DMA传输完成中断触发时,缓冲区里正好是一帧完整数据。配合Half Transfer中断还能做到"边收边处理"。

不定长报文的核心就在于"帧尾判断"。空闲中断是最直接的方案,但如果你遇到两帧紧挨着、帧间隔极短的情况,空闲中断会失效。我的一次实际经历是:主机连续发送多条指令,每条指令之间只有几个微秒的间隔,结果两条指令被当成一帧收了下来,解析时第一个字节对不上帧头,整包丢弃。这个问题最后靠应用层协议解决:每条指令增加固定的帧头帧尾,解析时用状态机扫描,同时配合一个软件定时器做超时兜底。DMA只负责把字节送到缓冲区,帧边界由状态机自己判断。

所以实际项目里不要指望单纯DMA加空闲中断解决所有通信问题,协议设计才是根本。DMA解决的是"高效把数据运回来"这个层,帧边界判断要靠应用层逻辑。

4. ADC多通道连续采样+DMA:从串口扩展到数据采集

4.1 高频采样场景下DMA几乎是唯一选择

串口是DMA最常见的应用,但ADC采样才是最依赖DMA的地方。假设三个模拟通道做连续扫描采样,采样率20kHz,如果不使用DMA,每次ADC转换完成都会触发一次中断,CPU需要立刻读取转换结果,否则下一个通道的数据会把当前结果覆盖掉。每秒20k次中断,每次中断还要读寄存器、存数组、判断通道号,这一套下来CPU基本就没空干别的了。

使用DMA之后,ADC每次转换完成都会自动触发DMA搬运,数据会按照通道扫描顺序依次存进缓冲区。中间CPU完全不需要介入,只在缓冲区填满后触发一次中断。这就是典型的"采集的时候不用管,处理的时候统一取数"。

初始化代码很简洁:

#define ADC_SAMPLE_COUNT 1024 uint16_t adc_buf[ADC_SAMPLE_COUNT]; void adc_dma_start(void) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_SAMPLE_COUNT); }

注意,这个缓冲区里存的不是单通道的波形,而是多通道交替排列的数据。如果扫描顺序是CH1、CH2、CH3,那么adc_buf[0]是CH1,adc_buf[1]是CH2,adc_buf[2]是CH3,adc_buf[3]又回到CH1。取数据时要用步长3去取,不能当成连续单通道波形处理。

4.2 半满全满双缓冲与数据覆盖问题

循环模式下,DMA会一直往缓冲区写数据,写满又从头部继续,对CPU来说,缓冲区就像一圈不停滚动的磁带。如果要长时间采集又不想丢数据,可以开启DMA的半传输中断和全传输中断。当DMA搬运完一半缓冲区时触发半传输中断,CPU处理前半段;搬运完整个缓冲区时触发全传输中断,CPU处理后半段。用HAL库时对应两个回调:

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { // 处理前半段 adc_buf[0] 到 adc_buf[N/2-1] } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { // 处理后半段 adc_buf[N/2] 到 adc_buf[N-1] }

这种"双缓冲"模式能保证数据不丢的前提是:CPU处理半段数据的时间,必须小于DMA填满另外半段缓冲区所需的时间。如果处理太慢,DMA会绕回来覆盖还没处理完的那一半数据。出现覆盖时最典型的症状就是:波形呈现规律性断裂,每隔一段就出现一个跳变点。

我在项目里用过一个简单有效的办法:把采样率调低一档,或者把缓冲区调大一倍,先让数据不再断裂,再用逻辑分析仪统计每次中断处理的实际耗时,最后确定合理的缓冲区大小。缓冲区不是越大越好,因为半满中断的延迟会变大,实时性会下降,这是一个折中。

5. DMA实战踩坑记录与排查链路

5.1 配好了却不传输:从寄存器入手先判断"有没有动"

DMA不工作的排查,最怕一上来就怀疑代码逻辑。正确的路径是先判断硬件层面有没有动作,再往上查配置。

第一步,用一个调试器挂在DMA的相关寄存器上。重点看三个:控制寄存器CCR里的EN位是否置1、通道配置是否和外设匹配、CNDTR计数器的值是多少。CNDTR是当前剩余要传输的数量,如果它在一开始就等于你设定的值,传输过程中也一直没变化,那说明DMA根本没收到外设的请求信号;如果CNDTR一直在递减,说明DMA在工作,问题出在中断或数据处理环节。

第二步,确认外设的DMA请求是否真正开启。用HAL库时,串口要调用HAL_UART_Receive_DMA,ADC要调用HAL_ADC_Start_DMA,这两个函数内部会同时配置外设的DMA使能位。如果只配置了DMA的初始化,却没有启动外设的DMA请求,那么DMA就是"通路修好了,但水闸没开"。

第三步,检查时钟。DMA挂在不同总线上,F1的DMA1在AHB1上,如果系统初始化时没使能DMA1的时钟,写寄存器根本写不进去。很多从旧工程复制代码的人容易漏掉这一条,因为CubeMX自动生成的代码里时钟是配好的,手工移植时就容易丢。

第四步,回头查DMA映射表。前面说过,外设和DMA通道是固定的对应关系,换型号或者换引脚复用的时候尤其容易出错。调试时观察外设是否真的产生了DMA请求信号很难直接看到,所以最好的办法就是对着参考手册逐个确认。

5.2 数据错位与"规律跳变"的定位思路

DMA搬运的数据看起来"乱了",通常有几种情况。

第一种是数据宽度配置不匹配。ADC输出16位数据,DMA如果配置成8位,那么数据会被拆分,取回来就是乱的。这个在初始化代码里一目了然,ExternalDataWidth和MemoryDataWidth要同时设置成16位。

第二种是多通道数据顺序理解错误。串口是一个字节一个字节连续排列,但ADC多通道是轮流排列的,上面的步长取数问题已经说过了。还有一种场景是DMA用了非循环模式,一次搬运的数量设定错了,导致只搬了一部分,缓冲区后半段是上次残留的数据,看起来就像"后半段全是旧数据"。

第三种是内存缓冲区地址对齐问题。在F4/H7这类带Cache的高性能芯片上,如果缓冲区地址没有按32位对齐,DMA可能会报错或者传输结果异常。这在用局部数组当作缓冲区时尤其容易出现,因为编译器给局部变量安排地址不一定对齐。我一般会把DMA缓冲区定义成全局数组,或者用__attribute__((aligned(32)))显式指定对齐。

出现"规律跳变"时,不要一个个数数据,先把采到的波形打印出来看跳变的位置间隔。如果每隔N个点跳变一次,基本可以判断是缓冲区半满或者全满位置的覆盖问题;如果跳变没有规律,则优先怀疑数据宽度和通道顺序。

5.3 DMA和CPU抢总线之后,系统变慢该找谁

DMA虽然不占用CPU去执行搬运指令,但它和CPU共用系统总线,搬运本身会占用总线周期。当一个DMA通道的优先级配置过高,或者需要搬运的数据量特别大,比如持续做内存到内存的大块拷贝,DMA会大量占用总线带宽,CPU取指令和数据都会变慢,表现出来就是"系统整体卡顿"。

排查思路是先看DMA优先级配置。DMA优先级的层次是:软件优先级高者先响应,同等优先级下硬件编号小的先响应。我把一路DMA从High改成Medium之后,系统的中断响应延迟立刻下来了。如果你的系统里有多路DMA,要按实际重要性分配优先级,别把不重要的传输设成High。

另一个思路是减少搬运频率。例如,串口发送本来可以DMA搬运100个字节,但如果你用内存到内存方式高频搬运小段数据,总线会被频繁打断,还不如用普通寄存器轮询。

5.4 缓存一致性问题:F4升级党最容易忽略的坑

从F1升级到F4,很多人会遇到一个诡异问题:用DMA接收的数据是乱的,前面一半是对的,后面一半是旧的,或者反过来。如果你打开了F4的D-Cache数据缓存,问题就出在缓存一致性上。

D-Cache的作用是缓存CPU访问内存的数据,当CPU读取内存时,可能先命中缓存而不是实际内存。DMA是外设,它访问的是物理内存,不会经过CPU的Cache。这就导致一个问题:CPU往缓冲区写了一段数据,这段数据可能还在Cache里没有写回物理内存;紧接着DMA搬运这块缓冲区,读到的可能是旧数据。反过来,DMA往缓冲区写入了新数据,但CPU去读时,Cache里还留着之前的旧值,导致读出来是旧的。

解决办法有两个思路。一是用DMA搬运前/后显式操作Cache,比如搬运前SCB_CleanDCache_by_Addr把CPU写入的数据冲刷到物理内存,搬运后SCB_InvalidateDCache_by_Addr让CPU下次读取时重新从物理内存获取。二是在MPU配置里把DMA缓冲区所在的内存区域设置为非缓存(Device或者Non-cacheable),让数据绕过Cache。对新手来说,第一种方法最容易理解和排查,代码也就几行,但要注意方向:内存到外设方向用Clean,外设到内存方向用Invalidate。

6. 纸上谈兵不如先跑通串口:给新手的练手路径

学习DMA最忌讳的就是只看不练。我给新手建议一条从易到难的练手路径,每一步都是真实项目里的高频场景。

第一步,先跑通串口接收。不要一上来就搞复杂的协议,先定一个固定长度的帧,比如从串口调试助手发16个字节过来,用普通模式DMA接收,传输完成中断里把数据打印出来。这能帮你理解"外设到内存"的整条链路。

第二步,把固定长度改成不定长。加上空闲中断,一边接收一边用串口助手发不同长度的数据,观察长度计算结果是否准确。这一步能把中断、计数器、循环模式这些概念串起来。

第三步,接一个模拟传感器做ADC多通道采集。采集几路电压数据,通过串口或屏幕上显示波形,看看通道顺序、数据宽度、半满全满中断这些细节。

第四步,如果你用的是F4/H7系列,加一段缓存一致性验证的代码。打开D-Cache之后故意不做Invalidate操作,观察数据变化,这会让你对Cache的机制有非常直观的理解。

每一步跑完以后,都回头打开参考手册里对应的章节,把寄存器配置和代码对应上。我看过太多人只会复制CubeMX生成的代码,出了问题完全不知道从哪里查。DMA这种底层功能,最终还是比拼的是"看手册、查寄存器、读数据"这三板斧,把这套能力练出来,后面的疑难杂症处理起来会轻松很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 5:57:08

Diagram-Design:构建可执行的系统逻辑契约

1. 项目概述:这不是画图,是构建可执行的系统逻辑骨架“diagram-design”这个词组乍看像美术课作业,但在我过去十年带过的几十个跨领域项目里,它从来不是PPT里的装饰性插图,而是工程师、产品负责人、甚至一线运维人员每…

作者头像 李华
网站建设 2026/10/19 5:56:53

QPSK扩频通信原理与MATLAB实现详解

简介:本资源是一份面向通信工程专业学生、MATLAB初学者及数字通信仿真实践者的QPSK扩频系统完整实现代码包,聚焦于直接序列扩频(DSSS)原理验证与端到端链路仿真。资源包含18个.m文件,覆盖二进制数据生成、QPSK调制/解调…

作者头像 李华
网站建设 2026/10/19 5:56:35

artcraft 创意流程化实践:从纸艺灯罩到通用工序拆解

1. 从“artcraft”这个名字说起:它到底想解决什么问题第一次看到“artcraft”这个项目标题,我的直觉是:这大概率是一个把“艺术创作”和“手工实现”捏在一起的东西。拆开看,“art”指向审美、创意、视觉表达,“craft”…

作者头像 李华
网站建设 2026/10/19 5:55:26

artcraft项目实战:如何跨越审美表达与工程实现的鸿沟

1. 从“artcraft”这个名字说起:它到底想解决什么问题第一次看到“artcraft”这个标题,我脑子里蹦出来的第一反应是:这大概率不是一个单一工具,而是一类“把艺术创作和手工实现缝合起来”的项目思路。art 是艺术、审美、表达&…

作者头像 李华
网站建设 2026/10/19 5:54:58

Mac下用Jenkins+fastlane搭建iOS自动化打包实践

1. 为什么我在 Mac 上折腾 Jenkins 和 fastlaneMac 下搭建 Jenkins & fastlane,这个组合我是在 2021 年上半年正式跑通的。当时我用一个模拟项目 X 练手,这个项目结构跟普通 iOS App 一样,有若干 target、依赖了 CocoaPods,每…

作者头像 李华
网站建设 2026/10/19 5:54:21

artcraft:从审美到手工的实践框架,打通创意与执行

1. 从“artcraft”这个名字说起:它到底想解决什么问题第一次看到“artcraft”这个标题,我脑子里蹦出来的不是某个具体工具,而是一种很朴素的冲动——把“art”和“craft”这两个词捏在一起。art是艺术、是审美、是那种说不清道不明的“感觉对…

作者头像 李华