简介:这份资源面向嵌入式开发初学者与需要优化串口通信的工程师,聚焦STM32F407平台上串口环形队列与DMA收发中断的协同实现,帮助解决高速数据传输中CPU占用高、数据易丢失的问题。压缩包共4个文件,包含2个h头文件、1个c源文件与1个cpp文件,分别承担串口1及DMA配置、环形队列数据结构与操作等职责,整体约5KB,体量轻便,便于直接嵌入现有工程。资源围绕USART1的DMA通道配置、收发中断服务程序以及环形队列入队出队逻辑展开,并涉及变量类型不一致等常见问题的排查思路,读者可据此掌握数据缓冲与高效传输的完整链路。目前已有4283人学习下载,适合具备一定单片机基础、希望深入理解DMA与中断配合的开发者参考借鉴。
1. 串口环形队列加 DMA:为什么裸机轮询一到高波特率就翻车
前阵子帮一个做工业网关的朋友排查问题,他的板子跑 115200 波特率时一切正常,一旦把传感器换成 921600,串口就开始丢包,偶尔还整帧错位。代码里用的是最经典的HAL_UART_Receive轮询接收,主循环里还塞了 Modbus 解析和 Flash 写入。这种场景在嵌入式硬件里太常见了——STM32F407 的 USART 外设本身完全扛得住高波特率,翻车的从来不是芯片,而是数据搬运的架构。
这份资源要解决的就是这件事:用 DMA 把串口数据自动搬进内存,再用环形队列做缓冲,让 CPU 只在数据攒够一帧时才被中断叫醒。它适合已经能点亮串口、但被丢包和 CPU 占用率折磨的单片机开发者,尤其是跑 407 这类带 DMA 控制器的 M4 平台。读完你能自己搭出一套「DMA 搬运 + 环形队列缓存 + 空闲中断判帧」的接收链路,而不是继续在主循环里死等标志位。
2. 环形队列与 DMA 接收的底层配合:从外设到内存的完整链路
2.1 为什么单缓冲 DMA 一定会丢数据
很多人第一次用 DMA 收串口,会写成这样:开一个 256 字节的数组,调用HAL_UART_Receive_DMA,然后在回调里处理。跑起来发现短帧没问题,一旦两帧数据背靠背进来,第二帧就没了。原因在于 DMA 的传输完成中断(TC)只在缓冲区填满时触发一次,而串口数据是异步到达的——你不知道下一帧什么时候来,也不知道它有多长。
单缓冲的致命伤是「处理窗口」和「接收窗口」重叠。当 CPU 正在解析缓冲区里的数据时,DMA 如果还在往同一个缓冲区写,新数据就会覆盖旧数据。有人用「双缓冲」缓解,但双缓冲只能扛住两帧的节奏,遇到突发流量还是会溢出。环形队列的价值就在这里:它把「写指针」和「读指针」解耦,DMA 只管往后写,CPU 只管从前面读,两者互不阻塞。
STM32F407 的 DMA 控制器支持循环模式(Circular Mode),这是配合环形队列的关键。开启循环模式后,DMA 传输到缓冲区末尾会自动回到开头继续写,不需要软件重新配置。但要注意:循环模式下 TC 中断依然只在「写满一圈」时触发,所以不能靠 TC 来判断数据到达,得靠串口的空闲中断(IDLE)。
2.2 空闲中断加 DMA 剩余计数:算出这一帧到底多长
IDLE 中断的触发条件是:串口接收线在一个字节时间内没有新数据。对于 Modbus RTU 这种帧间隔明确的协议,IDLE 就是天然的帧边界。配合 DMA 的剩余传输计数寄存器(NDTR),可以精确算出这一帧收了多少字节。
原理是这样的:假设 DMA 缓冲区大小是BUF_SIZE,当前NDTR读数是remain,那么已经写入的字节数就是BUF_SIZE - remain。但环形队列的写指针是循环的,所以需要记录上一次的NDTR值,用差值来算增量。常见做法是维护一个last_pos变量,每次 IDLE 中断里计算curr_pos = BUF_SIZE - remain,然后len = (curr_pos - last_pos + BUF_SIZE) % BUF_SIZE。
这里有个容易翻车的点:NDTR在 DMA 循环模式下会从BUF_SIZE递减到 0,然后自动重载回BUF_SIZE。如果你在它刚好重载的瞬间读,差值计算会出错。稳妥的做法是在 IDLE 中断里先关掉 DMA 的循环重载,或者用「双指针 + 溢出标志」的方式处理。我一般会在初始化时把last_pos设为 0,每次算完增量后更新它,并且在差值计算里加一个if (len > BUF_SIZE) len = 0;的兜底。
2.3 环形队列的读写指针设计:无锁还是加锁
环形队列的核心就两个指针:head(写指针,DMA 侧更新)和tail(读指针,CPU 侧更新)。在裸机环境里,如果只有 IDLE 中断在写head,主循环在读tail,那么天然就是「单生产者单消费者」模型,不需要加锁。但要注意head和tail的类型选择——用volatile uint16_t还是uint32_t取决于缓冲区大小。如果BUF_SIZE是 2 的幂,可以用位掩码代替取模运算,速度更快。
判断队列空的条件是head == tail,判断满的条件是(head + 1) % BUF_SIZE == tail。注意这里牺牲了一个字节的空间来区分空和满,这是环形队列的经典做法。如果你不想浪费这个字节,可以额外维护一个count变量,但那样就需要考虑count的原子性——在中断和主循环同时访问时,count的自增自减不是原子操作,容易出玄学 bug。
我一般会这样定义队列结构:
typedef struct { uint8_t buffer[UART_BUF_SIZE]; volatile uint16_t head; // DMA/中断侧写入 volatile uint16_t tail; // 主循环侧读取 } ring_queue_t; static ring_queue_t uart_rx_q; // 判断队列中可读字节数 static inline uint16_t ring_available(ring_queue_t *q) { return (q->head - q->tail + UART_BUF_SIZE) % UART_BUF_SIZE; } // 从队列读取一个字节 static inline int ring_get(ring_queue_t *q, uint8_t *byte) { if (q->head == q->tail) { return -1; // 队列空 } *byte = q->buffer[q->tail]; q->tail = (q->tail + 1) % UART_BUF_SIZE; return 0; }这段代码里head和tail都声明为volatile,防止编译器优化掉「看似重复」的读取。ring_available用模运算处理回绕,如果UART_BUF_SIZE是 256 或 512 这类 2 的幂,可以改成(q->head - q->tail) & (UART_BUF_SIZE - 1),省掉除法。ring_get返回 -1 表示队列空,调用方需要处理这个情况,不能假设队列里一定有数据。
2.4 初始化顺序:先开 DMA 还是先开串口
初始化顺序错了会导致第一帧数据丢失,这是血泪经验。正确的顺序是:先配置 GPIO 和 USART 的波特率、数据位、停止位,然后配置 DMA 流(包括通道、方向、循环模式、优先级),接着把 DMA 和 USART 关联起来(HAL_UART_Receive_DMA或直接操作寄存器),最后使能 USART 的 IDLE 中断和 DMA 请求。
如果你先使能了 USART 的接收,再配置 DMA,那么在这段窗口期里到达的数据会触发溢出错误(ORE),而且 ORE 一旦置位,后续数据可能全部丢失,直到你手动清除标志。我见过有人调了一整天,最后发现是初始化顺序问题。稳妥的做法是在MX_USART1_UART_Init之后立刻调用HAL_UART_Receive_DMA,再调用__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)。
另外,DMA 流的优先级要设对。STM32F407 的 DMA1 和 DMA2 各有多个流,每个流有 4 级优先级。串口接收建议设为「高」或「非常高」,避免被其他 DMA 请求(比如 SPI 刷屏)挤占。但也不要无脑设最高,否则可能影响系统里其他实时性要求更高的外设。
3. 从零搭建接收链路:寄存器配置、中断处理与队列消费
3.1 用 CubeMX 生成骨架后必须手改的三处
CubeMX 能帮你把 GPIO、USART、DMA 的初始化代码生成出来,但有三处它不会替你做好。第一处是 IDLE 中断的使能,CubeMX 里没有勾选项,得在main.c的MX_USART1_UART_Init之后手动加__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);。第二处是 DMA 循环模式的确认,CubeMX 里 DMA 设置有个「Mode」选项,要选「Circular」而不是「Normal」,否则 DMA 传完一圈就停了。第三处是中断优先级分组,HAL_Init里默认是NVIC_PRIORITYGROUP_4,所有 4 位都用于抢占优先级,如果你用了 FreeRTOS 或者有其他中断嵌套需求,得改成NVIC_PRIORITYGROUP_2之类的分组。
生成代码后,先别急着写业务逻辑,用调试器看一眼DMA1_Stream5->NDTR寄存器的值。如果它在你发送数据时递减,说明 DMA 链路通了。如果一直是BUF_SIZE不变,检查 USART 的CR3寄存器里DMAR位有没有置 1。
3.2 IDLE 中断服务函数:把数据从 DMA 缓冲区搬进环形队列
IDLE 中断的处理是整个链路的核心。它要做三件事:清除 IDLE 标志、计算本次收到的字节数、把数据从 DMA 缓冲区拷贝到环形队列。注意不能直接在中断里做协议解析,那是主循环的活。
void USART1_IRQHandler(void) { // 检查 IDLE 标志 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清标志,顺序不能反 // 计算 DMA 当前写位置 uint16_t curr_pos = UART_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t last_pos = uart_rx_q.head; uint16_t len; if (curr_pos >= last_pos) { len = curr_pos - last_pos; } else { len = UART_BUF_SIZE - last_pos + curr_pos; // 回绕 } // 把数据搬进环形队列(这里 DMA 缓冲区和队列可以是同一块内存) // 如果分开,需要 memcpy uart_rx_q.head = curr_pos; // 通知主循环有新数据 uart_rx_flag = 1; } HAL_UART_IRQHandler(&huart1); // 处理其他中断源 }这段代码里__HAL_DMA_GET_COUNTER读的就是NDTR寄存器。last_pos直接用uart_rx_q.head,因为head始终指向 DMA 上一次写完的位置。len的计算处理了回绕情况。最后把head更新为curr_pos,主循环通过比较head和tail就知道有多少数据可读。
注意__HAL_UART_CLEAR_IDLEFLAG必须在读NDTR之前调用,否则可能读到已经变化的值。另外,如果 DMA 缓冲区和环形队列的buffer是同一块内存,那就不需要memcpy,直接更新head就行——这是最省 CPU 的做法,也是我推荐的方式。但前提是队列的buffer大小和 DMA 缓冲区大小一致,且 DMA 配置为循环模式。
3.3 主循环消费:按帧解析而不是逐字节处理
主循环里不要一个字节一个字节地读,那样效率低而且容易把帧拆散。常见做法是先用ring_available判断可读长度,如果超过一帧的最小长度,再进入解析状态机。
void main_loop(void) { uint8_t byte; static uint8_t frame[256]; static uint16_t frame_len = 0; while (ring_available(&uart_rx_q) > 0) { if (ring_get(&uart_rx_q, &byte) != 0) { break; // 队列空 } // 简单的帧头帧尾状态机 if (frame_len == 0 && byte != 0xAA) { continue; // 等帧头 } frame[frame_len++] = byte; if (frame_len >= sizeof(frame)) { frame_len = 0; // 防溢出 continue; } if (byte == 0x55 && frame_len > 2) { // 收到完整帧,交给协议解析 protocol_parse(frame, frame_len); frame_len = 0; } } }这个状态机以0xAA为帧头、0x55为帧尾,中间的数据全部缓存。ring_available返回可读字节数,ring_get取出一个字节。注意frame_len达到缓冲区上限时要重置,否则会越界写。实际项目里帧头帧尾可能更复杂,但思路是一样的:从队列里取数据,喂给状态机,状态机判断帧边界。
3.4 关键参数对照:缓冲区大小、波特率与中断频率
缓冲区大小不是随便定的,它和波特率、帧长度、主循环周期都有关系。下面这张表是我在 168MHz 的 STM32F407 上实测的经验值:
| 波特率 | 典型帧长 | 推荐 DMA 缓冲区 | IDLE 中断频率 | 备注 |
|---|---|---|---|---|
| 9600 | 8 字节 | 64 字节 | 极低 | 随便设都行 |
| 115200 | 32 字节 | 256 字节 | 约 300Hz | 主流配置 |
| 460800 | 64 字节 | 512 字节 | 约 1.2kHz | 注意中断开销 |
| 921600 | 128 字节 | 1024 字节 | 约 2.5kHz | 建议开 DMA 双缓冲 |
| 2000000 | 256 字节 | 2048 字节 | 约 5kHz | 中断里只搬数据,别解析 |
缓冲区太小会导致 DMA 写指针追上读指针,数据被覆盖;太大则浪费 RAM,而且memcpy开销增加。一般建议缓冲区至少能装下 4 到 8 帧的数据。IDLE 中断频率超过 5kHz 时,中断本身的进出开销就不可忽略了,这时候要考虑用 DMA 双缓冲(HAL_UARTEx_ReceiveToIdle_DMA)或者降低中断处理里的工作量。
4. 避坑与排查:那些让串口丢包的隐蔽问题
4.1 现象:第一帧总是丢,后面正常
原因:初始化顺序问题。USART 使能后、DMA 配置完成前到达的数据会触发 ORE 溢出,而且 ORE 标志不清除的话,后续接收会一直异常。解决:在MX_USART1_UART_Init之后立刻调用HAL_UART_Receive_DMA,再使能 IDLE 中断。如果已经出现 ORE,在中断里读SR寄存器再读DR寄存器来清除。
4.2 现象:高波特率下偶发数据错位,低波特率正常
原因:DMA 和 CPU 同时访问同一块内存,缓存一致性问题。STM32F407 有 ART 加速器和数据缓存,如果 DMA 缓冲区的内存属性配置为可缓存(Cacheable),CPU 读到的可能是旧数据。解决:把 DMA 缓冲区放到非缓存区域(比如0x20000000附近的 SRAM),或者在读取前调用SCB_InvalidateDCache_by_Addr。但 F407 的 SRAM 默认就是非缓存的,这个问题在 F7/H7 上更常见。
4.3 现象:队列里的数据时多时少,解析随机失败
原因:head和tail的读写顺序问题。如果主循环在读取head时被 IDLE 中断打断,而中断又更新了head,那么主循环可能读到「半新半旧」的值。解决:在读取head前关中断,读完再开;或者用「读两次取一致」的方法——连续读两次head,如果值相同才认为有效。更稳妥的做法是用__disable_irq()和__enable_irq()包住临界区,但临界区要尽可能短。
4.4 现象:DMA 传输完成中断和 IDLE 中断同时触发,数据重复
原因:如果同时开了 DMA 的 TC 中断和 USART 的 IDLE 中断,两者可能在同一时刻触发,导致同一段数据被处理两次。解决:只用 IDLE 中断来判断帧边界,DMA 的 TC 中断要么不开,要么只用来做溢出检测。如果必须用 TC,在 TC 中断里不要重复搬运数据,只更新一个标志位。
4.5 现象:系统跑一段时间后串口完全无响应
原因:DMA 传输错误(TE)或者 USART 噪声/帧错误(NE/FE)导致 DMA 流被自动关闭。STM32 的 DMA 在遇到总线错误时会停止传输,而 HAL 库的错误回调可能没有被正确处理。解决:在HAL_UART_ErrorCallback里重新初始化 DMA 和 USART,并清除所有错误标志。另外,在 IDLE 中断里定期检查DMA1_StreamX->CR的EN位,如果为 0 就重新使能。
5. 进阶技巧:用双缓冲 DMA 把中断频率再降一半
当波特率推到 2M 以上,或者系统里中断源特别多时,单缓冲加 IDLE 的方案还是会占用不少 CPU。这时候可以上 DMA 双缓冲(Double Buffer Mode),STM32F407 的 DMA 支持在同一个流上配置两块内存区域,通过CT位切换。配合HAL_UARTEx_ReceiveToIdle_DMA,可以在一次 IDLE 事件里处理半缓冲和全缓冲两次数据,中断频率直接减半。
具体做法是定义两块缓冲区buf0和buf1,调用HAL_UARTEx_ReceiveToIdle_DMA(&huart1, buf0, HALF_SIZE),然后在HAL_UARTEx_RxEventCallback里根据Size参数判断是半满还是全满。半满时处理buf0,全满时处理buf1,同时把tail指针往前推。这样主循环的消费节奏更平滑,也不容易因为单次中断处理时间过长而丢数据。
验证方法很简单:用逻辑分析仪抓 USART 的 RX 线,同时用调试器看tail指针的递增是否连续。如果tail在两次中断之间没有跳跃,说明数据没有丢。我一般还会在队列里加一个「高水位标记」,当ring_available超过缓冲区 80% 时点亮一个 LED 或者置一个标志位,用来预警消费不及时。
从那以后我每次调串口接收,都会先跑一遍「高波特率压力测试」:用 PC 端脚本以最大速率连续发 10 万帧,每帧带递增序号,板子收到后校验序号连续性。只要有一帧序号跳变,就说明链路还有问题,绝不带着隐患上产线。希望帮到你。
本文还有配套的精品资源,点击获取