资讯动态

STM32F407串口环形队列加DMA:解决丢数据与CPU占用

发布时间:2026/10/15 16:13:18 来源:尧图企业网站定制
简介这份资源面向嵌入式开发初学者与中级工程师聚焦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_SIZEDMA 会从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 中断该用就用它比定时器轮询判断帧结束可靠得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑