☰
STM32串口高效接收:DMA+空闲中断+环形缓冲区实战
2026/10/28 9:18:29 网站建设 项目流程

1. 先说结论:串口接收的核心矛盾是什么?

做嵌入式开发,跟串口打交道是第一天就要面对的基础问题。但要说把串口接收“接明白了”,我见过不少做了两三年的朋友,依然在低速率、小数据量的场景里轮询收数据,换到高波特率或者不定长帧协议时就丢包、卡死、状态错乱。

先给“高效接收”定个明确的标准:接收不丢数据、不反复打断CPU、不阻塞业务逻辑。三者缺一不可。很多人以为串口接收就是把收进来的数据放进数组,然后慢慢处理,其实这个理解就错了。串口只是一个外设,它每秒能产生的数据量相对CPU来说是极其有限的——哪怕跑到115200波特率,一帧11位,每秒也不过1万字节出头,而一颗主频72MHz的STM32F103,每秒能执行几千万条指令。真正的瓶颈从来不是“搬这点数据要花多少时间”,而是“CPU为了搬这点数据被中断打断多少次、被阻塞等待多久”。

所以串口接收这个事,本质上是把一个慢速数据流,用尽量少的CPU干预,转移到一个能被业务逻辑随时消费的内存区域里。理解了这句话,你再看下面所有的方案选型和代码设计,就都能串起来了。

适合阅读这篇文章的,是用STM32做项目、正在为串口数据接收不稳、丢数据、CPU被打满而头疼的开发者,尤其是从简单轮询往中断、DMA方案过渡的这批人。

2. 三种接收方式的全景对比与选型依据

2.1 轮询接收:简单,但代价是CPU空转

轮询接收就是主循环里一直读串口数据寄存器,有数据就收,没数据就继续等。最常见写法是HAL库的阻塞式接收:

uint8_t ch; HAL_UART_Receive(&huart1, &ch, 1, 100); // 阻塞等待一字节,超时100ms

这种方式最大的问题是“等”。假设你要等一帧10字节的数据,每字节间隔能到几十毫秒甚至上百毫秒,那你在这一帧期间CPU基本就挂在这个调用上了。哪怕你设置了超时时间,超时之后业务继续跑,一旦数据刚好在你去做其他事情的时候到齐了,你也不知道,必须重新进一次接收。

轮询适合什么场景呢?裸机小系统、串口只用来在初始化阶段收几条固定命令、或者压根不需要并发处理别的任务。反正数据量小、时序不敏感,拿最简单的写法换最低的维护成本。但一旦你的系统里同时还要跑按键检测、屏幕刷新、电机控制、传感器采集,轮询串口接收就是给自己埋雷——等串口的时候,其他事情全部被拖住。

这里面还有一个小坑:HAL_UART_Receive的第三个参数是接收长度,不是“总超时”。它内部是“接收指定长度的字节,直到全部收完或超时”。所以你要是传长度1、超时100ms,那这一轮可能等你10ms数据就来了,也可能是100ms后才来。你没法精确知道业务被阻塞了多久,这在实时系统里是很要命的事。

2.2 中断接收:中低速率下的主力方案

中断接收的思路是:串口每收到一字节,硬件触发一次UART中断,CPU在中断服务函数里把这字节搬走,存入自己管理的缓冲区,然后退出中断。主循环该干嘛干嘛,完全不用阻塞等待。

HAL库的标准用法:

HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 开启中断接收一字节 // 中断回调里会收到这一字节 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 把 rx_byte 存入自己的缓冲区 } }

重点来了:HAL库的Receive_IT接收完你指定的字节数之后,不会再自动开启下一次接收。你在回调里必须重新调用一次HAL_UART_Receive_IT,否则第二次串口数据来了不会产生中断,数据直接丢掉。这个“一字节一重入”的方式,是HAL库中断接收的经典写法,也是最容易踩坑的地方——很多人只开了一次中断,发现只收到第一个字节,后面的全没了。

中断接收的性能瓶颈也很清楚:每字节进一次中断。算笔账,115200波特率下,每字节间隔约87微秒,你的中断服务函数如果在这87微秒内干不完“读寄存器+存缓冲区+重开中断”这三件事,下一字节就会把当前数据覆盖掉。更别提系统里还有定时器中断、外部中断、DMA传输完成中断在跟你抢CPU。所以在115200以上、连续不断收长包的应用里,纯中断接收其实已经比较吃力了。

但中断接收仍然是我个人最推荐新手入门的方案,因为逻辑简单、不阻塞、排查容易。把速度控制在9600~57600之间,一次收几帧几十字节,中断接收完全够用。

2.3 DMA接收:把数据搬运交给硬件

DMA接收的核心逻辑是:串口收到数据后,由DMA控制器自动把数据从串口数据寄存器搬到内存缓冲区,整个过程不需要CPU参与。CPU只在DMA传输完成、或者串口产生特定事件时,被轻量地通知一次。

用DMA后,一颗主频72MHz的芯片接收115200波特率的连续数据流,CPU占用几乎为零。DMA传输完成中断在整个接收过程中只会触发一次(循环模式下是缓冲区满触发一次)。这对于需要把CPU留给业务逻辑、算法运算、屏幕刷新的系统来说,优势是决定性的。

但DMA有一个天然的问题:它搬数据是按固定长度搬的,它自己不知道“一帧数据到哪结束”。比如你让它搬200字节,结果对方发了一条35字节的指令,那DMA会傻傻地继续等,直到搬够200字节才通知你,到那时35字节的数据已经不知道被后续数据冲到哪去了。

所以要实现“高效接收不定长帧”,就不能只用DMA,得给DMA配一个“哨兵”——这就是串口的空闲中断(IDLE interrupt),后面专门讲。

2.4 三种接收方式选型速查表

方案CPU占用丢数据风险代码复杂度适用场景
轮询高,阻塞式低(但丢业务实时性)最低初始化指令、低速少量数据
中断中等,每字节打断一次高速率下可能丢低9600~57600中低速、帧长几十字节
DMA+空闲中断极低,近乎零干预低,但设计要正确中高115200以上、不定长帧、连续大数据流

一句话总结:数据量小、要求低的用中断;数据量大、要求高的直接上DMA+空闲中断;轮询能不用就不用了,除非你写的是那种一上电处理完就休眠的玩具程序。

3. 高效接收的完整链路:DMA + 空闲中断 + 环形缓冲区

3.1 硬件层面的核心:空闲中断(IDLE)到底怎么用

串口空闲中断不是一个字节中断,而是串口接收线在“接收完一个字节后”保持空闲(即高电平)时触发的一次中断。用一句话理解:空闲中断告诉你“线上的数据已经停下来了,你可以把刚才这段时间收到的数据一次性取走”。

这正好弥补了DMA不知道帧在哪结束的短板。DMA负责不停地把数据搬进缓冲区,空闲中断负责通知CPU“帧边界到了,快把缓冲区里的数据拿出来处理”。两者一配合,就是目前STM32串口接收最常见的“DMA + 空闲中断”方案。

具体触发逻辑是这样的:假如对方发来一帧数据,DMA在后台逐字节搬运,当这一个帧的所有字节都收完,线上不再有新数据到来时,串口硬件检测到空闲状态,置起IDLE标志位并触发中断。CPU在中断里读DMA还剩下多少字节没搬,用“缓冲区总长度 - DMA剩余计数”,就能精确算出来这一帧实际收到了多少字节。

关键点在于,DMA要工作在循环模式(Circular),这样DMA搬满整个缓冲区后不会停止,而是自动回到缓冲区开头继续搬,方便你持续接收。空闲中断触发后,你需要立刻读取这次帧的长度、处理数据,如果用户处理太慢,下次DMA写回缓冲区开头时,可能把还没处理完的老数据覆盖掉,这就需要在软件层面做缓冲和同步。

有人说“能不能用串口的接收超时中断(RTO)替代空闲中断”?RTO确实也可以,但RTO是HAL库比较新的功能,依赖硬件UART的接收超时检测,不同型号支持情况不同。IDLE中断是STM32全系老牌标配,兼容性最好,资料也最全。从工程角度我建议优先用IDLE。

3.2 数据缓冲层:为什么需要环形缓冲区

有了DMA和IDLE中断,数据从硬件到内存的问题解决了,但还有一个问题:中断服务函数里取到的帧数据,业务代码怎么拿?

最粗暴的做法是开一个全局数组,中断往数组里写,业务代码从数组里读。但这样一旦业务代码处理慢了一拍,中断里新来的数据就会把还没处理完的数据覆盖,造成丢帧。要解决这个问题,就得引入环形缓冲区。

环形缓冲区本质是一个固定大小的数组,维护两个指针:写指针(head)和读指针(tail)。中断服务函数只负责往head位置写数据,写完推进head;业务代码只负责从tail位置读数据,读完推进tail。当head追到tail,表示缓冲区满了;当tail追到head,表示缓冲区空了。

#define RING_BUFFER_SIZE 1024 typedef struct { uint8_t buffer[RING_BUFFER_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; void ring_buffer_write(ring_buffer_t *rb, uint8_t data) { uint16_t next_head = (rb->head + 1) % RING_BUFFER_SIZE; if (next_head != rb->tail) { // 不为满 rb->buffer[rb->head] = data; rb->head = next_head; } // 若满则丢弃,并记录溢出标志 } int ring_buffer_read(ring_buffer_t *rb, uint8_t *data) { if (rb->head == rb->tail) { return 0; // 为空 } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE; return 1; }

注意这里的head和tail加了volatile修饰,因为它们在中断和主循环两个上下文之间共享。单生产者单消费者场景下,环形缓冲区的实现不需要加锁,天生是安全的——写指针只被生产者(中断)改,读指针只被消费者(主循环)改,这是环形缓冲区相比全局数组最大的优势。

使用环形缓冲区的另一种常见架构,是两层缓冲:DMA直接搬到一个大的原始缓冲区,IDLE中断里把这一帧数据拷入环形缓冲区,业务代码再从环形缓冲区按帧取。为啥不直接让业务代码读DMA原始缓冲区?因为DMA的读写指针是硬件控制的,你在业务代码里同时要兼顾“当前帧起始位置”“帧长”“DMA是否又写回来了”这些信息,状态管理很容易出错。环形缓冲区把这些细节全部封装掉了,业务代码只管取,完全不用关心DMA现在写到哪了。

3.3 业务处理层:状态机解析不定长协议

串口数据进了环形缓冲区,接下来还有最后一步:把它还原成业务能识别的协议帧。很多新手在这一步犯糊涂,以为数据到了缓冲区就万事大吉,结果取数据时按固定字节数切帧,遇到粘包、半包、拆包就全乱了。

正确的做法是写一个接收状态机。假设你的协议格式是“帧头AA 55 + 长度LEN + 数据 + 校验”,状态机可以这么设计:

  • 状态0:等待帧头。读到AA进状态1,读到55进状态2,其他字节丢弃。
  • 状态1:等待第二个帧头。读到55进状态3,否则回到状态0。
  • 状态2:收到55,说明帧头确认,读取LEN。
  • 状态3:读取后续数据,计数字节数,够LEN后进状态4。
  • 状态4:读取校验字节,校验通过就是一帧完整数据,校验失败丢弃并回到状态0。

这个状态机的代码量不大,但是把“什么时候算一帧结束”这件事,从含糊的逻辑判断里彻底解放出来了。不管对方是连续发多帧,还是发送断断续续,状态机都能稳定地切分出一帧帧完整数据。

状态机的优势在于:它把协议解析从“数据到达的时序”中解耦出来。数据什么时候来、来多少,对状态机来说都是输入流,它只关心当前状态和下一个字节。这在嵌入式里是处理不定长串口协议最可靠、最容易排查的架构。

4. 实操:手把手配置DMA + 空闲中断接收

4.1 CubeMX中的初始化配置

用STM32CubeMX配置时,关键选项如下:

  • USART1模式选为Asynchronous,波特率按你的需要设置,我习惯线上默认115200。
  • 在DMA Settings里添加USART1_RX通道,方向Peripheral to Memory,模式设为Circular(循环模式),数据宽度Byte。
  • 使能USART1全局中断(NVIC里勾上USART1 global interrupt)。
  • DMA通道的中断可以不开,因为我用IDLE中断来判断一帧结束,DMA传输完成中断在这个方案里不是必需品。如果不开DMA中断,注意别把DMA的中断优先级和UART中断配成相同的抢占优先级,避免意外。

CubeMX生成代码后,工程里需要自己做的初始化只有一行:在main函数里开启DMA接收。

HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_DMA_BUF_SIZE);

这行代码的作用是告诉串口和DMA:从此刻开始,收到的数据自动往rx_dma_buf里搬,搬满RX_DMA_BUF_SIZE个字节后自动回绕。注意这个buf_size,通常设成256、512或1024,具体看你的协议帧最大长度和系统内存。设太大会浪费RAM,设太小则业务代码来不及消费就溢出丢数。经验值是缓冲区容量大于最大协议帧长度的两倍,给处理留出余量。

4.2 中断服务函数与帧处理代码

重点来写USART1中断服务函数。CubeMX生成的工程里已经有IRQHandler函数,我们只需要在里面追加IDLE空闲中断的处理。

void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 算一下本次实际收到多少字节 uint16_t len = RX_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 这里把 len 字节的数据送入环形缓冲区或直接按帧处理 process_received_frame(rx_dma_buf, len); } }

关键行是__HAL_DMA_GET_COUNTER。DMA计数器保存的是“还剩多少字节没搬”,用缓冲区总大小减去剩余值,就是“已经搬进来多少新字节”。比如缓冲区大小1024,DMA计数器显示800,说明已搬了224字节,这一帧应该就是224字节。

产生IDLE中断时,线上已经空闲。DMA已经完成这一帧所有字节的搬运,所以读出来的长度正好就是完整帧长,不会出现半帧的情况。拿到len后,正常有三种处理方式:

  • 直接把rx_dma_buf里的数据拷贝到业务缓冲区,然后置标志位,主循环消费。
  • 把数据写入环形缓冲区,业务代码从环形缓冲区里按状态机切帧。
  • 直接在中断里做轻量的协议解析,只把解析结果丢给业务。但串口中断里不建议做重逻辑,优先级高,长时间占用会拖累其他中断。

我个人的工程习惯是:中断服务函数里只做“长度计算 + 拷贝入环形缓冲区”,把状态机解析全部放到主循环或RTOS任务里。这样中断总耗时控制在几个微秒级别,对整个系统的干扰最小。

注意一点:HAL_UART_Receive_DMA只需调用一次,之后每次IDLE中断处理完,不需要再次调用。因为DMA工作在循环模式,它会自动持续接收。很多人是从“中断接收一字节一重入”的思路迁移过来的,习惯性在IDLE中断里再调一次Receive_DMA,结果造成DMA被反复重新配置,数据错乱,属于典型的画蛇添足。

4.3 业务层消费:从缓冲区到协议帧

假设我按上面的方式,IDLE中断里把一帧数据写入环形缓冲区,那么在主循环里消费的代码大致长这样:

while (1) { // 尝试从环形缓冲区读一个字节喂给状态机 if (ring_buffer_read(&rx_ring, &byte)) { protocol_parser_byte(&parser, byte); if (parser.frame_ready) { handle_frame(&parser.frame); // 业务处理 parser.frame_ready = 0; } } // 其他业务任务... }

这套“IDLE中断产帧 + 环形缓冲队列 + 状态机切帧 + 主循环消费”的架构,是我给多个项目落地过的最稳的组合。实测在STM32F103 @ 72MHz主频、115200波特率、连续接收大量传感器报文的场景下,CPU占用可以控制在个位数百分比,业务逻辑有充足余量去跑屏幕刷新和PID运算。在FreeRTOS项目里,则只需要把主循环换成独立任务,用信号量或队列通知数据到达,思路完全一致。

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

5.1 接收丢数据的排查顺序

串口接收丢数据,优先级最高的排查顺序是:

  • 第一查波特率误差。用示波器测波形最准,没有示波器就用串口回环测试。STM32串口波特率误差容忍度一般在±3%以内,如果外部晶振偏差大(比如用了HSI内部RC),高速率下必丢。我碰到过把主板上的8M晶振换成了12M导致所有波特率都不对的情况,排查了半天才发现是BOM贴错料。
  • 第二查缓冲区和处理速度。数据进来后是否在下一轮覆盖前取走了?环形缓冲区满时是丢弃还是覆盖?建议在缓冲区满时置一个溢出标志,测试期通过调试器观察有没有溢出发生,比猜强得多。
  • 第三查中断优先级。UART/DMA中断的抢占优先级必须高于系统里那些会产生长阻塞的中断。如果定时器中断里处理了大段算法代码,串口中断一直在等,数据早就被覆盖了。
  • 第四查DMA配置。循环模式有没有选错、数据宽度是不是Byte、外设和内存地址有没有反。

我曾经遇到一个特别隐蔽的问题:程序里开了看门狗,看门狗在中断里喂狗,而串口中断优先级比看门狗低。当串口高速接收时,串口中断抢占喂狗,导致看门狗复位。表面现象是“串口一收数据就复位”,谁也没想到是中断优先级的关系。

5.2 IDLE中断的误触发与丢失

空闲中断的一个典型坑:如果对方发送的数据是连续不间断的(比如某些传感器以固定周期持续输出数据流,字节之间几乎无间隙),IDLE中断可能连续几秒都不触发,因为线上根本没有空闲状态。但这时候DMA还在正常搬运,缓冲区满了就回绕。如果你在IDLE中断里才处理数据,那数据在缓冲区里可能被覆盖了两轮,等终于触发IDLE时,取到的已经是新数据了。

解决思路有两个方向:

  • 用DMA的半满/全满中断做兜底。缓冲区满一半触发一次中断,先把这批数据搬走,防止回绕覆盖。但这个方案的代码复杂度会明显升高。
  • 更推荐的是:先确定协议的最大帧长,把DMA缓冲区设成大于两帧的大小,同时业务代码在环形缓冲区里按状态机切帧,即使IDLE来晚了,只要环形缓冲区足够大、没有溢出,数据依然在缓冲区里完整保留,不会丢。

IDLE中断丢失也是常见现象。我遇到过IDLE标志清了但中断不触发的情况,最后发现是CubeMX生成的代码里,UART的中断处理顺序问题——HAL_UART_IRQHandler内部已经把IDLE标志清掉了,我在它后面再去判断IDLE标志位,永远判断不到。解决办法是:不要在IRQHandler里处理IDLE,而是在调用HAL_UART_IRQHandler之前先手动判断IDLE标志,或者直接在中断里读取标志后先保存。具体写法看HAL库版本,排查时建议先读库源码确认标志清除的位置。

5.3 乱码的常见成因

乱码和丢数据还不一样,乱码说明数据“在”但“内容不对”。最常见的几个原因:

  • 波特率不匹配,收发两端的波特率算出的实际速率有偏差,表现就是偶发乱码而不是完全乱。
  • 电平不匹配,TTL电平直接接了RS232电平设备,也是经典连环坑。串口模块、主板、传感器之间务必确认电平标准一致。
  • 接地问题,收发设备之间没有共地,或者共地接触不良,高速率下会出现随机性误码。这是我最容易忽略的问题,经常在实验室里查了半天数据格式,最后发现是飞线接触不良。
  • 校验位设置不一致。收发两端数据位、停止位、校验位必须完全一致,一个设偶校验一个设无校验,必然出乱码。

排查乱码时强烈建议先用串口调试助手的自发自收功能,短接TXD和RXD测试串口本身是否正常。这一步能过滤掉一半以上的低级问题。

5.4 在FreeRTOS/RTOS环境下的推荐做法

如果项目跑FreeRTOS,串口接收架构可以保持和裸机一模一样,只是把“主循环消费”换成“任务消费”,数据通知方式从轮询标志位换成任务通知或二值信号量。

IDLE中断里做完长度计算和数据搬运后,调用xSemaphoreGiveFromISR释放信号量,接收任务阻塞在xSemaphoreTake上,数据一来立刻被唤醒处理。这样做的CPU占用比裸机更低——裸机主循环还需要不停轮询环形缓冲区是否为空,RTOS方案里完全没有轮询压力。

需要注意:中断服务函数里绝对不能调用xSemaphoreGive这类非FromISR版本的API,会触发断言或导致系统崩溃。SDK迭代后API细节可能有差异,务必查当前SDK提供的FromISR版本函数。

6. 写在最后的工程建议

手头项目真正用起来时,我建议把串口接收这层做成统一的小模块,接口只暴露一个注册回调函数,用户只需要实现“拿到一帧数据后怎么处理”。无论底层是中断接收还是DMA+IDLE,业务代码看到的都是同一套接口。这样项目迭代时串口协议从AT指令换成Modbus,或者从裸机迁到RTOS,业务层代码一行都不用动。

另一个建议是,协议设计时尽量采用“帧头 + 长度 + 载荷 + 校验”的固定结构,别用“特殊字节结尾”的方案。特殊字节作为结尾,一旦载荷里恰好出现这个字节,就必须做转义处理,转义逻辑写起来不难,但排查粘包半包时会让你怀疑人生。用长度字段做定界,状态机逻辑最干净,排查也最容易。

我早期做串口接收时吃过亏,觉得“DMA+IDLE”听起来高级就一上来就用,结果代码改了好几轮都不顺。后来老老实实从中断接收开始,把协议拆干净了,再迁移到DMA方案,一步到位反而没出任何问题。想用DMA的,先打通中断接收的链路再迁;想写状态机的,先把协议文档里的帧格式画清楚再动代码。基础打牢了,剩下的都只是时间问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询