第 5 讲 | UART + DMA + 环形缓冲:一套不掉包的中断收帧架构
【本讲要点】串口收包是 IoT 固件的地基。收包不稳,上面所有逻辑都是空中楼阁。这一讲给你一套可直接移植的收帧框架。
一、串口收包的三层境界
第一层:轮询(新手村)
while(1){if(USART_GetFlagStatus(USART1,USART_FLAG_RXNE)){uint8_tb=USART_ReceiveData(USART1);buf[idx++]=b;}}问题:主循环一旦有别的事(比如处理按键),就会漏掉串口字节。
第二层:中断逐字节(进阶,但有隐患)
voidUSART1_IRQHandler(void){if(USART_GetITStatus(USART1,USART_IT_RXNE)){uint8_tb=USART_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#include<stdint.h>#include<stdbool.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 ==================== */#include"uart_rx.h"#include"gd32e23x.h"/* 以 GD32E23x 为例,其他 MCU 换成对应头文件 */staticuint8_ts_dma_buf[UART_RX_BUF_SIZE];/* DMA 直接写入的循环缓冲 */staticvolatileuint16_ts_last_pos=0;/* 上次处理到的位置 */staticvolatileuint32_ts_drop_cnt=0;/* ---------- 环形缓冲(单生产者=中断,单消费者=任务) ---------- */staticuint8_ts_ring[UART_RX_BUF_SIZE];staticvolatileuint16_ts_head=0;/* 生产者写指针 */staticvolatileuint16_ts_tail=0;/* 消费者读指针 */staticinlineuint16_tring_next(uint16_tp){return(uint16_t)((p+1)&(UART_RX_BUF_SIZE-1));}staticvoidring_push_isr(uint8_tb){uint16_tnext=ring_next(s_head);if(next==s_tail){s_drop_cnt++;/* 环形缓冲满:丢字节并计数,绝不阻塞 */return;}s_ring[s_head]=b;s_head=next;}staticuint16_tring_read(uint8_t*out,uint16_tmax_len){uint16_tn=0;while(s_tail!=s_head&&n<max_len){out[n++]=s_ring[s_tail];s_tail=ring_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_ttmp=USART_STAT0(USART1);tmp=USART_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_pos+1)&(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_tidx=0;uint8_tb;while(uart_rx_read_frame(&b,1)==1){if(b=='\n'){out[idx]='\0';uint16_tlen=idx;idx=0;returnlen;/* 完整一行 */}if(idx<max-1){out[idx++]=b;}else{idx=0;/* 超长,丢弃 */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(next==s_tail){s_drop_cnt++;/* 明确记账,量产后能远程看到 */return;}为什么不能覆盖?覆盖会破坏正在传输的帧结构,导致解析出"看起来合法但内容错误"的数据——比丢包危险得多。
2. 帧超时丢弃
如果一帧收了一半,后面再也不来了(对方重启了),这半帧会永远卡在缓冲里。必须加超时清理:
/* 在任务里:若 100ms 内没有新字节且缓冲有残帧,清掉 */if(idx>0&&(xTaskGetTickCount()-last_rx_tick>pdMS_TO_TICKS(100))){idx=0;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让模组掉线"的根因所在。