
第 5 讲 | UART DMA 环形缓冲一套不掉包的中断收帧架构【本讲要点】串口收包是 IoT 固件的地基。收包不稳上面所有逻辑都是空中楼阁。这一讲给你一套可直接移植的收帧框架。一、串口收包的三层境界第一层轮询新手村while(1){if(USART_GetFlagStatus(USART1,USART_FLAG_RXNE)){uint8_tbUSART_ReceiveData(USART1);buf[idx]b;}}问题主循环一旦有别的事比如处理按键就会漏掉串口字节。第二层中断逐字节进阶但有隐患voidUSART1_IRQHandler(void){if(USART_GetITStatus(USART1,USART_IT_RXNE)){uint8_tbUSART_ReceiveData(USART1);ring_push(g_ring,b);/* 每来一个字节进一次中断 */}}问题波特率一高比如 921600每字节省一次中断CPU 被淹没。在 48MHz 的 M0 上这会吃掉可观的 CPU 时间尤其当每 1.2 秒要收 24 个数据包时。第三层DMA 空闲中断推荐本讲主题让 DMA 自动把字节搬进缓冲CPU 只在一帧收完时被打断一次。核心思路DMA 循环模式接收自动往缓冲区填字节不占 CPU利用 UART 的IDLE空闲中断总线连续空闲一个字符时间说明一帧结束了在 IDLE 中断里用当前 DMA 写指针 - 上次读指针算出这一帧的长度。二、DMA IDLE 的完整实现/* uart_rx.h */#ifndefUART_RX_H#defineUART_RX_H#includestdint.h#includestdbool.h#defineUART_RX_BUF_SIZE256/* 必须是 2 的幂方便取模 */voiduart_rx_init(void);/* 从接收缓冲取出完整帧返回帧长度无数据返回 0 */uint16_tuart_rx_read_frame(uint8_t*out,uint16_tmax_len);uint32_tuart_rx_drop_count(void);/* 溢出/丢帧计数用于诊断 */#endif/* uart_rx.c */#includeuart_rx.h#includegd32e23x.h/* 以 GD32E23x 为例其他 MCU 换成对应头文件 */staticuint8_ts_dma_buf[UART_RX_BUF_SIZE];/* DMA 直接写入的循环缓冲 */staticvolatileuint16_ts_last_pos0;/* 上次处理到的位置 */staticvolatileuint32_ts_drop_cnt0;/* ---------- 环形缓冲单生产者中断单消费者任务 ---------- */staticuint8_ts_ring[UART_RX_BUF_SIZE];staticvolatileuint16_ts_head0;/* 生产者写指针 */staticvolatileuint16_ts_tail0;/* 消费者读指针 */staticinlineuint16_tring_next(uint16_tp){return(uint16_t)((p1)(UART_RX_BUF_SIZE-1));}staticvoidring_push_isr(uint8_tb){uint16_tnextring_next(s_head);if(nexts_tail){s_drop_cnt;/* 环形缓冲满丢字节并计数绝不阻塞 */return;}s_ring[s_head]b;s_headnext;}staticuint16_tring_read(uint8_t*out,uint16_tmax_len){uint16_tn0;while(s_tail!s_headnmax_len){out[n]s_ring[s_tail];s_tailring_next(s_tail);}returnn;}/* ---------- 初始化 ---------- */voiduart_rx_init(void){/* 1. 配置 UART 收发引脚、波特率略用官方 SDK *//* 2. 配置 DMA循环模式外设USART_DATA内存s_dma_buf */dma_deinit(DMA_CH3);dma_init(DMA_CH3,DMA_PERIPH_TO_MEMORY,DMA_MEMORY_INCREASE_ENABLE,DMA_PERIPH_INCREASE_DISABLE,DMA_CIRCULAR_MODE_ENABLE,DMA_PRIORITY_HIGH,UART_RX_BUF_SIZE);dma_transfer_number_config(DMA_CH3,UART_RX_BUF_SIZE);dma_memory_address_config(DMA_CH3,(uint32_t)s_dma_buf);dma_peripheral_address_config(DMA_CH3,(uint32_t)USART_DATA(USART1));dma_channel_enable(DMA_CH3);usart_dma_receive_config(USART1,USART_DENR_ENABLE);/* 3. 使能 IDLE 中断 —— 这是一帧结束的信号 */usart_interrupt_enable(USART1,USART_INT_IDLE);nvic_irq_enable(USART1_IRQn,6,0);/* 优先级数值 ≥5可调 FreeRTOS API */}/* ---------- IDLE 中断一帧收完 ---------- */voidUSART1_IRQHandler(void){if(usart_interrupt_flag_get(USART1,USART_INT_FLAG_IDLE)!RESET){/* 读 DR 清标志顺序不能错 */volatileuint32_ttmpUSART_STAT0(USART1);tmpUSART_DATA(USART1);(void)tmp;/* 当前 DMA 写到哪了 */uint16_tcur_pos(uint16_t)(UART_RX_BUF_SIZE-dma_transfer_number_get(DMA_CH3));/* 把 [s_last_pos, cur_pos) 之间的新字节搬进环形缓冲 */while(s_last_pos!cur_pos){ring_push_isr(s_dma_buf[s_last_pos]);s_last_pos(uint16_t)((s_last_pos1)(UART_RX_BUF_SIZE-1));}}}/* ---------- 任务侧读一帧 ---------- */uint16_tuart_rx_read_frame(uint8_t*out,uint16_tmax_len){returnring_read(out,max_len);}uint32_tuart_rx_drop_count(void){returns_drop_cnt;}三、为什么要环形缓冲这一层你可能会问DMA 已经在缓冲了为什么还要再套一个环形缓冲因为两者的角色不同DMA 缓冲环形缓冲谁写DMA 硬件中断服务程序谁读中断服务程序应用任务位置固定循环会被覆盖队列语义先进先出问题任务来不及读就被 DMA 覆盖满时明确丢字节并计数DMA 缓冲只是一个中转站它会被硬件不断覆盖。一旦 CPU 因为高优先级中断延迟处理DMA 已经绕回覆盖了旧数据。环形缓冲把会被覆盖的缓冲变成有明确边界的队列任务什么时候来读都行读到的都是没被覆盖的完整字节流真读慢了也能通过s_drop_cnt明确知道丢了多少——可观测才是工程。四、粘包与拆包UART 是字节流不是消息。DMA IDLE 给你的只是一段一段的字节一段里可能有 3 个包也可能只有半个包。必须用协议层的规则来切分。常见三种方案 1定长帧每帧固定 N 字节。最简单但灵活性差。if(ring_available()FRAME_LEN){read_exact(out,FRAME_LEN);}方案 2首尾定界符推荐用于 AT / 文本协议如以\r\n结尾uint16_textract_line(uint8_t*out,uint16_tmax){staticuint16_tidx0;uint8_tb;while(uart_rx_read_frame(b,1)1){if(b\n){out[idx]\0;uint16_tlenidx;idx0;returnlen;/* 完整一行 */}if(idxmax-1){out[idx]b;}else{idx0;/* 超长丢弃 */s_drop_cnt;}}return0;}方案 3长度字段推荐用于二进制协议帧头里带长度最可靠#pragmapack(1)typedefstruct{uint8_thead;/* 0xAA */uint8_tcmd;uint16_tlen;/* 数据长度 */uint8_tdata[];}proto_frame_t;#pragmapack()/* 解析先找到 head再根据 len 判断整帧是否到齐 */注意二进制协议一定要处理半包。收到 5 字节但整帧要 20 字节时必须把这 5 字节留在缓冲里等后续不能丢掉重来——这是新手最常见的 bug。五、三个必须处理的边界情况1. 溢出与丢包统计环形缓冲满时不能覆盖也不能阻塞只能丢弃并计数if(nexts_tail){s_drop_cnt;/* 明确记账量产后能远程看到 */return;}为什么不能覆盖覆盖会破坏正在传输的帧结构导致解析出看起来合法但内容错误的数据——比丢包危险得多。2. 帧超时丢弃如果一帧收了一半后面再也不来了对方重启了这半帧会永远卡在缓冲里。必须加超时清理/* 在任务里若 100ms 内没有新字节且缓冲有残帧清掉 */if(idx0(xTaskGetTickCount()-last_rx_tickpdMS_TO_TICKS(100))){idx0;s_drop_cnt;}3. 中断优先级DMA 和 UART 的中断优先级数值必须 ≥configMAX_SYSCALL_INTERRUPT_PRIORITY第 3 讲讲过否则一旦在 IDLE 中断里调用了 FreeRTOS API比如xQueueSendFromISR就会崩。六、性能对比同一块 48MHz 的 M0 板子同一个 921600 波特率的收包压力方案CPU 占用收包部分掉包率轮询高且会漏高逐字节中断15%25%低DMA IDLE 1%低在每 1.2 秒 24 个包这种场景下DMA IDLE 是唯一不会影响其他任务的选择。七、小结四句话1. 收包的终极方案是 DMA 空闲中断CPU 只在帧结束时被打断一次。2. DMA 缓冲会被覆盖必须再套一层环形缓冲把它变成有边界的队列。3. UART 是字节流粘包/半包必须由协议层切分二进制协议尤其要注意半包。4. 溢出要丢弃并计数绝不覆盖——可观测性比少丢一个字节重要得多。下一讲第 6 讲我们把这条链路接到 WiFi 模组上如何写一个不被阻塞拖垮的 AT 指令驱动。这正是一次printf让模组掉线的根因所在。