news 2026/10/4 20:08:59

STM32 DMA+空闲中断实现串口不定长接收的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 DMA+空闲中断实现串口不定长接收的完整指南

1. 为什么要用DMA+空闲中断做不定长接收

1.1 传统接收方式的瓶颈

写这篇东西的起因是最近调试一个串口通信模块,数据帧长度不确定,短的时候十几个字节,长的时候几百个字节,而且上位机下发频率还挺高。用传统的中断逐字节接收,要么丢数据,要么把CPU累得够呛;后来改用DMA接收,配合USART的空闲中断来判断一帧数据的结束,才算真正解放出来。这篇文章就把这套方案从原理到代码、从踩坑到测试记录完整拆一遍,希望能帮正在调串口的兄弟少走点弯路。

先说传统的接收方式。很多新手一开始用的是单字节中断,也就是在USART中断里,每次收到一个字节就往数组里塞,同时用一个变量计数。这种方式在小数据量、低波特率下完全能用,但有两个明显的坑。第一,波特率上去之后,比如921600,一个字节大概10us,如果中断服务程序里做的事情稍微多一点,比如做个CRC校验、写个全局状态、拷贝个数组,很容易在下一个字节到来之前还没退出中断,这时硬件接收寄存器里的数据就被覆盖了,也就是丢字节。第二,CPU一直在被频繁打断,处理一个几百字节的长帧,中断可能要触发几百次,哪怕是空的串口中断也能把系统时序搅乱,实时性根本没法保证。所以数据量一大,单字节中断这条路基本走不通。

还有一种做法是用定时器超时来判断帧结束,比如每个字节进中断时重装定时器,超时则认为一帧结束。这种方式确实能解决不定长的问题,但代价是每个字节都要操作定时器,而且字节间隔稍长一点就会出现误判,尤其在波特率不高、发送端有延迟的时候,调起来很烦。我当时也试过,最后还是放弃了。真正靠谱的方案就是DMA搬运数据,让外设直接把字节写进内存,CPU完全不用管单个字节,只在DMA搬运完成或总线空闲的时候做一次统一处理,也就是标题里说的DMA收发加空闲中断不定长接收。

1.2 DMA加空闲中断的思路核心

这套方案的核心思路其实很简单:接收的时候,让USART的接收DMA把数据源源不断地写进一片缓冲区,DMA计数器会随着字节到来自动减少,当一帧数据发完之后,总线上会出现一个字节时间的空闲电平,USART就会触发IDLE空闲中断。在这个中断里,我们只要读出DMA当前还剩多少字节没搬,用缓冲区总长度减去剩余计数,就知道这一帧实际收了多少字节,然后拷贝出来处理即可。这样就做到了不定长接收,而且无论帧长多大,中断只在帧结束的时候触发一次,CPU的压力极小。

发送方向也一样,把要发的内容放到一个数组里,配好DMA通道,设置好目的地址为USART的数据寄存器,长度指定为数组长度,然后触发一次DMA传输,USART的发送硬件会自动把每个字节挪到移位寄存器发出去,CPU也不用参与。DMA发送的好处是发送大批量数据时不会阻塞主循环,坏处是发送一个比较短的命令时,DMA初始化那些寄存器代码量相对大,所以实际项目中我一般根据报文长短决定用DMA还是直接轮询,这后面会细说。

这套组合之所以经典,还有一个重要原因:STM32F103这颗芯片本身DMA控制器不多,通道数也有限,但USART1的收发DMA通道是独立分开的,分别是DMA1的通道4(发送)和通道5(接收),配置起来非常清晰。配合空闲中断之后,既能把数据搬移任务卸给DMA,又能用硬件自动判定帧边界,整体的软件结构会非常干净。下面开始说原理和配置。

2. 几个必须理解透的原理

2.1 USART的发送与接收机制

STM32F103的USART是一个通用同步异步收发器,核心是两个移位寄存器和一个数据寄存器DR。发送数据时,你往DR写一个字节,就好比把行李放上传送带,硬件会自动把它挪到发送移位寄存器,然后按波特率一位一位地送到TX引脚;接收数据时,RX引脚上的电平按波特率采样,凑齐一个字节就放到接收移位寄存器,再挪到DR,同时置起RXNE接收寄存器非空标志。如果我们开启了RXNE中断,那每个字节到DR都会触发一次中断,这就是传统中断接收的源头。

这里要注意,DR寄存器在物理上是两个寄存器,一个是发送DR,一个是接收DR,只是共用了同一个地址。读取DR会清掉RXNE标志,写入DR会清掉TC标志。所以很多人刚学的时候,发现读一下接收数据顺便就把标志清了,其实这是硬件设计的巧妙之处。对于DMA来说,它做的事情就相当于自动读DR和自动写DR,不需要CPU去碰那个地址。

2.2 DMA传输的本质

DMA的全称是Direct Memory Access,翻译过来就是直接存储器访问。它的本质就是在外设和内存之间搭一条独立于CPU的高速公路,由一个控制器管理搬运工作。CPU只需要告诉DMA三件事:源地址在哪、目的地址在哪、要搬多少。设定好之后,DMA会自己一条一条地搬,搬完或者达到指定数量后可以产生中断通知CPU。外设和内存之间的搬运,与CPU运算可以并行进行,这是它最大的价值。

使用DMA时,有几个关键参数必须理解清楚,不然配置一定会出问题。第一个是传输方向,对于USART接收,数据方向是外设到内存,外设地址固定,内存地址递增;发送则反过来。第二个是循环模式,对应DMA_CircleMode,开启后,DMA计数器减到0时不会停,而是自动重新装回初始值,继续接收。这个模式对于串口不定长接收来说几乎必选,因为只有这样,DMA才能一直在后台待命,接收任意时刻到来的字节。第三个是数据宽度,串口是8位数据,所以内存和外设都配成字节宽度,如果是16位或者32位数据则要对应修改,但注意外设地址的宽度要和实际寄存器一致。

还需要提一点,DMA每次传输完成可以触发传输完成中断,即TCIF标志。但我们在空闲中断方案里不太依赖DMA的传输完成中断,因为缓冲区的容量设置得比最大帧长要大,正常情况下DMA不会传输完成,只有数据填满了整个缓冲区才会触发。这也说明缓冲区容量的选择非常关键,后面会专门讲。

2.3 空闲中断的触发条件

空闲中断,英文叫IDLE interrupt,是USART硬件提供的一个非常有用的中断源。它的触发条件是:在接收完一个字节后,总线保持高电平(即空闲状态)超过一个完整的字节时间。举个例子,如果波特率是115200,一个字节在总线上的时间是10.4us左右,那么接收完某个字节后,只要线路上持续高电平超过这个时间,USART就会认为总线空闲,置起IDLE标志。

这个特性天生就适合用来做不定长帧的边界判定,因为绝大多数串口协议里,一帧数据内字节间是连续发送的,字节间隔远小于一个字节时间,而帧与帧之间总会留一点间隔。即使帧间隔很紧凑,只要硬件上出现一个完整的空闲位,IDLE就能捕捉到。这里有个细节需要注意:IDLE标志是在接收过程中检测到空闲才置位的,如果一直没数据,它反而不会触发,所以不用担心空闲状态下疯狂触发中断。

清IDLE标志的方法在STM32F103上有个坑,和清零RXNE类似,需要先读USART_SR寄存器,再读USART_DR寄存器,顺序不能反。很多人直接用USART_GetITStatus判断,然后忘了读DR,结果IDLE标志一直不消失,导致中断反复进入,这是典型的新手问题。我在后面代码里会专门放一个清除操作,实测有效。

3. 工程配置全过程

3.1 硬件与开发环境

我做这套实验用的是最普通的STM32F103C8T6最小系统板,8MHz晶振,通过内部PLL倍频到72MHz。调试器是ST-Link V2,用SWD四线方式连接,SWDIO、SWCLK、GND、3V3这四根线就够了。串口用的是USART1,也就是PA9(TX)和PA10(RX),通过USB转串口模块连电脑。如果你用的是其他型号,原理一样,只要注意DMA通道的映射关系就行。

工程环境我用的是Keil MDK 5,配合STM32标准外设库(StdPeriph SPL)来开发。现在很多人喜欢用CubeMX和HAL库,但F103这颗老芯片用标准库写DMA和中断的寄存器逻辑更直观,也更容易理解底层。如果你是用CubeMX自动生成的代码,思路完全一致,只要在生成的代码基础上把空闲中断处理和DMA计数器逻辑加上就行。开发工具版本方面,我用的是Keil MDK 5.27,编译器AC5,标准库版本3.5。

3.2 GPIO和USART初始化代码

首先初始化GPIO,把PA9推挽复用输出作为TX,PA10浮空输入或者带上拉输入作为RX。这里有一个细节:STM32F103的USART引脚必须配置为复用推挽输出(AF_PP),而RX则可以浮空输入,因为RX默认是空闲高电平,浮空没问题,但如果你怕干扰,外部加一个10k上拉更稳。

然后是USART初始化,波特率根据实际需求来,我测试时用的115200,8位数据,无校验,1位停止位。一定要开启USART的接收DMA请求和发送DMA请求,否则就算DMA配好了也不会工作。开启的方式是调用USART_DMACmd,一个传USART_DMAReq_RX,一个传USART_DMAReq_TX。这个开关很多人会漏掉,漏掉之后的现象就是DMA始终不搬运数据,计数器不动,百思不得其解。

需要提醒的是,开启USART接收缓冲区非空中断和空闲中断时,要把空闲中断使能:USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)。这个中断在NVIC里属于USART1全局中断,也就是IRQHandler,所以还要把NVIC配置好。具体的GPIO、USART、DMA初始化代码如下,我直接把工程里能跑的代码贴出来,变量定义也一并给出。

#define RX_BUF_SIZE 256 uint8_t usart1_rx_buf[RX_BUF_SIZE]; volatile uint8_t usart1_rx_last_pos = 0; // 上一次帧结束位置 volatile uint16_t usart1_rx_frame_len = 0; // 当前帧长度 volatile uint16_t usart1_rx_frame_start = 0; // 当前帧起始位置 volatile uint8_t usart1_rx_wrap_flag = 0; // 回绕标志 volatile uint8_t usart1_rx_frame_flag = 0; // 帧接收完成标志 void USART1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); } void USART1_Init(void) { USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); // 使能IDLE中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }

这里我故意把NVIC优先级设为抢占优先级1、子优先级1,具体数值根据你的工程来,但建议串口中断优先级不要设得太低,因为你还要在中断里做DMA搬运数据的处理,处理不及时容易造成下一个字节覆盖的风险。

3.3 DMA接收通道配置

接下来是接收DMA的初始化。USART1_RX对应的是DMA1的通道5,外设地址是USART1的数据寄存器地址,内存地址是我们定义好的usart1_rx_buf数组,内存地址方向是递增。传输模式必须配成循环模式,这一点前面已经解释过。数据宽度8位。优先级可以设为High,因为串口接收不想被其他DMA抢占。

需要特别提醒的是,DMA配置完成后要调用DMA_Cmd使能通道,然后调用USART_DMACmd使能USART的接收DMA请求。两个使能一个都不能少。如果只使能了DMA_Cmd,USART不会主动把数据往DMA里送;如果只使能了USART_DMACmd,DMA通道本身是关闭的,也不会有数据搬运。这些都是我实际调试中踩过的坑。

DMA_InitTypeDef DMA_InitStructure; void USART1_DMA_RX_Init(void) { RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)(&USART1->DR); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)usart1_rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); }

这段代码配置好之后,只要USART串口有数据进来,DMA就会自动搬运到usart1_rx_buf,CPU完全不用管。我们可以通过读取DMA_GetCurrDataCounter(DMA1_Channel5)来知道缓冲区还剩多少个空位,用RX_BUF_SIZE减去这个值就是当前已经接收的字节数。这个逻辑在空闲中断里会用到。

3.4 DMA发送通道配置

发送DMA用DMA1的通道4,数据方向是内存到外设。外设地址同样是USART1->DR,内存地址是你要发送的缓冲区地址。发送DMA不需要循环模式,用普通模式就行,因为每次发完就停,发送完成之后最好产生一个传输完成中断,用于清理状态。如果不关心发送完成的时刻,也可以轮询DMA的传输完成标志。我这里用了一个简单的全局变量来保存当前发送缓冲区的地址和长度,方便发送函数使用。

uint8_t usart1_tx_buf[128]; void USART1_DMA_TX_Init(void) { RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)(&USART1->DR); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)usart1_tx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 0; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel4, &DMA_InitStructure); DMA_Cmd(DMA1_Channel4, DISABLE); USART_DMACmd(USART1, USART_DMAReq_TX, ENABLE); } void USART1_DMA_SendBytes(uint8_t *buf, uint16_t len) { // 等待上一次发送完成 while (DMA_GetFlagStatus(DMA1_FLAG_TC4) == RESET); DMA_Cmd(DMA1_Channel4, DISABLE); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)buf; DMA_InitStructure.DMA_BufferSize = len; DMA_Init(DMA1_Channel4, &DMA_InitStructure); DMA_Cmd(DMA1_Channel4, ENABLE); }

这里需要注意,我复用了全局的DMA_InitStructure,其实工程里每个初始化函数都有自己的局部结构体,发送函数这里最好也单独声明一份,避免变量冲突。实际项目里我通常会做成一个结构体封装,把DMA通道号、完成标志、缓冲区都包进去。

4. 核心中断处理与帧解析

4.1 空闲中断里的接收逻辑

串口中断服务函数是整个方案的大脑。在这个函数里面,我们需要先判断是RXNE中断还是IDLE中断。RXNE中断可以开着,但实际处理可以很简单,甚至只在必要的时候处理。我的做法是:判断IDLE标志,如果置位,说明当前有一帧数据已经接收完,接下来计算这一帧的长度,置一个帧接收完成标志;然后清除IDLE标志。RXNE中断在这里就不做处理,因为DMA已经把数据拿走了。

计算长度的方法是这样的。由于接收DMA是循环模式,DMA的计数器从256开始递减,每接收一个字节减1。帧结束的时候,我们读一次DMA_GetCurrDataCounter(DMA1_Channel5),用256减去这个值,得到一个当前总共已经收到的字节数pos。然后还要记录上一次帧结束时的位置last_pos。如果pos大于last_pos,说明这一帧的数据在缓冲区里是连续的,长度就是pos - last_pos;如果pos小于last_pos,说明缓冲区发生了回绕,这一帧数据被分成了两段,需要分开处理。

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 先清除IDLE标志:读SR再读DR USART_ReceiveData(USART1); uint16_t cur_pos = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 检查是否有新数据 if (cur_pos == usart1_rx_last_pos) { return; } if (cur_pos > usart1_rx_last_pos) { usart1_rx_frame_start = usart1_rx_last_pos; usart1_rx_frame_len = cur_pos - usart1_rx_last_pos; usart1_rx_wrap_flag = 0; } else { // 回绕,数据分为两段 usart1_rx_frame_start = usart1_rx_last_pos; usart1_rx_frame_len = RX_BUF_SIZE - usart1_rx_last_pos + cur_pos; usart1_rx_wrap_flag = 1; } usart1_rx_last_pos = cur_pos; usart1_rx_frame_flag = 1; } }

但严格来说,如果数据出现回绕,我们必须要先把第一段数据拷贝出来,再拼接第二段,主循环需要知道起始地址和两段长度。上面代码只是为了说明主思路,实际工程里我会维护一个帧起始索引和wrap标志,然后在主循环里做处理,确保不会丢数据。这里还要注意,如果cur_pos恰好等于last_pos,说明这一帧没有新数据,可能是空闲中断误触发,可以直接忽略,不置标志位。

4.2 接收帧数据在主循环中的处理

帧接收完成标志置位后,主循环就可以检测到标志并处理。处理时要注意一个问题:要从缓冲区里把数据拷贝出来,否则下一次接收会覆盖。特别是当缓冲区出现回绕时,必须分两段拷贝。拷贝完数据后再决定怎么解析,比如判断帧头帧尾、校验和、地址码等。下面给出一个通用的处理示范。

uint8_t frame_cache[256]; void ProcessReceivedFrame(void) { if (usart1_rx_frame_flag == 0) return; usart1_rx_frame_flag = 0; if (usart1_rx_frame_len == 0) return; if (usart1_rx_wrap_flag == 0) { memcpy(frame_cache, &usart1_rx_buf[usart1_rx_frame_start], usart1_rx_frame_len); ParseFrame(frame_cache, usart1_rx_frame_len); } else { uint16_t first_len = RX_BUF_SIZE - usart1_rx_frame_start; uint16_t second_len = usart1_rx_frame_len - first_len; memcpy(frame_cache, &usart1_rx_buf[usart1_rx_frame_start], first_len); memcpy(&frame_cache[first_len], usart1_rx_buf, second_len); ParseFrame(frame_cache, usart1_rx_frame_len); usart1_rx_wrap_flag = 0; } }

这个处理虽然简单,但已经能应付大部分不定长串口协议了。实际项目里如果有更复杂的协议,比如多个帧连续到来,我建议在空闲中断里只做“记录位置和长度”,主循环里用环形缓冲管理,不要一帧一帧地拷贝,效率会更高。不过对于大多数MCU应用,把帧拷贝到临时缓存再解析,逻辑简单且不容易出错,完全够用。

4.3 DMA发送中断和状态管理

DMA发送通道配好之后,发送完成会产生传输完成标志TCIF。如果想在发送完成的时候做点什么,比如置一个标志给主循环,可以开启DMA的传输完成中断。开启方法是在DMA_Init之前调用DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, ENABLE),然后在DMA1_Channel4_IRQHandler中断服务函数里判断DMA_GetITStatus(DMA1_IT_TC4)。

我这里建议,如果不是特别在乎发送完成的精确时刻,干脆不用发送完成中断,直接用轮询标志。因为USART1的DMA发送速度非常快,一个几百字节的帧在115200波特率下也不过几十毫秒,轮询并不会卡太久。如果发送缓冲区很大,或者波特率很低,再考虑用中断方式,避免长时间阻塞主循环。用了中断之后,你还需要在中断里清掉TCIF标志,并且注意不要在DMA传输过程中关闭通道,否则会产生错误。

状态管理方面,核心是确保发送函数不会同时被主循环和中断调用。最简单的方法是一个全局发送忙标志,发送函数入口判断如果正在发送就直接返回或者等待,发送完成中断里清标志。对于裸机程序来说,这个标志位已经足够可靠了。

4.4 如何测试和验证

写完代码后,我是用电脑上的串口助手轮询发送不同长度数据来测试的。测试要覆盖三种情况:固定短帧(比如5字节)、长帧(比如接近缓冲区大小的200字节)、连续快速多帧,中间不带人工延时。最好的办法是写一个简单的上位机脚本或者用串口助手的定时发送功能,每100ms发一帧不同长度的数据,然后看单片机解析结果和回显的数据长度是否一致。

回显测试很有用。你可以让单片机收到一帧数据后,把这一帧的长度和内容原样发回电脑,电脑端对比发送内容和返回内容是否完全一致。我当时就是这么测的,发现了一个非常隐蔽的bug,是回绕处理时拼接顺序反了,导致数据错位。后来我把回绕分支单独打印出来,才找到原因。所以测试时一定不要只测正常情况,要把缓冲区回绕、短帧连续快速到来这些边界情况都测一遍。反正串口助手点几下就能复现,总比产品上线后出问题强。

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

5.1 DMA接收数据错位或丢失

表现:收到的数据总是少几个字节,或者数据内容整体偏移。

原因大概率是DMA配置里缓冲区大小和实际帧长不匹配,或者DMA没有配成循环模式。如果你用的是普通模式,DMA传输完设定的长度后会自动停止,此时再来数据就不会被搬运,空闲中断也可能误判,导致丢数据。另外,如果USART_DMACmd没有使能接收请求,或者DMA_Cmd没有使能通道,DMA根本不会搬运,数据会留在DR寄存器里,接着被下一个字节覆盖,表现也是丢数据。

排查方法很简单:在空闲中断里读DMA计数器的值,看它是否正常递减。如果计数器不动,先检查USART_DMACmd有没有开,再检查DMA控制寄存器里的EN位是不是1。如果计数器在动但数据错位,就要怀疑循环模式或者地址递增配置了。可以用调试器在内存窗口里直接看usart1_rx_buf,对比串口发的内容,非常直观。

5.2 空闲中断误触发或反复进入

表现:没有发数据,但是程序频繁进入USART1_IRQHandler。

出现这个问题,十有八九是IDLE标志没有清除干净。前面强调过,清IDLE标志必须读SR寄存器再读DR寄存器。如果你用了USART_GetITStatus(USART1, USART_IT_IDLE)判断,但忘记调用USART_ReceiveData(USART1)去读DR,IDLE标志会一直为1,中断就会一直进。还有一种情况是RX引脚悬空,导致总线电平不稳定,误判空闲。解决方法是给RX引脚加上拉电阻,或者至少配置为带上拉的输入模式。

还有一种误触发比较隐蔽:当DMA缓冲区满了,DMA循环模式会自动从头开始覆盖数据,但此时如果你正在处理上一帧数据,缓冲区内容已经被新数据覆盖,而空闲中断并不会因为这个覆盖而触发。这种情况下你可能会觉得数据“反复进入”,但实际上是因为缓冲区太小,覆盖得太快。解决方法是增大缓冲区,或者加快主循环处理速度,必要的时候开启DMA传输完成中断进行保护。

5.3 DMA发送卡死或发不完

表现:调用发送函数后,串口完全没有数据发出来,或者只发出一部分。

先检查DMA通道对不对,USART1_TX是DMA1_Channel4。如果初始化DMA用的通道5,接收通道也被占了,就会冲突,现象就是发送完全无反应。再检查有没有开启USART的发送DMA请求,也就是USART_DMACmd(USART1, USART_DMAReq_TX, ENABLE)。最后检查发送函数里DMA_Cmd之前有没有赋值正确的BufferSize。如果BufferSize为0,DMA不会传输任何东西。

很多时候“只发一部分”是因为DMA传输过程中你调用了第二次发送,此时DMA还在忙,直接改寄存器导致波形乱掉。解决办法是在发送函数入口等上一次传输完成。如果发送频率非常快,建议用中断方式并且加上环形发送缓冲,不要在阻塞等待上浪费时间。

5.4 缓冲区大小怎么选

这是很多人忽视的问题。接收缓冲区的大小,必须大于一帧数据的最大可能长度,否则缓冲区满了会覆盖数据。对于一般modbus、AT指令、自定义协议,256字节通常够用。如果你的协议允许单帧数据达到512字节甚至1KB,缓冲区就要相应扩大到1KB或2KB。注意,STM32F103C8T6的RAM只有20KB,如果缓冲区设得太大,会挤占其他变量空间,所以要根据实际需求权衡。

建议的做法是,先统计实际项目的最大帧长,加上一定余量,比如最大帧长是200字节,那么缓冲区设置256或者512比较合适。同时,主循环一定要把数据处理完再等下一次帧到达,不要让处理时间超过帧间隔,否则缓冲区还是会被覆盖。还可以考虑使用双缓冲区,一个用于DMA接收,一个用于主循环处理,交替使用,这样即使处理时间稍长也不会丢数据。但这个方案比较复杂,一般在数据量比较大的时候才需要上。

5.5 实测中的其他坑

除了以上几个典型问题,还有一些零碎但很影响体验的坑。比如,在USART1_IRQHandler里千万不要做耗时的操作,比如调用printf、长时间循环、甚至软件延时。中断里只做标志置位和简单的长度计算,所有协议解析一律放到主循环。否则波特率一高,中断响应不及时,轻则丢字节,重则整个系统卡死。

还有一个是DMA引脚的波特率问题。如果你用的是STM32F103C8T6,输出DMA时钟频率不能超过36MHz,内部已经自动处理,不需要额外配置。但如果你用其他系列,比如F407或者MSPM0,DMA时钟、外设时钟的关系不同,最好仔细对照参考手册,不要盲目照抄F103的代码。

另外,如果你后面对接的是GD32、AT32或者HC32这些国产替代芯片,它们的USART寄存器大部分兼容STM32的,但DMA通道映射和中断向量可能会变,比如GD32的DMA通道分配虽然基本兼容,但AT32的DMA控制字格式有区别,HC32则完全是另一套架构。所以移植的时候一定要以目标芯片的用户手册为准,至少要确认DMA通道号和寄存器位定义。我身边就有同事从F103移植到AT32时,DMA初始化照抄,结果通道号对不上,折腾了半天。这个经验供大家参考。

6. 使用心得与扩展建议

6.1 性能实测对比

我实际测试了一下,在115200波特率下,用传统的单字节中断接收1000字节数据,CPU几乎全程都在处理中断,偶尔还会因为不小心关闭中断而丢字节。而换成DMA加空闲中断之后,CPU在接收过程中基本是零负担,只有在每帧结束的时候才进入一次中断做几行运算,主循环完全不受影响。如果一帧是100字节,那么接收一帧数据,CPU中断运行时间总长大概只有几微秒,相对于逐字节中断的功耗和资源占用差距非常明显。

发送方向也一样,用DMA发送1KB数据,发送期间CPU可以继续做别的任务,比如跑屏幕显示、传感器采集,串口发送完全后台完成。如果数据量再大一些,比如通过串口做固件升级或者日志传输,这个优势就更明显了。所以这套方案不仅仅是“能跑”,而是真正能把串口这件事从CPU的任务里剥离出去。

6.2 扩展方向

这套代码后续可以扩展的方向很多。首先是可以把它改造成一个通用的串口驱动,把USART1换成USART2、USART3,不同的DMA通道和中断向量都要对应修改。我建议把这些配置集中到一个结构体里,用宏定义或者函数指针把串口号、DMA通道、缓冲区地址都抽象出来,以后接多个外设就不用写好几遍类似的初始化代码了。

第二个扩展方向是加一个环形缓冲区,在空闲中断里只记录位置和长度,主循环通过环形缓冲区接口取数据。这样即使主循环处理慢了,数据也不容易丢,可以扛住更高速率的连续收发。第三种扩展是把DMA接收和协议解析结合起来,比如modbus的RTU模式,它本身就是以3.5个字符时间作为帧间隔,这正好可以完全替代定时器超时判断,移植起来非常顺。

还有一个我一直想推荐的做法:把接收缓冲区做成“半满中断+全满中断”的双缓冲模式。不过这需要关闭循环模式,改用普通模式,并且在半满和全满中断里切换缓冲区,复杂度会高不少。如果你的串口数据流量极大,这种方法可以保证DMA永远不休眠,CPU永远有最新数据处理,是工业级串口方案的标准做法。但如果你和我一样只是做做设备联调,上面这套简单方案已经绰绰有余了。

6.3 最后的一点经验

算了一下,从第一次配置DMA到彻底稳定运行,我前后大概花了三天,其中大部分时间都消耗在“为什么DMA计数器不动”和“为什么IDLE标志清不掉”这两个问题上。回头看,其实都是最基础的细节,但正因为原理没吃透,才走了那么多弯路。如果你也是刚接触这块,我的建议是:先别急着抄代码,用串口助手加调试器把DMA计数器、IDLE标志的行为摸清楚,再动手写业务逻辑,你会发现自己写得比想象中顺畅得多。

另外想提醒的是,STM32F103虽然老,但在很多低成本产品里依然是绝对的出货主力。它的DMA和USART配合得好不好,直接决定一个设备的通信稳定性。这套DMA加空闲中断的方案,不仅仅是省CPU资源,更是在为后续更复杂的项目打基础。你把这一套吃透了,后续再用HAL库、再换其他芯片,思路都是通的。

最后送给大家一个小技巧:调试串口DMA的时候,强烈建议在串口助手里开启hex显示,并且打开时间戳功能。这样你能看到每一帧数据到达的具体间隔,判断IDLE中断的触发是否和你预期一致。有了这个工具,很多玄学问题都能变成看得见的数据,排查起来会快很多。希望这篇实战记录能帮你少踩几个坑。

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

深入理解 ABAP CDS Table Entity 的核心属性,从主键、Client、NULL 到 Delivery Class

在今天的 ABAP Cloud 和 RAP 开发里,Table Entity 已经不只是一个用 CDS DDL 换种写法定义数据库表的语法糖。SAP 对它的定位非常明确,CDS Table Entity 负责描述 SAP HANA 上真正存在的物理数据库表,同时又把传统 DDIC 表定义里的很多技术属性重新纳入 CDS 数据模型体系。 …

作者头像 李华
网站建设 2026/10/4 20:00:40

低功耗语音芯片长续航横评:四类场景待机电流与唤醒功耗实测

你们有没有算过,一个100mAh的纽扣电池,能供一颗始终在听唤醒词的低功耗语音芯片跑多久?我拿着这个问题问了几家方案原厂,得到的答案从“至少一年”到“看你怎么定义一年”都有。2026年做产品,低功耗语音芯片早就不是能…

作者头像 李华
网站建设 2026/10/4 20:00:12

ESP32-P4加ESP32-C5双芯驱动:打造屏即是网关的智能中控屏

在做这块家庭智能中控屏之前,我对网关的理解还停留在弱电箱里那个小盒子——一根网线插进去,几根天线伸出来,剩下的就是配置页里一堆看不懂的专业术语。直到有一天我决定把"屏"和"网关"做进同一个产品里,才发…

作者头像 李华
网站建设 2026/10/4 19:59:04

YALMIP建模语言原理与SDPT3求解器集成实战

1. YALMIP不是插件,是建模语言——它到底解决什么问题?YALMIP 是 MATLAB 生态里最被低估、也最容易被误用的优化建模工具之一。很多人第一次听说它,是在“Matlab 优化工具箱不够用”“想调用 SDPT3 但不会写原始接口”“CVX 写着写着报错太多…

作者头像 李华
网站建设 2026/10/4 19:57:11

九月AI最热主题盘点:Agent接管行动权,安全成主线

2026年9月,AI圈的热度不在生成式内容。视频生成、机器人、语音这些过去抢头条的方向,在这个月几乎集体失声。真正被反复翻炒的,是一条工程主线:Agent(智能体)从"被问问题的工具"变成"被授权…

作者头像 李华