☰
STM32F407串口环形队列加DMA:解决丢数据与CPU占用
2026/10/15 16:13:16 网站建设 项目流程

简介:这份资源面向嵌入式开发初学者与中级工程师,聚焦STM32F407平台上串口通信的环形队列与DMA收发中断数据处理,帮助解决高速串口传输中数据丢失、CPU占用过高及缓冲管理混乱等常见问题。压缩包共4个文件,包含2个h头文件、1个c源文件与1个cpp文件,分别承担串口1及DMA配置声明、底层驱动实现与环形队列数据结构封装,整体约5KB,体量轻便便于直接移植到工程中。目前已有4283人学习下载,说明该方案在嵌入式硬件与单片机学习群体中具有较高参考价值。读者可从中获取串口波特率、数据位、停止位与校验位的配置思路,DMA通道选择、传输方向与内存外设模式设置方法,以及接收发送中断服务程序中环形队列入队、出队与状态检查的完整实现逻辑,同时能借鉴变量类型不一致等典型问题的排查经验,适合需要稳定高效串口通信的嵌入式项目参考。

1. 串口环形队列加 DMA:为什么你的中断还在丢数据

很多做过 STM32F407 串口通信的工程师都有过这样的经历:主循环里轮询接收标志,波特率一上 115200 就开始丢字节;改成接收中断,数据量一大 CPU 全耗在中断里,稍微加点协议解析就溢出。问题的根子不在中断本身,而在于「中断里干了太多不该干的事」——把每个字节的搬运、缓存、解析全塞进 ISR,等于让 CPU 给串口当搬运工。

串口环形队列配合 DMA 收发,解决的正是这个矛盾:DMA 负责把数据从外设寄存器搬到内存,环形队列负责在内存里做无锁缓冲,中断只在「一批数据搬完」或「队列快满」时触发一次。STM32F407 有双缓冲 DMA 和空闲中断,天然适合这套组合。这套方案适合做多路串口通信、Modbus 主从、上位机协议对接的嵌入式开发者,尤其是那些被丢包和 CPU 占用率折磨过的人。下面从缓冲区设计讲到中断处理,把参数和坑一次说清。

2. 环形队列的内存布局与 DMA 双缓冲怎么配合

2.1 环形队列的读写指针为什么必须用 volatile 加原子操作

环形队列的核心就两个索引:写指针(DMA 或中断往里放数据的位置)和读指针(主循环取数据的位置)。在 STM32F407 上,DMA 写内存和 CPU 读内存是并行的,如果写指针不是 volatile,编译器优化后可能把它缓存在寄存器里,主循环永远看不到 DMA 更新后的值。但光加 volatile 不够,head = (head + 1) % size这种操作在 Cortex-M4 上不是原子的,读-改-写三步之间可能被中断打断。

常见做法是把队列大小设成 2 的幂,用位掩码代替取模,同时保证同一时刻只有一个写者(DMA 或中断)和一个读者(主循环)。如果确实需要多写者,就得关中断或用 LDREX/STREX 做独占访问。我一般会把队列大小定成 256、512 这种值,掩码就是 size-1,写指针更新变成head = (head + 1) & mask,一条指令完成,省去取模的除法开销。

// ring_buffer.h #ifndef RING_BUFFER_H #define RING_BUFFER_H #include <stdint.h> #include <stdbool.h> #define UART_RX_BUF_SIZE 512 // 必须是 2 的幂 #define UART_RX_BUF_MASK (UART_RX_BUF_SIZE - 1) typedef struct { uint8_t buffer[UART_RX_BUF_SIZE]; volatile uint16_t head; // 写指针,DMA/中断更新 volatile uint16_t tail; // 读指针,主循环更新 volatile uint16_t overflow_cnt; // 溢出计数,调试用 } ring_buffer_t; void rb_init(ring_buffer_t *rb); uint16_t rb_available(ring_buffer_t *rb); // 可读字节数 uint16_t rb_write(ring_buffer_t *rb, const uint8_t *data, uint16_t len); uint16_t rb_read(ring_buffer_t *rb, uint8_t *out, uint16_t len); #endif

这段头文件定义了队列结构和接口。head和tail用volatile uint16_t而不是uint32_t,是因为 512 字节的队列用 16 位索引足够,还能省一半内存访问带宽。overflow_cnt不是必须的,但在调试丢包问题时,它能告诉你到底是 DMA 没搬进来还是主循环没来得及取。

2.2 DMA 双缓冲模式怎么配置才能不丢第一个字节

STM32F407 的 USART 支持 DMA 循环模式和双缓冲模式。循环模式下 DMA 把缓冲区填满后自动回到开头继续填,但 CPU 不知道什么时候填满了一圈,容易覆盖未读数据。双缓冲模式(DMA_Mode = DMA_MODE_CIRCULAR 配合 DMA_MemoryBurst 和双地址)会在切换缓冲区时触发中断,给 CPU 一个明确的「这一半满了,你来取」的信号。

配置时最容易翻车的地方是 DMA 的传输方向和数据宽度。USART_RX 的 DMA 请求是外设到内存,方向必须设成DMA_PERIPH_TO_MEMORY;数据宽度外设侧是 8 位(USART_DR 低 8 位有效),内存侧也设 8 位,两边不一致会导致字节错位。还有优先级,如果同时有多个串口在用 DMA,优先级低的那个在高速率下可能被抢占导致溢出。

// uart_dma.c 片段:USART1 RX DMA 双缓冲配置 void uart1_dma_rx_init(ring_buffer_t *rb) { // 1. 使能 DMA2 时钟和 USART1 时钟 RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN; RCC->APB2ENR |= RCC_APB2ENR_USART1EN; // 2. 配置 DMA2 Stream2 Channel4 为 USART1_RX DMA2_Stream2->CR &= ~DMA_SxCR_EN; // 先关闭流 while (DMA2_Stream2->CR & DMA_SxCR_EN); // 等待关闭完成 DMA2_Stream2->PAR = (uint32_t)&USART1->DR; // 外设地址 DMA2_Stream2->M0AR = (uint32_t)rb->buffer; // 内存地址 DMA2_Stream2->NDTR = UART_RX_BUF_SIZE; // 传输长度 DMA2_Stream2->CR = (4 << DMA_SxCR_CHSEL_Pos) // 通道 4 | DMA_SxCR_MINC // 内存地址递增 | DMA_SxCR_CIRC // 循环模式 | DMA_SxCR_TCIE // 传输完成中断 | DMA_SxCR_HTIE; // 半传输中断 // 3. 使能 USART1 的 DMA 接收请求 USART1->CR3 |= USART_CR3_DMAR; // 4. 使能 DMA 流 DMA2_Stream2->CR |= DMA_SxCR_EN; }

这段代码里DMA_SxCR_CIRC让 DMA 在缓冲区末尾自动回卷,TCIE和HTIE分别在半满和全满时触发中断。注意NDTR设成UART_RX_BUF_SIZE,DMA 会从M0AR开始搬NDTR个字节,搬完触发 TC,搬一半触发 HT。中断里根据当前是前半还是后半来更新head,主循环只读tail到head之间的数据。

提示:DMA 流关闭后必须等待 EN 位真正清零再重新配置,否则新参数不生效。这个等待循环在 F4 上通常几个周期就完成,但少了它会出现「配置了没反应」的玄学问题。

3. 空闲中断加 DMA 半满中断:把不定长帧收完整的实操

3.1 IDLE 中断的触发条件和清除顺序

串口收不定长数据最头疼的是「一帧到底多长」。STM32 的 USART 有个 IDLE 中断,总线空闲一个字节时间后触发,正好用来判断一帧结束。但 IDLE 标志的清除顺序有讲究:必须先读 SR 再读 DR,否则标志清不掉,中断会反复进。而且 IDLE 中断和 DMA 传输完成中断可能同时挂起,处理顺序不对会丢数据。

我一般会在 IDLE 中断里做两件事:一是计算从上次位置到当前 DMA 写指针之间有多少字节,把这部分数据「提交」到环形队列;二是重置 DMA 的 NDTR 或者记录新的起始位置。如果 DMA 是循环模式,NDTR 会自己回卷,只需要用UART_RX_BUF_SIZE - NDTR算出当前写位置。

// stm32f4xx_it.c 片段:USART1 中断处理 void USART1_IRQHandler(void) { uint32_t sr = USART1->SR; uint32_t dr = USART1->DR; // 读 DR 清除 IDLE 和 RXNE if (sr & USART_SR_IDLE) { // 计算 DMA 当前写位置 uint16_t curr_pos = UART_RX_BUF_SIZE - DMA2_Stream2->NDTR; // 把 [last_pos, curr_pos) 区间的数据提交到环形队列 uart1_rx_commit(last_pos, curr_pos); last_pos = curr_pos; } // 其他错误标志处理:ORE、FE、NE、PE if (sr & (USART_SR_ORE | USART_SR_FE | USART_SR_NE | USART_SR_PE)) { // 读 SR 再读 DR 已清除,这里只做计数 uart_error_cnt++; } }

USART_SR_IDLE的判断必须在读 DR 之前,因为读 DR 会顺带清 IDLE。last_pos是一个静态变量,记录上次提交到的位置。uart1_rx_commit负责把 DMA 缓冲区里新增的数据拷进环形队列,如果队列剩余空间不够,就丢弃或覆盖最旧数据,同时overflow_cnt加一。

3.2 半满中断和空闲中断的优先级怎么排

DMA 的半传输中断(HT)和传输完成中断(TC)优先级如果设得比 USART 空闲中断高,会出现「DMA 中断里正在提交数据,IDLE 又来了」的嵌套。Cortex-M4 的中断嵌套是硬件自动压栈的,但如果在 DMA 中断里操作了环形队列的head,而 IDLE 中断也操作head,就可能出现写指针错乱。

稳妥的做法是:DMA 的 HT/TC 中断只做「标记半满/全满」这一件事,把实际的数据提交放到 USART 的 IDLE 中断或者主循环里做。或者干脆不用 HT/TC 中断,只靠 IDLE 中断加 DMA 循环模式,因为 IDLE 已经能覆盖帧结束的场景,HT/TC 只在连续大数据流时才有意义。我一般会在 NVIC 里把 USART1 中断优先级设得比 DMA 高,保证 IDLE 能及时响应。

中断源优先级建议职责
USART1_IRQn抢占优先级 1处理 IDLE、错误标志,提交数据到队列
DMA2_Stream2_IRQn抢占优先级 2仅标记半满/全满状态
DMA2_Stream7_IRQn (TX)抢占优先级 3发送完成回调,启动下一帧

这张表是我在多路串口项目里常用的优先级分配。USART 抢占优先级最高,因为 IDLE 晚处理一个字节时间就可能被下一帧覆盖。DMA 接收中断次之,发送中断最低,因为发送不着急,晚一点启动下一帧不影响正确性。

4. 发送方向的环形队列:DMA 搬运和队列满的判断

4.1 发送队列和接收队列的差异在哪

接收队列是「DMA 写、CPU 读」,发送队列反过来:「CPU 写、DMA 读」。这个方向差异导致发送队列的满判断更敏感——如果 CPU 写入速度超过 DMA 发送速度,队列会满,此时要么阻塞等待,要么丢弃。接收队列满了可以覆盖旧数据(看协议要求),发送队列满了不能覆盖,因为覆盖意味着发出去的数据不完整。

发送队列的另一个坑是 DMA 传输完成中断的时机。TC 中断触发时,DMA 已经把 NDTR 个字节全部搬到 USART_DR 了,但最后一个字节可能还在移位寄存器里没发完。如果此时立刻启动下一笔 DMA 传输,理论上没问题,因为 USART 的 TXE 标志会控制节奏。但如果队列里没有新数据,就要把 DMA 流关掉,等下次有数据再开,否则 DMA 会一直请求。

// uart_tx.c 片段:发送环形队列 + DMA static ring_buffer_t tx_rb; static volatile bool tx_busy = false; bool uart1_send(const uint8_t *data, uint16_t len) { if (tx_busy) { // DMA 正在发送,尝试追加到队列 uint16_t written = rb_write(&tx_rb, data, len); return (written == len); } // 空闲状态,直接启动 DMA uint16_t written = rb_write(&tx_rb, data, len); uart1_tx_start(); return true; } static void uart1_tx_start(void) { uint16_t avail = rb_available(&tx_rb); if (avail == 0) { tx_busy = false; return; } // 从队列取数据到 DMA 发送缓冲区 uint16_t n = rb_read(&tx_rb, dma_tx_buf, avail); DMA2_Stream7->CR &= ~DMA_SxCR_EN; while (DMA2_Stream7->CR & DMA_SxCR_EN); DMA2_Stream7->M0AR = (uint32_t)dma_tx_buf; DMA2_Stream7->NDTR = n; DMA2_Stream7->CR |= DMA_SxCR_EN; USART1->CR3 |= USART_CR3_DMAT; tx_busy = true; } // DMA2_Stream7_IRQHandler 里调用 void uart1_tx_complete_callback(void) { DMA2_Stream7->CR &= ~DMA_SxCR_EN; while (DMA2_Stream7->CR & DMA_SxCR_EN); USART1->CR3 &= ~USART_CR3_DMAT; tx_busy = false; uart1_tx_start(); // 检查队列里是否还有数据 }

uart1_send先判断tx_busy,如果 DMA 正在发送就把数据追加到队列,否则直接写队列并启动 DMA。uart1_tx_start从队列取数据到dma_tx_buf,这个缓冲区必须是全局的或静态的,因为 DMA 在传输过程中会持续访问它,不能是栈上的局部数组。TC 中断里先关 DMA 和 DMAT 请求,再调uart1_tx_start检查队列,形成链式发送。

4.2 发送队列满的时候该阻塞还是丢弃

这取决于协议层。如果是 Modbus RTU 这种一问一答的协议,发送队列几乎不会满,因为每次只发一帧,等响应回来才发下一帧。如果是持续上报传感器数据的场景,队列满了说明上位机处理不过来,此时阻塞会导致主循环卡死,丢弃会导致数据不完整。我一般会返回「实际写入字节数」,让调用者决定重试还是丢弃,同时在overflow_cnt里记录丢弃次数,方便现场排查。

注意:DMA 发送缓冲区不能用局部变量。栈上的数组在函数返回后可能被覆盖,而 DMA 还在后台搬运,结果就是发出去的数据随机错乱。这个坑我在早期项目里踩过,现象是「偶尔发出去的数据前几个字节对,后面全是乱码」,查了两天才定位到。

5. 避坑与排查:环形队列和 DMA 配合时的五个血泪教训

5.1 现象:队列数据偶尔错位一个字节

原因通常是 DMA 和 CPU 同时访问队列索引。head在 DMA 中断里更新,tail在主循环里更新,如果主循环读head时被中断打断,读到的可能是更新前的值。解决方法是读head和tail时关中断,或者用「读两次直到一致」的乐观锁。我一般会在rb_available里关中断再读两个指针,因为这两个读操作很快,关中断几十个周期不影响实时性。

5.2 现象:高速率下 DMA 传输完成中断进不来

DMA 的 TC 标志需要先清再等,如果中断服务函数里没清标志,NVIC 会一直挂起同一个中断,但 DMA 硬件已经停止触发。STM32F4 的 DMA 中断标志在DMA_LISR和DMA_HISR里,清除方式是写 1 到对应位。另外,如果 DMA 流被其他高优先级流抢占,低优先级流的 TC 可能延迟很久才响应,此时 NDTR 已经回卷,算出来的位置是错的。解决办法是给接收 DMA 设最高优先级,或者用双缓冲模式让硬件自动切换。

5.3 现象:IDLE 中断频繁触发但收不到数据

这通常是噪声或波特率不匹配导致的帧错误。USART 的 FE(帧错误)和 NE(噪声)标志如果没清,会反复触发中断。在 IDLE 处理里读 SR 再读 DR 能清掉大部分标志,但 ORE(溢出错误)需要特别注意:ORE 置位说明 DMA 没及时搬走数据,USART_DR 被覆盖了。此时应该丢弃当前帧,重置 DMA 的 NDTR 和写位置,而不是继续往队列里塞错误数据。

5.4 现象:发送完一帧后 DMA 不再启动

检查uart1_tx_complete_callback里是否清了 TC 标志,以及tx_busy是否被正确置回 false。另一个常见原因是USART_CR3_DMAT没关,导致 USART 一直请求 DMA,但 DMA 流已经关了,USART 的 TXE 标志会一直置位但发不出数据。正确顺序是先关 DMA 流,再关 DMAT,最后清标志。

5.5 现象:队列溢出计数一直在涨但数据看起来正常

这说明主循环取数据的速度跟不上 DMA 写入速度。可能是主循环里有阻塞操作(比如HAL_Delay或等待其他外设),也可能是队列太小。先量一下主循环一轮的时间,再算一下串口满速时的字节率。115200 波特率下每秒约 11520 字节,如果主循环 10ms 一轮,队列至少要 128 字节才不溢出。我一般会把接收队列设成 512 或 1024,给主循环留足余量。

6. 用 DMA 剩余计数做零拷贝取数的一个技巧

前面几章讲的都是「DMA 搬到缓冲区,再拷进环形队列」,多了一次内存拷贝。如果协议解析允许直接访问 DMA 缓冲区,可以用NDTR剩余计数实现零拷贝:主循环直接读 DMA 缓冲区里[tail, curr_pos)区间的数据,读完更新tail,不经过环形队列。这个技巧在数据量大、内存紧张的场合很有用,但要求主循环处理速度足够快,否则 DMA 回卷会覆盖未读数据。

具体做法是维护一个tail变量,每次主循环读DMA2_Stream2->NDTR算出当前写位置curr_pos = UART_RX_BUF_SIZE - NDTR。如果curr_pos >= tail,可读区间就是[tail, curr_pos);如果curr_pos < tail,说明 DMA 已经回卷,可读区间是[tail, UART_RX_BUF_SIZE)加上[0, curr_pos)。读完更新tail = curr_pos。整个过程没有内存拷贝,只有指针运算。

// 零拷贝取数示例 void uart1_poll_zero_copy(void) { uint16_t curr_pos = UART_RX_BUF_SIZE - DMA2_Stream2->NDTR; if (curr_pos == tail) return; // 无新数据 if (curr_pos > tail) { // 未回卷,直接处理 [tail, curr_pos) protocol_parse(&dma_rx_buf[tail], curr_pos - tail); } else { // 已回卷,分两段处理 protocol_parse(&dma_rx_buf[tail], UART_RX_BUF_SIZE - tail); if (curr_pos > 0) { protocol_parse(&dma_rx_buf[0], curr_pos); } } tail = curr_pos; }

protocol_parse必须是可重入的,因为它可能被调用两次处理回卷后的两段数据。如果协议解析需要跨段拼接(比如一帧数据刚好被缓冲区边界切开),就要在解析函数里维护状态机,或者退回到环形队列方案。这个技巧的边界很明确:主循环轮询间隔必须小于「缓冲区大小 / 字节率」,否则 DMA 回卷会覆盖未读数据。我一般会在调试阶段用 GPIO 翻转加示波器量一下轮询间隔,确认余量足够再上生产。

这套方案我在多个 F407 项目里用过,从 9600 到 921600 波特率都跑过,最深的体会是:环形队列的索引类型和 DMA 的 NDTR 计算方式必须一开始就定对,后期改起来牵一发动全身。另外,别迷信「中断越少越好」,IDLE 中断该用就用,它比定时器轮询判断帧结束可靠得多。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询