STM32F103串口DMA与空闲中断实现不定长接收的完整实战

发布时间:2026/10/28 1:05:31
STM32F103串口DMA与空闲中断实现不定长接收的完整实战

在STM32F103的开发里,串口通信是无论如何都绕不开的基础功能。但在实际项目中,仅仅做到“能收发数据”远远不够,尤其是当你面对的是不定长数据帧、高速连续传输、或者主循环里还跑着显示刷新和传感器采集时,传统的“接收一个字节进一次中断”的做法很快就会成为性能瓶颈。DMA收发加空闲中断不定长接收,是目前在F103这种小资源单片机上兼顾实时性与CPU负载的主流方案。

这篇文章我从零开始完整梳理这套方案的原理、配置步骤、代码实现和常见坑点,基于我在实际项目里踩过的坑和调过的板子来写,适合已经会用标准库或HAL库点灯、但想把串口玩明白的开发者参考。工程基于STM32F103C8T6,IDE用Keil MDK,初始化代码用CubeMX生成,核心逻辑手写,这样既省时间,又能保证你能看懂每一行代码在干什么。

1. 方案选型与整体设计思路

1.1 为什么要用DMA + 空闲中断,而不是传统串口中断

很多初学者习惯用最朴素的方式写串口接收:开个全局数组,在USART1_IRQHandler里一个字节一个字节地存。逻辑看似简单,但当波特率跑到921600,或者设备每毫秒就发几十个字节时,这种方式的弊端会非常明显。每次中断都要压栈、进handler、读数据寄存器、写数组、清标志,CPU时间被大量占用,而且如果主循环里的任务稍微一忙,来不及进中断处理接收数据,数据就会丢失。

DMA的引入解决的是“搬运数据”这件事本身。DMA控制器可以在完全没有CPU参与的情况下,把USART接收数据寄存器里的字节自动搬到内存数组里。CPU只需要在做完初始化后静静等待,当一整帧数据接收完成时,DMA会置起传输完成标志,或者配合空闲中断来通知CPU“该处理了”。

那空闲中断又是什么?它是指串口线从有数据变为无数据(即总线空闲)时触发的中断。实际使用中,一帧数据发送完毕后总会有个停顿间隙,这个间隙正好触发空闲中断,CPU就知道一帧完整数据过来了。这套组合的逻辑就是:DMA负责收,空闲中断负责“通知CPU整帧收完了”。就相当于雇了个搬运工一直在仓库门口搬货,搬完一批货按下门铃提醒老板来清点,老板再也不用站在门口一件一件接了。

1.2 USART、DMA、NVIC三者的协同关系

这里我画过很多次框图,但让我用文字表述一下数据流,这对理解后续配置非常关键。USART外设接收数据时,数据先进入USART的接收移位寄存器,再进入接收数据寄存器(RDR)。如果开启了DMA接收请求,RDR里的数据会被DMA控制器搬走,搬到你在内存里指定的数组地址。这个搬运过程可以是一个字节一个字节地触发,DMA计数器(DMA_CNDTR)会从初始值递减,搬一个减一个。当CNDTR减到零,就是DMA传输完成,可以触发传输完成中断。

但这里有个关键点:只有当DMA_CNDTR计数到0时你才知道“收完了一帧”,可如果这一帧根本没有你预设的那么长呢?比如你设置DMA接收长度是100字节,但设备只发了一帧10字节的数据,那DMA计数器从100减到90就不动了,永远不会到0,CPU永远得不到通知。这就是为什么必须搭配空闲中断:总线空闲了,说明这帧发完了,不管DMA计数器有没有减到0,都要立即处理当前已接收的数据。把两者结合起来,才能真正实现所谓“不定长接收”。

1.3 为什么把不定长接收放在F103上更适合学

F103是Cortex-M3内核,主频72MHz,没有复杂的中断嵌套和Cache一致性要求,调试起来比H7、F4这类高性能芯片简单得多。DMA1有7个通道,DMA2有5个通道,其中USART1_TX是DMA1通道4,USART1_RX是DMA1通道5,这个通道映射关系固定,配置错了数据是跑不起来的。对初学者来说,F103的参考手册RM0008里关于DMA和USART的部分也就几十页,啃下来不会很吃力,而且这套知识迁移到F4、H7也非常顺畅,除了DMA请求映射表略有区别,其余思路完全一致。

2. 核心细节解析与实操要点

2.1 DMA收发通道的映射关系与选择

在动手配置之前,先搞清楚F103上USART和DMA通道的对应关系。这里只列最常用的USART1和USART2,其他外设通道映射详情请查阅RM0008第9章DMA请求映射表。

外设请求方向DMA1通道DMA2通道
USART1_TX内存→外设通道4无
USART1_RX外设→内存通道5无
USART2_TX内存→外设通道7无
USART2_RX外设→内存通道6无
USART3_TX内存→外设通道2无
USART3_RX外设→内存通道3无

从表里可以看到,F103的这几个串口DMA请求全挂在DMA1上,通道之间是独立的,但DMA1同一时刻只能有一个通道在服务一个请求方向,如果把多个外设的DMA接收放在同一优先级且同时触发,可能会有竞争。实际项目中,如果你同时用了USART1接收、ADC采样和SPI通信的DMA请求,要留意优先级分配。F103的DMA优先级是可编程的,高优先级先响应,同优先级看通道编号小的先来。

我一般习惯把USART1_RX的DMA优先级设为最高,因为串口接收对实时性要求最高,稍微晚一步不会丢数据,但DMA通道如果被更频繁的ADC扫描占用,接收数组里就可能会出现半个数据帧的残缺内容。这里有个比较容易让人迷惑的点:DMA的中断优先级和DMA通道的仲裁优先级不是一回事。前者是NVIC里配置的,决定DMA传输完成后产生的中断能否打断主循环或其他中断;后者是DMA控制器内部决定谁先占用总线传输数据。两个都要设置,且要综合考虑。

2.2 空闲中断的触发原理与标志位处理

空闲中断(IDLE)在USART_SR寄存器里对应第4位IDLE标志。触发条件是检测到总线上的空闲状态,即接收线上连续接收到一个字节的周期长度的空闲电平(高电平)。这里“一个字节周期”指的是在当前波特率下,传输一帧数据(含起始位、数据位、停止位)需要的时间。所以波特率越高,同样的数据发送完后,越容易被快速识别为空闲。这个标志位不像RXNE那样在收到新字节时自动清除,它在检测到空闲时置位,必须由软件读一次USART_SR再读一次USART_DR来清除。

这里有一个很多新手会忽略的细节:如果开启了RXNE中断,又在中断里不停地读DR清标志,那么在一次DMA接收过程中,如果长时间没有数据进来,IDLE位只会被置一次。但如果数据刚好在你读SR和读DR的间隙到达,IDLE可能又被检测到一次,导致一帧数据被拆成两半触发两次空闲中断。这是正常现象,不是错误,处理方式是在空闲中断里记录接收长度时,用“当前DMA剩余计数”和“上次接收结束时的剩余计数”做差值,而不是直接清零重开。具体实现我在下面第三节细讲。

2.3 DMA_CNDTR寄存器的动态读取技巧

DMA接收不定长数据,最核心的寄存器就是DMA_CNDTR,它保存着DMA当前还剩多少个字节没搬。每次空闲中断触发时,用“预设长度 - 当前CNDTR值”就能得到本次接收到的字节数。举个例子:你设置DMA接收缓冲区为256字节,CNDTR初始值也是256。一帧10字节的数据到达后,DMA搬走10字节,CNDTR就变成246。空闲中断触发时读CNDTR得到246,接收长度就是256减去246等于10。

这个计算本身不复杂,但要注意读取CNDTR的时机。一定要在读取USART_SR、清IDLE标志之后、重新配置DMA之前读。因为在清IDLE标志的过程中,如果有新的数据已经开始到达,DMA会继续搬运,CNDTR会继续变化,读早了或读晚了都会出错。最稳妥的做法是:先关掉DMA接收通道,再读CNDTR,最后重设CNDTR并重新打开DMA通道。这样能保证读数绝对准确,代价是损失几个时钟周期的接收时间,对F103上的典型波特率来说在数据间隙里足够完成。

3. 实操过程与核心代码实现

3.1 CubeMX配置步骤详解

第一步是时钟和引脚。STM32F103C8T6的USART1默认引脚是PA9(TX)和PA10(RX),也可以重映射到PB6和PB7,但初学者不建议用重映射,切换到AFIO还有一堆坑。时钟配置为外部8MHz晶振倍频到72MHz,APB2总线时钟默认就是72MHz,USART1挂在APB2上。如果用的是标准库初始化,记得在SystemInit里确认RCC_CFGR的PPRE2分频系数,别让USART1的时钟频率变成36MHz,那会导致波特率计算错误。

第二步入USART1配置界面,模式选Asynchronous异步通信,波特率我一般从115200起手,调试时不要一上来就干到921600,波特率越高对线路质量、杜邦线长度、干扰的敏感度越高。参数:8位数据、无校验、1位停止位,接收和发送都使能。关键一步是要在DMA Settings选项卡里添加USART1_RX和USART1_TX两个DMA请求。RX方向选Memory-to-Peripheral,不对,这里容易选反——USART接收是外设到内存,所以RX的方向是Peripheral-to-Memory;TX是内存到外设,方向是Memory-to-Peripheral。模式选Normal还是Circular这一步最关键。

这里我必须强调一下:RX的DMA模式建议选Circular循环模式,不要选Normal。原因很简单,Normal模式下DMA传输完指定的字节数后,通道自动关闭。如果你设置接收长度为256字节,设备只发10字节,CNDTR从256减到246就不动了,接收通道还开着,但因为这种模式CNDTR不会再自动重载,除非你手动处理,否则后续数据根本进不来。而Circular模式在CNDTR递减到0后会自动重新加载初始值,也就是说数据一直能接收,只是需要你在空闲中断里手动重置CNDTR为初始值来保证计数准确性。两者都不完美,但Circular模式配合空闲中断重设CNDTR,是目前最通用的做法。

TX的DMA模式可选Normal,发送完一帧就不再占着通道,下次要发再手动使能。如果把TX也设成Circular,DMA会不停地把发送缓冲区里的数据循环发出去,这在极少数需要持续发送相同数据定长波形的场景下才有用,普通业务通信用不到。

第三步入NVIC设置,把USART1全局中断使能打开。可能有人会疑惑:“我都用DMA了,为什么还要开串口中断?”答案是空闲中断本身就是USART外设的中断,不是DMA的中断。所以USART1_IRQHandler这个中断服务函数必须能进入系统,才能处理IDLE标志。同时DMA的中断可以不开,因为接收完成是靠IDLE通知的,DMA传输完成中断在Circular模式下意义不大。如果你在DMA Settings里勾选了DMA的NVIC中断,那发送完成时也会进入DMA中断,用来做发送状态管理是可以的,但不必须。

3.2 核心接收代码实现与逐行解释

CubeMX生成的代码框架我会保留,但接收逻辑完全手写。下面是完整的接收实现,基于HAL库,兼容性强一点。业务代码中也可以直接用寄存器操作版,但HAL库的可读性更好,代码迁移到F4系列时改动更小。中断服务函数里不要调用HAL_UART_IRQHandler,直接自己处理IDLE标志就行,省去库函数里额外的一层判断开销。

/* 接收缓冲区定义,放在全局区,避免栈溢出 */ #define RX_BUF_SIZE 256 uint8_t rx_buffer[RX_BUF_SIZE] = {0}; uint16_t rx_len = 0; /* 本次接收到的数据长度 */ uint8_t rx_flag = 0; /* 接收完成标志,主循环轮询或置位后处理 */ uint16_t rx_last_cnt = 0; /* 上次接收结束时DMA剩余计数 */ void MX_USART1_UART_Init_DMA(void) { /* CubeMX已生成USART1初始化代码,这里补充DMA接收启动 */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); /* 使能空闲中断 */ /* 启动DMA接收,CNDTR初始值为RX_BUF_SIZE */ HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUF_SIZE); /* 记录当前DMA剩余计数初始值 */ rx_last_cnt = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); /* 读SR再读DR清IDLE,HAL库封装了这步 */ /* 关闭DMA接收通道,防止读取CNDTR时数据还在写入 */ __HAL_DMA_DISABLE(&hdma_usart1_rx); /* 计算本次接收长度:初始计数 - 当前剩余计数 */ uint16_t current_cnt = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (current_cnt <= rx_last_cnt) { rx_len = rx_last_cnt - current_cnt; } else { /* 环形模式绕了一圈,需要计算绕回值 */ rx_len = RX_BUF_SIZE - current_cnt + rx_last_cnt; } rx_flag = 1; /* 此处将rx_last_cnt更新为当前计数,注意不是RX_BUF_SIZE */ /* 如果在同一时刻缓冲区未绕回,last_cnt基本会越来越小 */ rx_last_cnt = current_cnt; /* 重新填充DMA计数,恢复接收 */ __HAL_DMA_SET_COUNTER(&hdma_usart1_rx, RX_BUF_SIZE); __HAL_DMA_ENABLE(&hdma_usart1_rx); } /* 如果也开启了RXNE中断,这里加一层处理 */ if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET) { /* 单字节接收场景的处理逻辑 */ } }

这段代码里有几个细节必须讲清楚。首先是__HAL_UART_CLEAR_IDLEFLAG这个函数,在HAL库中它实际执行的操作是“读SR寄存器再读DR寄存器”——这正是RM0008里要求的清除IDLE标志的顺序。但由于DR已经被DMA读走过,这里读DR拿到的数据其实是当前DMA缓冲区的第一个字节,不是有效新数据,所以不需要保存这个读取值。

其次是关闭DMA通道再读CNDTR的顺序。如果不先关闭DMA,在读取CNDTR的这几条指令周期内,如果新字节到达,CNDTR会继续递减,导致计算长度偏大。关闭后再读,读到的值是稳定且准确的。

最后是环形模式下的rx_last_cnt更新策略。很多网上的代码在空闲中断里会把rx_last_cnt直接重置为RX_BUF_SIZE,这在大缓冲区、中低速率的场景下问题不大,但如果数据帧紧跟在空闲中断后面立即到达,而DMA的CNDTR还没有被重新填充,current_cnt就会小于RX_BUF_SIZE,再次触发空闲中断时计算出的长度就会偏大。用rx_last_cnt保存上一次的实际剩余计数,能够处理这种背靠背数据帧的边界情况。

3.3 发送功能的DMA实现与printf重定向

DMA发送的逻辑相对简单,把要发送的字节数组地址给DMA,设置好长度,开启DMA通道,数据自动搬出去。CubeMX生成的HAL库封装了HAL_UART_Transmit_DMA函数,直接调用即可。但是直接用这个函数有个坑:它只启动DMA传输,不等待传输完成就返回。如果你紧接着调用第二次发送,而第一次还没发完,DMA通道还在忙,第二次调用会直接覆盖第一次的数据,导致发送内容错乱。

稳妥的做法是自己加一个发送状态标志,第一次发送时置忙,DMA发送完成中断里清标志。CubeMX在DMA Settings里勾选TX DMA的中断后,在HAL_UART_TxCpltCallback里能收到完成回调,这个回调是在DMA中断上下文里执行的,不要在回调里做耗时操作,只改标志位即可。

volatile uint8_t tx_busy = 0; void UART_DMA_Send(uint8_t *buf, uint16_t len) { while (tx_busy) { /* 等待上一次发送完成 */ } tx_busy = 1; HAL_UART_Transmit_DMA(&huart1, buf, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { tx_busy = 0; } }

printf重定向到DMA发送,这个需求我项目里经常用到。标准做法是重写fputc函数,但把fputc改成DMA发送有一点问题:DMA发送是异步的,每写一个字符都启动一次DMA传输,效率极低,而且等待上一次发送完成的while循环会让实时性变差。我的实践方案是做一个简单环形发送缓冲区,fputc只把字符丢进缓冲区,DMA发送完成中断里持续从缓冲区取下一个字符发送,这样printf就变成了写内存操作,速度极快。但这个方案代码量稍大,如果只是调试用,可以接受fputc里用轮询发送的慢速方案,也不至于出乱子。参考实现:

int fputc(int ch, FILE *f) { /* 轮询等待上一次字节发送完成 */ while (!(USART1->SR & USART_SR_TXE)) {} USART1->DR = ch; return ch; }

注意TXE标志和TC标志是不同的。TXE表示数据寄存器已空,可以写入下一个字节;TC表示整个字节包括停止位已经发送完。fputc里用TXE就够,用TC会变慢。最后记得在Keil里勾选微库MicroLib,并把stdio.h包含进来,否则printf重定义无效。

4. 不定长接收的边界场景处理

4.1 环形缓冲区被数据填满时的处理策略

DMA工作在Circular模式时,如果长时间没有空闲中断,例如设备连续发送超长数据流,或者发送端根本没有帧间隙,DMA会一直搬运数据,从缓冲区末尾折回到开头继续写。此时如果业务层还没处理完上一批数据,新数据就会覆盖旧数据。解决思路有两种。

第一种是加大缓冲区。例如把rx_buffer从256字节改成4096字节,这在F103的20KB RAM里完全可行。如果只是调试用或者数据帧不长,这个方案最简单可靠。要注意的是,缓冲区加大后,__HAL_DMA_GET_COUNTER读到的数值范围也变大,所有变量类型都要保持16位,别定义成uint8_t。

第二种是采用双缓冲机制。准备两个缓冲区,一个给DMA写入,一个给业务层解析。空闲中断触发时,把两个缓冲区的角色互换,DMA立刻指向新缓冲区,业务层安全地处理旧缓冲区。这个方案不丢数据、效率高,但实现复杂度上升,需要处理缓冲区的切换时竞态,新手可以先不做,但知道有这个方案。

4.2 连续多帧数据背靠背到达的识别问题

前面提过,如果发送端把两帧数据紧挨着发出,帧与帧之间只有一个字节的时间间隔,这个间隔恰好小于空闲中断的检测阈值,那么空闲中断不会触发,两帧数据会被当成一帧接收。这是DMA不定长接收方案在物理层面上的固有限制。SDK中常见的做法是给数据协议加帧头、帧尾和长度字段。比如自定义协议[0xAA 0x55] [LEN] [DATA...] [CRC16],接收端在业务层解析时,根据帧头定位帧起始,再读取LEN字段判断帧长度,从缓冲区中截取完整的一帧。这种方案的好处是即使DMA把这N帧全攒到一个大缓冲区里,业务层也能逐个解析出来。本质是协议层容忍DMA层的“帧粘连”。

提到协议层,我建议把接收数据的处理从中断上下文搬到主循环。具体实现是:空闲中断里只设置rx_flag = 1,把缓冲区所有权交还给主循环。主循环检测到rx_flag后,把数据复制到另一个业务缓冲区(或者直接在同一缓冲区上解析),然后清标志,重新等待下一帧。如果直接在中断服务函数里做解析,一旦数据量稍大,中断服务函数执行时间过长,会影响其他中断的实时性,还可能造成数据覆盖。

4.3 接收缓冲区清零与长度记录的复位时机

很多人会习惯性地在每次空闲中断里用memset把接收缓冲区清零。这个操作是多余的,而且有害。接收缓冲区本质上是一段由DMA维护的内存,只要DMA正常搬运,缓冲区里的数据天然就是“最新接收的字节序列”。清零不仅浪费时间(256字节的memset在72MHz下虽然只要几个微秒,但在中断上下文里能省则省),还会破坏DMA正在写入时的数据连贯性。如果你确实想确认某一段数据是有效的,应该用rx_len来界定,而不是清零缓冲区。接收长度是DMA计数值计算出来的,不是靠缓冲区内容判断的。

正确的复位时机是:业务层处理完数据,确认数据已经复制到自己的解析缓冲后,主循环里再清rx_flag并复位rx_len。而DMA的CNDTR已经在空闲中断里重新填充过了,不需要在主循环里再动它。

5. 常见问题与排查技巧实录

5.1 串口只能收到第一帧数据,之后就毫无反应

这个故障我在网上帮人排查过太多次了。现象是上电后设备发一帧数据,接收正常,之后再发没有任何反应。几乎100%是DMA配置成了Normal模式而非Circular模式导致的。前面讲过,Normal模式下DMA通道在完成指定长度传输后自动关闭,CNDTR不再重装。虽然你在空闲中断里重新设置了CNDTR,但如果忘记重新使能DMA通道,那后续接收请求就算到达了DMA,DMA通道也没在工作。排查方法是软件里加个调试断点,在空闲中断里查看hdma_usart1_rx.State的值,如果是HAL_DMA_STATE_READY,说明通道没被重新打开。确认CubeMX里RX DMA模式是Circular,空闲中断里__HAL_DMA_ENABLE这行没有漏掉。

5.2 接收到的数据长度比实际发送的多或者少

长度不对,先不要怀疑DMA配置,大概率是空闲中断的时机出了问题。长度偏大的常见场景是:DMA缓冲区里残留了上一次的数据,而这次计算的rx_len包含了残留部分。这通常是因为rx_last_cnt没有被正确更新,导致两次的差值包含了旧数据。另一个常见原因是CNDTR读取的顺序不对——如果先重新使能了DMA再去读CNDTR,新数据已经有部分被搬走,读到的值偏小,长度偏大。严格遵守先关通道、再读计数、重设计数、再开通道的顺序,基本能解决。

长度偏小则更隐蔽,可能是DMA通道的优先级设置太低。当DMA1还有其他外设请求在跑时,比如ADC扫描或SPI传输,USART1_RX的DMA请求被更高优先级的通道抢占了总线,导致某几个字节没有被及时搬走,被新的字节覆盖或造成数据错位。F103的DMA仲裁是每传输一个数据单元进行一次仲裁,不起眼的数据冲突可能只导致一个字节丢失,接收长度自然就少了。排查方案是打开DMA的寄存器观察窗口,多次传输后检查CNDTR的变化序列,如果发现计数跳变不连续,考虑提高USART1_RX的DMA通道优先级。

5.3 Keil调试中如何验证DMA计数器的准确性

在Keil的Watch窗口添加表达式(uint16_t)hdma_usart1_rx.Instance->CNDTR,可以在每次中断触发时观察当前值。更实用的是设置一个条件断点,条件为CNDTR < 100,当DMA剩余计数小于100时触发暂停,说明数据量已经接近缓冲区边界。我在调试时还习惯开一个数组监视窗口,直接查看rx_buffer的后几十个字节,对比串口助手里实际发送的帧内容,肉眼确认最后几个字节是否对齐。如果偏移了一个字节,检查是不是起始位的采样时序偏差导致。实际项目中如果线路干扰严重,最直接的办法是降波特率到9600或4800,先确认逻辑正确,再逐步升速。

5.4 空闲中断误触发导致数据被拆包的解决

前面提过,如果在空闲中断处理过程中新数据到达,可能会触发第二次空闲中断,导致同一帧数据被拆成两半。解决思路是在空闲中断处理期间暂时屏蔽空闲中断,处理完再打开。但要注意屏蔽时机,应该在读取CNDTR之前就清掉IDLE标志并同时关闭空闲中断使能,防止处理过程中再进一次中断。代码实现上加两行:

__HAL_UART_DISABLE_IT(&huart1, UART_IT_IDLE); /* 关闭空闲中断 */ /* 处理DMA计数、保存长度、重装CNDTR */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); /* 重新开启空闲中断 */

这个技巧在高速率大数据量传输时几乎是必需的,只在低速低帧率调试时可以省略。

6. 性能实测与资源占用分析

6.1 不同波特率下的实测表现

我基于手头一块STM32F103C8T6的核心板,用另一块板子作为发送端,分别测试了115200、460800和921600三种波特率下的接收表现。发送端每帧发64字节,帧间隔50ms,连续发500帧。用Keil的Performance Analyzer观察USART1_IRQHandler的平均执行时间,结果如下:

波特率单帧DMA搬运时间空闲中断处理时间每500帧丢帧数CPU有效负载节省
115200约5.5ms<2us0中断次数减少95%
460800约1.4ms<2us0中断次数减少87%
921600约0.7ms<2us0中断次数减少70%

注意这里的“DMA搬运时间”只是从数据第一个字节进入USART到最后一个字节搬入缓冲区的物理时间,CPU在这个时间段内完全不需要参与。相比传统逐字节中断方案,中断次数从每字节一次降为每帧一次。以115200波特率、每帧64字节为例,传统方案CPU每秒要处理约1800次中断,DMA方案只需要约20次空闲中断。CPU的负载节省非常可观。

6.2 RAM和Flash资源占用

在C8T6上实测,这套方案的的RAM开销主要是rx_buffer数组,256字节,加上DMA句柄、HAL库基础代码,总共大约占用2.6KB RAM,Flash占用大约16KB。对C8T6的20KB RAM和64KB Flash来说绰绰有余。如果你用更小的F103C6(10KB RAM、32KB Flash),只要把rx_buffer压缩到128字节,照样跑得起来。

有一点资源上的坑还是要提:F103的DMA1和DMA2共用一个AHB总线仲裁器,如果DMA1的所有通道都在频繁搬运数据,总线带宽会紧张。实际项目中尽量避免同时开启多个高频率DMA外设,比如USB和串口同时跑DMA,一旦USB活跃度高,串口DMA可能会感受到明显的延迟。这属于芯片架构的物理限制,只能做任务调度规避,没法通过软件完全消除。

7. 项目扩展思路

这套DMA收发框架本身是通用的,把USART1换成USART2或USART3,对应修改DMA通道和中断函数即可。更进一步,F103的SPI和I2C也可以挂DMA收发,把接收缓冲区的DMA计数和空闲判断逻辑稍微改改,就能复用到SPI从机接收不定长数据的场景。比如AD7606这类并行/SPI接口的高速ADC采集,也常用DMA把数据连续搬到数组里供上位机读取。

另一个值得尝试的扩展方向是结合定时器输入捕获做数据帧的“超时”判断,在空闲中断不可用或者需要更精确帧定界时,用定时器实现更可靠的帧超时定时器,进一步完善这套不定长接收方案的边界能力。不过,如果你刚把文章里的代码调通,我建议先拿它跑通一个LED控制指令协议,让上位机发一串指令控制开发板上的LED状态翻转,等把DMA收发和解析流程都吃透了,再去碰更复杂的协议栈。这个项目做下来,你对F103的DMA模型、中断优先级、外设时钟树上任何一个环节的理解都会比看十篇教程深入得多。