简介:这份资源面向嵌入式开发初学者与需要优化串口通信的工程师,聚焦STM32F407平台上串口环形队列与DMA收发中断数据处理的实现方案。内容围绕USART1的DMA通道配置、收发中断服务程序编写以及环形队列的入队出队管理展开,帮助解决高速串口通信中数据丢失、CPU占用过高等常见问题。压缩包共4个文件,包含2个h头文件、1个c源文件和1个cpp文件,分别承担串口与DMA配置声明、底层驱动实现及环形队列数据结构封装,整体约5KB,结构精简便于直接移植到工程中。目前已有4283人学习下载,适合具备一定单片机基础、希望掌握DMA加环形队列高效缓冲策略的开发者参考,可从中获取中断与队列协同处理的完整思路及变量类型不一致等排错经验。
1. 串口环形队列加 DMA:为什么裸机轮询在 115200 以上就开始丢包
很多刚接触 STM32F407 的开发者都有过这样的经历:主循环里用HAL_UART_Receive轮询收数据,波特率一上 115200,稍微来一串连续帧,数据就开始对不上,帧头错位、校验失败,甚至整个缓冲区被冲烂。你以为是串口配置错了,反复检查波特率、停止位、校验位,结果都没问题——问题出在数据到达的速度和 CPU 处理速度之间的时间差上。
STM32F407 的 USART 在 115200 波特率下,每个字节的到达间隔大约是 86.8 微秒。如果主循环里还有 LCD 刷新、传感器读取、协议解析等任务,一次循环耗时轻松超过这个间隔,硬件数据寄存器(DR)里的数据还没被读走,下一个字节就覆盖上来了,溢出错误(ORE)标志置位,数据直接丢失。这就是轮询方式在高波特率下的死穴。
串口环形队列配合 DMA 收发中断,解决的正是这个「生产者(串口硬件)速度远快于消费者(CPU 主循环)速度」的经典矛盾。环形队列做缓冲,DMA 做批量搬运,中断做边界通知,三者配合下来,CPU 只需要在数据攒够一批或者一帧结束时介入一次,中间过程完全不占用内核。这套方案适合所有需要稳定串口通信的裸机或 RTOS 项目,尤其是 Modbus 通信、上位机指令交互、传感器数据采集这类场景。下面把我自己反复调过的实现路径拆开讲。
2. 环形队列与 DMA 收发的配合逻辑:谁写谁读、什么时候触发中断
2.1 环形队列的本质:两个指针在固定数组上追尾
环形队列不是什么高深数据结构,就是一个固定长度的数组加两个索引:写指针(head)和读指针(tail)。写入时数据放在 head 位置,head 前移;读取时从 tail 位置取数据,tail 前移。两者都到达数组末尾时回绕到 0。判断空的条件是 head == tail,判断满的条件是 (head + 1) % size == tail,牺牲一个存储单元来区分空和满,这是最常见也最不容易出错的做法。
在串口接收场景里,DMA 是生产者,它把串口 DR 寄存器的数据搬到环形队列的写入区域;主循环或协议解析任务是消费者,从环形队列的读取区域取数据。中断的作用是通知消费者「有一批新数据到了」或者「一帧结束了」,而不是让 CPU 一个字节一个字节地去搬。
这里有一个关键设计决策:DMA 的目标地址是环形队列的物理内存,但环形队列的 head 指针由 DMA 传输完成中断来更新,而不是由 DMA 硬件自动维护。也就是说,DMA 只负责把数据从外设搬到内存的某个连续区域,环形队列的「环形」逻辑是靠软件在中断里维护 head 指针来实现的。这一点如果理解偏了,后面代码一定写乱。
2.2 DMA 接收的两种模式:普通模式和循环模式怎么选
STM32F407 的 DMA 控制器支持普通模式(Normal)和循环模式(Circular)。普通模式下,DMA 传输完设定的数据量就停止,需要软件重新使能;循环模式下,DMA 传输完会自动回到起始地址继续传输,不需要软件干预。
对于串口接收,我一般用循环模式配合「空闲中断(IDLE)」来用。具体做法是:DMA 以循环模式搬运串口数据到一块固定大小的缓冲区,同时开启串口的 IDLE 中断。当串口总线上一段时间没有新数据时,IDLE 标志置位,触发中断。在中断里,我根据 DMA 的剩余传输计数(NDTR)算出这次实际收到了多少字节,把这些字节从 DMA 缓冲区拷贝到环形队列里,然后更新环形队列的 head 指针。
为什么不用 DMA 传输完成中断(TC)?因为 TC 中断只在 DMA 搬满设定长度时才触发,如果一帧数据长度不固定,TC 中断要么来得太晚(等搬满才通知),要么需要频繁重设 DMA 长度,反而增加复杂度。IDLE 中断配合循环 DMA,能很好地适应变长帧。
2.3 空闲中断里到底该做什么:拷贝、更新指针、通知任务
IDLE 中断服务函数里要做的事情很明确,顺序不能乱:
第一步,清除 IDLE 标志。F407 的 IDLE 标志清除方式是先读 SR 寄存器再读 DR 寄存器,HAL 库的__HAL_UART_CLEAR_IDLEFLAG已经封装好了。
第二步,计算本次接收到的字节数。公式是:本次接收长度 = DMA 缓冲区总长度 - DMA 当前剩余传输计数(NDTR)。注意 NDTR 是递减的,所以要用总长度减去它。
第三步,把 DMA 缓冲区里对应位置的数据拷贝到环形队列。这里要注意,DMA 缓冲区是线性数组,而环形队列的写入位置可能跨越数组末尾,所以拷贝要分两段处理:从当前 head 到数组末尾一段,剩余部分从数组开头继续。
第四步,更新环形队列的 head 指针,并置位一个「有新数据」的标志,供主循环查询。
第五步,如果使用了 RTOS,可以在这里释放一个信号量或者发送任务通知,让解析任务立刻开始工作。
整个中断里不做协议解析,不做复杂运算,只做搬运和通知,保证中断执行时间尽可能短。这是保证高波特率下不丢数据的关键纪律。
2.4 发送方向:DMA 发送加环形队列的意义在哪
接收方向用环形队列加 DMA 是刚需,发送方向其实也有价值。如果发送数据量较大或者发送频率较高,用HAL_UART_Transmit阻塞发送会占用大量 CPU 时间。把待发送数据先写入发送环形队列,然后启动 DMA 发送,DMA 传输完成中断里再检查队列里是否还有数据,有就继续启动下一轮 DMA。这样发送过程也变成异步的,主循环只管往队列里塞数据,不用等硬件发完。
发送环形队列的实现逻辑和接收类似,只是生产者和消费者角色互换:主循环是生产者,DMA 是消费者。DMA 传输完成中断里更新 tail 指针,并判断是否需要启动下一次传输。
3. 在 STM32F407 上把环形队列和 DMA 收发跑通:从 CubeMX 配置到中断处理
3.1 CubeMX 里必须改的三个地方
用 CubeMX 生成初始化代码时,串口和 DMA 的配置有几个默认值需要手动调整。
第一个地方是 DMA 模式。在 DMA Settings 标签页里,给 USART 的 RX 通道选择 Circular 模式,给 TX 通道选择 Normal 模式。RX 用循环模式是为了配合 IDLE 中断实现不定长接收;TX 用普通模式是因为每次发送的长度可能不同,发完再重新配置更灵活。
第二个地方是中断使能。在 NVIC Settings 里,除了使能 USART 的全局中断,还要确认 DMA 的中断是否按需使能。对于接收方向,我通常只开 USART 的 IDLE 中断,DMA 接收通道的中断不开;对于发送方向,需要开 DMA 发送通道的传输完成中断。
第三个地方是串口参数。波特率、字长、停止位、校验位按实际需求设,但有一个参数容易被忽略:过采样模式。F407 的 USART 支持 16 倍和 8 倍过采样,8 倍过采样下波特率上限更高,但抗噪能力稍弱。如果波特率在 115200 及以下,用默认的 16 倍过采样就行。
配置完成后生成代码,CubeMX 会帮你把MX_USART1_UART_Init和MX_DMA_Init写好,但环形队列和 IDLE 中断的处理逻辑需要自己加。
3.2 环形队列的 C 实现:结构体定义与读写函数
下面这份环形队列代码是我在多个 F407 项目里反复用过的版本,去掉了动态内存分配,全部用静态数组,适合裸机和 RTOS 环境。
// ring_buffer.h #ifndef RING_BUFFER_H #define RING_BUFFER_H #include <stdint.h> #include <stdbool.h> #define RING_BUF_SIZE 512 // 必须是 2 的幂,方便用位运算取模 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; // 写指针,中断里修改 volatile uint16_t tail; // 读指针,主循环修改 } ring_buffer_t; void ring_buf_init(ring_buffer_t *rb); uint16_t ring_buf_write(ring_buffer_t *rb, const uint8_t *data, uint16_t len); uint16_t ring_buf_read(ring_buffer_t *rb, uint8_t *data, uint16_t len); uint16_t ring_buf_used(ring_buffer_t *rb); bool ring_buf_is_empty(ring_buffer_t *rb); #endif// ring_buffer.c #include "ring_buffer.h" #include <string.h> void ring_buf_init(ring_buffer_t *rb) { rb->head = 0; rb->tail = 0; memset(rb->buffer, 0, RING_BUF_SIZE); } // 返回实际写入的字节数 uint16_t ring_buf_write(ring_buffer_t *rb, const uint8_t *data, uint16_t len) { uint16_t written = 0; uint16_t next_head; for (uint16_t i = 0; i < len; i++) { next_head = (rb->head + 1) % RING_BUF_SIZE; if (next_head == rb->tail) { break; // 队列满,停止写入 } rb->buffer[rb->head] = data[i]; rb->head = next_head; written++; } return written; } // 返回实际读出的字节数 uint16_t ring_buf_read(ring_buffer_t *rb, uint8_t *data, uint16_t len) { uint16_t read = 0; while (read < len && rb->tail != rb->head) { data[read] = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RING_BUF_SIZE; read++; } return read; } uint16_t ring_buf_used(ring_buffer_t *rb) { if (rb->head >= rb->tail) { return rb->head - rb->tail; } else { return RING_BUF_SIZE - rb->tail + rb->head; } } bool ring_buf_is_empty(ring_buffer_t *rb) { return (rb->head == rb->tail); }这段代码有几个细节值得说。head和tail都加了volatile修饰,因为 head 在中断里被修改,tail 在主循环里被修改,编译器不能把它们优化到寄存器里只读一次。RING_BUF_SIZE取 512,是 2 的幂,理论上可以用& (RING_BUF_SIZE - 1)代替% RING_BUF_SIZE来提速,但现代编译器对 2 的幂取模本来就会优化成位与,所以写%可读性更好。写入函数在队列满时直接截断,不覆盖旧数据,这是有意为之——串口场景下丢新数据比丢旧数据更安全,因为旧数据可能是一帧正在解析的协议。
3.3 DMA 接收缓冲区与 IDLE 中断的对接代码
假设用 USART1,DMA2 Stream2 做接收,DMA2 Stream7 做发送。先定义全局变量:
// uart_dma.h #define DMA_RX_BUF_SIZE 256 extern ring_buffer_t uart_rx_rb; extern uint8_t dma_rx_buffer[DMA_RX_BUF_SIZE];在main.c里初始化并启动:
// main.c 片段 ring_buffer_t uart_rx_rb; uint8_t dma_rx_buffer[DMA_RX_BUF_SIZE]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); ring_buf_init(&uart_rx_rb); // 启动 DMA 循环接收 HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, DMA_RX_BUF_SIZE); // 使能串口 IDLE 中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); while (1) { // 主循环里处理环形队列数据 uint8_t temp[64]; uint16_t len = ring_buf_read(&uart_rx_rb, temp, sizeof(temp)); if (len > 0) { // 在这里做协议解析 process_protocol(temp, len); } } }IDLE 中断处理函数放在stm32f4xx_it.c里:
// stm32f4xx_it.c 片段 extern ring_buffer_t uart_rx_rb; extern uint8_t dma_rx_buffer[DMA_RX_BUF_SIZE]; extern UART_HandleTypeDef huart1; void USART1_IRQHandler(void) { // 检查 IDLE 标志 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 计算本次接收到的数据长度 uint16_t remaining = __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t received = DMA_RX_BUF_SIZE - remaining; // 计算 DMA 缓冲区中本次数据的起始位置 // 注意:循环模式下,DMA 当前写入位置是 (DMA_RX_BUF_SIZE - remaining) static uint16_t last_pos = 0; uint16_t current_pos = DMA_RX_BUF_SIZE - remaining; if (received > 0) { if (current_pos > last_pos) { // 没有回绕,直接拷贝一段 ring_buf_write(&uart_rx_rb, &dma_rx_buffer[last_pos], current_pos - last_pos); } else if (current_pos < last_pos) { // 发生了回绕,分两段拷贝 ring_buf_write(&uart_rx_rb, &dma_rx_buffer[last_pos], DMA_RX_BUF_SIZE - last_pos); ring_buf_write(&uart_rx_rb, &dma_rx_buffer[0], current_pos); } // current_pos == last_pos 时表示没有新数据,不处理 } last_pos = current_pos; } HAL_UART_IRQHandler(&huart1); }这段中断代码是整个方案的核心,有几个地方容易写错。第一,__HAL_DMA_GET_COUNTER返回的是 DMA 剩余传输计数,循环模式下它从DMA_RX_BUF_SIZE递减到 0 再回绕到DMA_RX_BUF_SIZE,所以current_pos就是 DMA 当前写到了缓冲区的哪个位置。第二,last_pos记录上一次中断时的位置,两次位置之间的数据就是本次新收到的。第三,回绕的判断逻辑:如果current_pos > last_pos,说明没有跨过缓冲区末尾;如果current_pos < last_pos,说明 DMA 已经回绕到缓冲区开头了,数据分两段。第四,current_pos == last_pos的情况要排除,否则会在某些边界条件下重复写入。
3.4 发送方向的 DMA 加环形队列实现
发送环形队列的结构和接收一样,只是读写角色互换。定义一个发送队列和对应的 DMA 发送缓冲区:
// uart_dma.h 补充 extern ring_buffer_t uart_tx_rb; extern uint8_t dma_tx_buffer[DMA_TX_BUF_SIZE];发送逻辑:
// 把数据写入发送队列,然后尝试启动 DMA void uart_send_data(const uint8_t *data, uint16_t len) { ring_buf_write(&uart_tx_rb, data, len); uart_tx_start_dma(); } // 如果 DMA 空闲且有数据待发,启动一次传输 void uart_tx_start_dma(void) { if (huart1.gState == HAL_UART_STATE_READY) { uint16_t len = ring_buf_read(&uart_tx_rb, dma_tx_buffer, DMA_TX_BUF_SIZE); if (len > 0) { HAL_UART_Transmit_DMA(&huart1, dma_tx_buffer, len); } } } // DMA 发送完成回调,在 stm32f4xx_hal_uart.c 的弱函数里重写 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart_tx_start_dma(); // 继续发送队列里剩余的数据 } }发送方向的关键点是HAL_UART_Transmit_DMA启动后,DMA 从dma_tx_buffer里搬数据,搬完触发传输完成中断,在回调里再次检查发送队列。这里有一个隐患:dma_tx_buffer是共享的,如果上一次 DMA 还没发完,下一次uart_tx_start_dma又往同一个缓冲区里写,数据就乱了。所以uart_tx_start_dma里必须先判断huart1.gState == HAL_UART_STATE_READY,确保上一次传输已经结束。这个判断在 HAL 库的 DMA 发送流程里是必要的,否则会出现「发送数据被覆盖」的玄学问题。
4. 这套方案最容易翻车的五个地方:现象、原因与解决
4.1 现象:IDLE 中断进不来,或者进来一次后再也不触发
原因通常有两个。一是 IDLE 标志没有正确清除。F407 的 IDLE 标志清除需要先读 SR 再读 DR,HAL 库的__HAL_UART_CLEAR_IDLEFLAG宏展开后就是这两步,但如果你在中断里先调用了HAL_UART_IRQHandler,它可能已经把 DR 读走了,导致后面的清除操作无效。解决办法是把 IDLE 标志的判断和清除放在HAL_UART_IRQHandler之前,就像上面代码里那样。
二是 DMA 接收没有真正启动。HAL_UART_Receive_DMA调用后,如果 DMA 通道配置有误(比如优先级、数据宽度、地址增量设置不对),DMA 根本不会搬运数据,串口 DR 里的数据没人读,IDLE 标志也不会置位。检查 DMA 配置里 Peripheral 地址是否设为&huart1.Instance->DR,Memory 地址是否设为dma_rx_buffer,数据宽度是否都是 Byte。
4.2 现象:接收到的数据偶尔多出几个字节,或者帧头错位
这是环形队列写入时没有处理回绕导致的。DMA 缓冲区是线性的,环形队列也是线性的,但两者的回绕点不一定对齐。如果 DMA 缓冲区大小和环形队列大小不同,或者 DMA 缓冲区大小不是环形队列大小的整数倍,就会出现「DMA 回绕了但环形队列没回绕」或者反过来。最稳妥的做法是让 DMA 缓冲区大小等于环形队列大小,或者至少是它的整数分之一,这样回绕逻辑简单,不容易出错。
另一个可能原因是last_pos的更新时机。如果在拷贝数据之前就更新了last_pos,而拷贝过程中又来了新的 IDLE 中断,就会丢数据。所以last_pos必须在拷贝完成后更新,且中断里要保证这段逻辑的原子性。如果主循环也会修改last_pos(一般不会),需要用临界区保护。
4.3 现象:高波特率下运行一段时间后,环形队列里的数据出现周期性错乱
这种周期性错乱通常指向 DMA 传输计数溢出。F407 的 DMA NDTR 寄存器是 16 位的,最大值 65535。如果 DMA 缓冲区大小设得很大(比如 8192),NDTR 从 8192 递减到 0 再回绕,本身没问题。但如果缓冲区大小超过 65535,NDTR 就会溢出,计算出的current_pos完全错误。所以 DMA 缓冲区大小不要超过 65535,实际项目中 256 到 2048 足够了。
还有一种可能是串口溢出错误(ORE)没有处理。当环形队列满时,ring_buf_write会截断数据,但串口硬件层面的 ORE 标志如果置位了没有清除,会导致后续接收异常。在 IDLE 中断里加一句检查:如果__HAL_UART_GET_FLAG(&huart1, UART_FLAG_ORE)为真,调用__HAL_UART_CLEAR_OREFLAG(&huart1)清除。这个操作在 F407 上同样是读 SR 再读 DR。
4.4 现象:发送数据时程序卡死,或者 DMA 发送完成回调不执行
卡死最常见的原因是HAL_UART_Transmit_DMA在gState不是 READY 时被调用,HAL 库会返回 HAL_BUSY,但如果你没检查返回值,后续逻辑可能陷入死循环等待。解决办法是在调用前判断状态,或者在回调里只做「启动下一次发送」这一件事,不要在里面做阻塞操作。
回调不执行的原因可能是 DMA 发送通道的中断没有使能。CubeMX 里配置 DMA 时,TX 通道的 NVIC 中断默认可能是关闭的,需要手动勾选。另外,HAL_UART_TxCpltCallback是弱函数,如果你在多个文件里都重写了它,链接器只会保留一个,其他被覆盖。确保整个工程里只有一个HAL_UART_TxCpltCallback定义。
4.5 现象:RTOS 环境下,串口任务优先级设置不当导致数据积压
如果项目用了 RTOS,串口解析任务的优先级需要仔细考虑。优先级太低,环形队列里的数据来不及处理,队列很快满了开始丢数据;优先级太高,可能抢占其他关键任务,导致系统实时性变差。我一般把串口解析任务设为中等优先级,略高于日志任务,低于电机控制或通信协议栈任务。同时在 IDLE 中断里释放信号量时,用xSemaphoreGiveFromISR而不是xSemaphoreGive,并且要处理pxHigherPriorityTaskWoken参数,确保任务切换及时发生。
另外,RTOS 下ring_buf_write和ring_buf_read的并发访问需要保护。虽然 head 和 tail 是分开的,但ring_buf_used这类函数同时读两个指针,可能读到不一致的中间状态。如果只是用来判断队列是否为空,影响不大;如果用来做精确的长度计算,最好在临界区里读。
5. 把环形队列用出花:动态阈值、零拷贝与压力测试的一个小技巧
这套方案跑通之后,可以做一些进阶优化。第一个是动态调整 IDLE 中断的触发阈值。F407 的 IDLE 中断是在总线空闲一个字节时间后触发,这个时间是固定的。但如果你的协议帧很短,频繁的 IDLE 中断会增加 CPU 负担。一个取巧的做法是:在 IDLE 中断里不立即拷贝数据,而是启动一个定时器,延时几个字节时间后再检查 DMA 位置,如果位置没变说明帧真的结束了,再拷贝。这样可以把多个短帧合并成一次处理,减少中断次数。不过这个做法增加了逻辑复杂度,只在中断频率确实过高时才考虑。
第二个是零拷贝思路。上面的实现里,数据从 DMA 缓冲区拷贝到环形队列是一次内存拷贝,从环形队列读到解析缓冲区又是一次。如果协议解析可以直接在 DMA 缓冲区上进行,就能省掉第一次拷贝。具体做法是:不建环形队列,直接用一个足够大的 DMA 循环缓冲区,IDLE 中断里只记录本次数据的起始位置和长度,解析任务直接读 DMA 缓冲区。但这样做的风险是,解析过程中 DMA 可能回绕覆盖正在读的数据。所以零拷贝只适合「解析速度远快于数据到达速度」的场景,或者配合双缓冲区交替使用。
第三个是压力测试方法。我一般用 PC 端的串口调试助手,设置 500 毫秒发送一包 1024 字节的随机数据,连续发 10000 包,然后在 MCU 端统计接收总字节数和校验错误的包数。如果接收总字节数等于发送总字节数,且校验错误为零,说明环形队列和 DMA 配合没有问题。这个测试能暴露大部分边界条件问题,比如回绕处理错误、队列满截断、中断优先级冲突等。测试时把波特率拉到 921600,如果还能稳定跑完,这套实现就基本可靠了。
最后说一个我自己的习惯:每次调串口 DMA,我都会在 IDLE 中断里翻转一个 GPIO,用示波器看中断频率和数据量的关系。如果中断频率远高于数据帧率,说明 IDLE 触发太频繁,需要检查是不是有噪声或者波特率不匹配。这个土办法比看日志直观得多,血泪经验是曾经因为波特率误差导致 IDLE 频繁误触发,查了一整天才发现是外部晶振负载电容不匹配。希望帮到你。
本文还有配套的精品资源,点击获取