简介:基于STM32F407的串口环形队列与DMA收发中断实现,是一份面向嵌入式开发者和单片机初学者的紧凑示例代码包,重点展示如何在USART1上结合DMA与环形队列完成高效串口数据收发。包内仅含4个文件,包括2个头文件、1个C源文件和1个C++源文件,分别承担串口初始化/DMA配置、环形队列数据结构与读写操作等职责,整体压缩包仅5KB,代码精炼,便于直接阅读和移植到实际项目中。已有4278人学习下载,说明该方案在实战场景中受到较多关注。通过阅读这套实现,读者可以快速掌握串口中断与DMA协同工作的配置流程、环形队列防止数据丢失的缓冲思路,以及多文件中保持变量类型一致等常见注意事项,适合正在调试STM32串口通信或希望优化嵌入式数据吞吐能力的开发者参考。 做串口开发的人早晚会遇到同一个坎:单片机接收上位机数据,数据稍微多点、频率稍微快一点,用普通中断逐字节处理就开始丢数据。我也一样,从STM32F103到F407,从标准库换到HAL库,最终在F407上把串口收发稳定在了“环形队列+DMA+空闲中断”这套方案上。这篇文章就把这套方案的完整思路、具体配置和踩坑记录都摊开讲清楚,适合手里有STM32F407开发板、想彻底解决串口不定长数据收发问题的人。
先说这套方案解决了什么:串口接收时,数据由DMA自动搬运到内存缓冲区,全程不占用CPU;收到一帧完整数据后,通过空闲中断触发回调,把数据交给环形队列缓存;CPU只在合适的时候从队列取走数据。发送方向也一样,把所有要发送的数据先写入发送环形队列,再通过DMA一次性发出,发送完成后触发中断,自动发送队列里的下一条。这样CPU大部分时间都在干正事,串口收发像“后台任务”一样跑着。
我最初从串口裸中断改到这套架构,是因为调试一个四轴飞行器的数传链路,数据量只有几十字节每包,但每包之间间隔很短,普通中断方式接收时单片机忙不过来,导致控制周期抖动。后来在F407上用这套方案,不仅彻底解决了丢包,还意外省出了大量CPU时间去做姿态解算。如果你也遇到类似问题,建议认真看完下面的思路和代码。
1. 整体设计思路:为什么要绕这么大一圈
1.1 普通串口中断的痛
常规写法是开启RXNE中断,每收到一个字节就进一次中断,把字节放进数组。这在数据量小的时候没问题,但有两个明显问题:一是高频进中断会占用大量CPU时间,如果串口波特率是115200,大约每86微秒就进一次中断,CPU要频繁切换上下文;二是如果中断处理耗时稍长,比如在中断里做解析、清标志、操作其他外设,下一字节就来了,这时数据就会直接丢失。
更麻烦的是不定长数据。数据帧长度可能变,普通方式判断“一帧是否结束”只能靠固定的帧头帧尾或者超时计时。超时计时在中断里开定时器、关定时器,逻辑绕来绕去,最后往往还是不稳定。
1.2 DMA能帮我们解决什么
DMA(直接存储器访问)可以把串口接收寄存器里的数据自动搬运到内存数组里,不需要CPU参与。对F407来说,USART1的接收可以映射到DMA2的Channel4(或者用DMA1,根据具体外设映射)。配好之后,你只需要在接收缓冲数组填满或者发生空闲(线路空闲)时,再去处理数据。
配合空闲中断(IDLE)使用,就能做到“收到一帧数据后整体通知CPU”,而不是逐个字节通知。这样CPU端的处理频率大大降低,效率自然高。
1.3 环形队列在这里扮演的角色
DMA接收是把数据写进一个固定大小的数组,如果应用程序处理速度跟不上,下一次接收的数据就可能覆盖还没处理完的上一次数据。环形队列就是用来缓冲这批数据的。
环形队列本质上是一个固定大小的数组,加两个指针:读指针和写指针。写的一端往里放,读的一端往外取。当写满了但没被读走时,可以暂停写入或者丢弃新数据,保证旧数据不会被破坏。发送方向也一样,应用程序要发的数据先塞进发送队列,DMA发送完一条后自动从队列取下一批,避免阻塞在主流程里。
这套组合的最终效果是:DMA负责最底层的搬运,环形队列负责暂存和解耦,中断只做“通知”和“清标志”。CPU的逻辑变得非常干净。
2. 硬件与基本环境配置
2.1 开发板与软件版本说明
我手头用的是正点原子的探索者F407开发板,主控芯片是STM32F407ZGT6,主频168MHz。软件方面用的是STM32CubeMX 6.6.1生成初始化代码,HAL库版本是1.27.1。如果你用的是其他F407系列,逻辑完全一致,只是DMA映射和串口中断号需要对照参考手册调整。
在实际项目中,我最后把USART1作为调试串口,波特率115200,8位数据,无校验,1位停止位。用的PA9和PA10。硬件上如果走RS232或者RS485,要额外加电平转换芯片,这部分不影响DMA逻辑。
2.2 CubeMX里必须勾选哪些选项
打开CubeMX后选择芯片型号,配置时钟树,把USART1模式设为“Asynchronous”。然后在“DMA Settings”标签页添加USART1_RX和USART1_TX两个DMA请求。
RX通道模式设置为Circular(循环模式),方向PeripheralToMemory;TX通道模式设置为Normal(正常模式),方向MemoryToPeripheral。R事的优先级我一般设为High或VeryHigh,看系统资源占用情况。
这里有个很重要的细节:RX的DMA必须选Circular模式。原因后面会说。然后需要打开USART1的全局中断(NVIC设置里勾选USART1 global interrupt),因为我们要用空闲中断,而空闲中断属于串口中断,不是DMA中断。
生成代码后,在main.c里把用户代码初始化放到“USER CODE BEGIN 2”区域。注意不要手动改CubeMX生成的DMA初始化部分,否则重新生成时会覆盖。
2.3 接收缓冲区的布局
F407的DMA接收缓冲区可以是普通SRAM,也可以用CCM RAM(内核耦合内存)。如果用CCM RAM,需要特别注意,DMA不能直接访问CCM RAM,因此接收缓冲区必须放在普通SRAM里。这个坑我踩过:为了省内存把缓冲区放到CCRAM,结果DMA收进来全是乱码。
我一般这样声明全局变量:
#define UART_RX_BUF_SIZE 512 uint8_t uart_rx_buf[UART_RX_BUF_SIZE];这个数组是DMA的接收目标,数组大小可以根据项目要发的最大数据帧来定,至少要能装下最大一帧数据。建议取2的次方大小,方便环形队列取模运算。
3. 环形队列的完整实现与关键细节
3.1 环形队列的数据结构
环形队列的实现方式有很多种,最简单的是用“读指针”和“写指针”加一个“计数”字段。我这里用一个经典结构体:
typedef struct { uint8_t *buffer; uint16_t capacity; volatile uint16_t head; volatile uint16_t tail; volatile uint16_t count; } ring_buffer_t;head是写入位置(写指针),tail是读取位置(读指针),count是当前队列里有效数据个数。用count的好处是判空判满只需要比较count和capacity,不需要取巧的方式区分配满和空。缺点是count是共享变量,在中断和主循环间操作时要临时关中断保护。
初始化时把head、tail、count都清零即可。capacity必须是2的幂,比如256、512、1024,这样后面可以用“(index + 1) & (capacity - 1)”代替取模运算,速度更快。
3.2 写入和读取的边界处理
写入函数的核心逻辑是:检查count是否等于capacity,如果满了就返回失败;否则在buffer[head]位置写入数据,然后head加一,如果head到达capacity则回绕到0,count加一。
int ring_buf_write(ring_buffer_t *rb, uint8_t data) { if (rb->count >= rb->capacity) { return -1; } rb->buffer[rb->head] = data; rb->head = (rb->head + 1) & (rb->capacity - 1); rb->count++; return 0; }读取函数是同样的思路,从tail位置取数据,tail回绕,count减一。这里有个关键点,所有对head、tail、count的修改在进入临界区前要关中断,操作完再开中断。因为可能主循环正在写队列,串口中断正在往队列里追加数据,如果不加保护,两个线程同时修改head和count,数据就乱了。
即使在单核单片机上,也可能在执行到一半时被中断抢占,所以要特别注意临界区。我在实际代码里用到了HAL库的全局中断控制函数:
__disable_irq(); ring_buf_write(&rx_ring, data); __enable_irq();如果担忧关中断时间太长,可以用临界区嵌套计数,不过咱们这个写入操作就几条指令,直接关中断没问题。
3.3 批量写入和批量读取
单个字节的写入在DMA中断里可能被频繁调用,效率不够。更合适的做法是DMA一次接收一批数据,然后把这批数据整体写入环形队列。因此我还实现了批量写入:
int ring_buf_write_multi(ring_buffer_t *rb, const uint8_t *data, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { if (ring_buf_write(rb, data[i]) != 0) { return -1; } } return 0; }这里其实还有优化空间:判断可用空间足够后,直接拷贝连续区域,省去逐字节取模的循环。但在F407上,逐字节取模的操作耗时也在微秒级别以下,在绝大多数场景已经够用。如果你的数据吞吐量极大,再考虑分段memcpy优化。发送方向的读取也类似,从队列里一次性读取一段数据交给DMA发送。
4. DMA收发与中断处理的实战实现
4.1 接收DMA配置和空闲中断处理
CubeMX生成代码后,HAL库已经帮我们初始化了DMA和串口。我们还需要在初始化完成后启动第一次DMA接收。HAL库提供HAL_UART_Receive_DMA函数:
HAL_UART_Receive_DMA(&huart1, uart_rx_buf, UART_RX_BUF_SIZE);这里指定了接收缓冲区地址和长度。因为RX DMA配置的是Circular模式,所以这个函数调用一次后,DMA会一直把接收到的数据放到uart_rx_buf里。缓冲区满后,DMA会自动回绕到起始位置重新开始写入,不会产生中断(除非开启了传输完成中断,但一般我们不在这里开完成中断)。
那DMA在缓冲区满时数据会不会覆盖旧数据?会。但因为我们配合了空闲中断,程序会在每次收到一帧数据时立刻把这一帧的数据搬走,DMA写指针回绕后,旧数据已经被搬走了,不会冲突。
空闲中断怎么触发?USART空闲中断在串口接收到一个字节后,如果总线保持空闲(高电平)达到一个字节的时间,就会触发。这个中断不是DMA中断,而是USART中断。打开方式:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);然后在串口中断回调里处理:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); process_received_frame(); } HAL_UART_IRQHandler(&huart1); }注意必须在判断到IDLE标志后立即清除IDLE标志,否则会一直进中断。另外要确认HAL_UART_IRQHandler内部处理是否与我们的IDLE处理有冲突。实际测试中,先清除IDLE标志,再调用HAL_UART_IRQHandler是没问题的,因为HAL库本身不处理IDLE中断。
4.2 从DMA缓冲区搬运数据到环形队列
process_received_frame函数的任务是计算当前这一帧数据在uart_rx_buf中的位置和长度,然后把它们写入rx环形队列。
在Circular模式下,DMA现在写到了哪个位置,可以通过读取DMA的NDTR寄存器(剩余传输次数)来获取。HAL库提供了获取剩余数据计数的接口:
uint32_t current_pos = UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);这个current_pos表示DMA下一次要写入的位置,也就是已经写入数据的末尾+1。我们需要记录上一次处理时DMA写到的位置last_pos,那么本次接收到的新数据就是从last_pos到current_pos这一块区域。但因为是环形,中间可能越过数组末尾回绕,所以要分两段处理:
void process_received_frame(void) { uint32_t current_pos = UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (current_pos > last_rx_pos) { ring_buf_write_multi(&rx_ring, &uart_rx_buf[last_rx_pos], current_pos - last_rx_pos); } else if (current_pos < last_rx_pos) { ring_buf_write_multi(&rx_ring, &uart_rx_buf[last_rx_pos], UART_RX_BUF_SIZE - last_rx_pos); ring_buf_write_multi(&rx_ring, &uart_rx_buf[0], current_pos); } last_rx_pos = current_pos; }这个逻辑是整个接收流程的核心。last_rx_pos要定义为全局变量,初始化为0。
另外,还有一个更简单粗暴的方式,直接在IDLE中断里关闭DMA,读取数据,再重新开启DMA。好处是操作直观,坏处是关闭再开启DMA期间,如果有数据进来就会漏掉。我最终选择了上面的“位置差”算法,代码稍绕,但更稳定。
4.3 发送方向的具体实现
发送侧,我们使用Normal模式的DMA发送。思路是:先定义一个发送环形队列tx_ring,以及一个等待发送的缓冲区tx_dma_buf。主程序调用uart_send_data函数时,数据先写入tx_ring。
int uart_send_data(uint8_t *data, uint16_t len) { return ring_buf_write_multi(&tx_ring, data, len); }写入完成后,调用tx_start发送任务。这个任务会检查当前是否已经有DMA发送任务在跑,如果没有,就从tx_ring里取出一段数据放到tx_dma_buf,然后启动HAL_UART_Transmit_DMA。
void tx_start(void) { uint16_t len; if (tx_busy) return; len = ring_buf_read_multi(&tx_ring, tx_dma_buf, sizeof(tx_dma_buf)); if (len > 0) { tx_busy = 1; HAL_UART_Transmit_DMA(&huart1, tx_dma_buf, len); } }在DMA发送完成中断里,也就是HAL_UART_TxCpltCallback回调里,把tx_busy清0,再次调用tx_start,这样就能连续发送队列里的多包数据。这里必须判断tx_busy标志,否则可能出现同一缓冲区上的数据未发送完就再次启动DMA,导致数据错乱。
HAL库的发送完成回调:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { tx_busy = 0; tx_start(); } }这里有一个坑,HAL_UART_Transmit_DMA的缓冲区长度len如果为0,不会启动发送,所以我们的tx_start里要先判断读取到的len大于0。如果环形队列里暂时没数据了,就只把tx_busy清0,下次uart_send_data再调用tx_start即可。
4.4 中断优先级的分配
串口空闲中断和DMA中断的优先级需要合理设置。我建议把USART1全局中断优先级设成高于主循环里的其他任务,但不要高于系统时钟节拍中断。DMA中断的优先级可以比串口中断低一级,也可以和串口中断同一优先级,看是否使用了发送完成中断。
因为发送完成中断是一个“通知型”中断,不需要很紧急,而接收的空闲中断需要及时处理,避免用户数据滞留缓冲区。我的配置里用NVIC默认分组,USART1优先级设为2,DMA优先级设为3,实测没问题。
5. 常见问题与排查技巧实录
5.1 收到数据全是乱码或完全无数据
遇到这类问题,先不要怀疑代码逻辑,按顺序排查硬件和基础配置。第一,检查串口助手的波特率、数据位、校验位是否和单片机一致。第二,检查串口线是否接反,交叉线还是直通线。
如果硬件没问题,再检查DMA配置。我的经验是,接收DMA的Circular模式没有开启是最常见的乱码原因。如果CubeMX里误配成Normal模式,DMA收满缓冲区后会停止,再次调用HAL_UART_Receive_DMA之前数据不更新,表现出来就是只能收到固定数量的数据或完全不动。
还可以打开调试器看NDTR寄存器的值是否在变化。如果收到数据时NDTR会减少,说明DMA搬运正在进行;如果一直不变,检查DMA的请求源是否配置正确。
5.2 空闲中断一次不进或反复进
空闲中断不进,多半是初始化顺序问题。一定要在HAL_UART_Receive_DMA之后、再使能IDLE中断。不然可能因为DMA未启动,空闲标志在错误状态被清掉,后面再也进不了。
空闲中断反复进,则可能是清除IDLE标志的方式不对。HAL库里有__HAL_UART_CLEAR_IDLEFLAG宏,在F407上需要先读SR寄存器再读DR寄存器来清除。如果直接操作CR寄存器可能清不掉,或者不小心清掉了RXNE标志。我记得曾在网上看到过不同HAL版本对这个宏定义有差异,因此最好查看自己库文件里这一宏的实现方式。
5.3 接收数据时偶发丢字节
偶发丢字节一般有两个原因:一个是DMA缓冲区太小,导致一帧数据还没被空闲中断处理,DMA已经回绕写满了缓冲区,覆盖了还没处理的数据。二是空闲中断处理函数耗时太长,导致DMA新收到的数据来不及搬运,但因为DMA是硬件搬运,不存在被中断阻塞才丢字节的情况,丢字节更多是缓冲区覆盖。
解决办法是把DMA接收缓冲区设得足够大,至少比最大的数据帧长度大一倍,保证CPU在两次空闲中断之间能够处理完所有数据。
另一种情况是中断函数里用了HAL_UART_IRQHandler之后,由于HAL库默认没有IDLE处理,我们自己清标志后,HAL_UART_IRQHandler又做了一次额外的处理,导致标志被清除或状态错误。我建议只保留自己写的处理代码,不调用HAL_UART_IRQHandler,除非你同时用了其他串口中断,比如接收到字节中断。不过如果开启了RXNE中断,就不需要IDLE中断了,这和我们这套方案冲突,所以一般不开RXNE。
5.4 DMA发送只有第一包数据,后面不再发送
这个现象的重点在于“第一包成功,后续失败”,说明DMA通道被占用或传输未完成就被再次开启。检查发送完成回调里是否把tx_busy清零,是否重新调用tx_start。还有一点,如果发送缓冲区tx_dma_buf是局部数组或长度不够大,HAL_UART_Transmit_DMA在发送中途访问到了非法区域,也会导致DMA错误中止。
另外,HAL_UART_Transmit_DMA的发送是异步的,调用后立刻返回,此时数据还在从内存搬运到外设的路上。如果我们紧接着修改tx_dma_buf里的数据,就会破坏正在发送的内容。所以tx_dma_buf应该是独立的、不会在发送过程中被修改的数组。
5.5 环形队列读写指针错乱
发现队列数据明明没满,但写入时提示满了,或者读出来的数据顺序错乱,大概率是读写指针的临界区保护没做好。特别是中断里写、循环里读的情况,循环里读的时候如果被中断打断,而中断又调用了写函数,那么count变量会被两个上下文同时操作。
解决办法是在所有涉及读、写count和指针的代码段,都临时关闭中断。这块不能偷懒,哪怕你觉得上下文切换只花几个时钟周期,也一定要加保护。我在调试时用过一个土办法,在主循环里持续轮询队列长度并输出,同时串口不断发固定格式的数据,如果参数偶尔跳变,那多半就是临界区问题。
6. 这套方案后续还能怎么扩展
在我自己的多个项目里,这套基础结构反复使用,可以很轻松地扩展出其他功能。比如把USART1的收发统一抽象成接口,后续接ESP8266、GPS模块时,只需修改DMA映射和缓冲区大小。再比如用多个串口,只需要把环形队列结构体实例化多份,再为每个串口建立独立的收/发任务,逻辑完全一致。
还有一个值得尝试的扩展是给环形队列增加“丢弃最老数据”还是“拒绝新数据”的配置。某些实时性要求高的场景,比如接收遥控器信号,宁可丢掉老数据也要收到最新数据;某些数据完整性要求高的场景,比如文件传输,宁可阻塞也要保证不覆盖。可以根据项目需求,在写入函数里做策略切换。
如果你已经熟悉这套方案的每一行代码,可以进一步优化性能:把DMA直接搬运到双重缓冲区(Ping-Pong)中,或者用BDMA(仅适用于部分系列)来做外设到内存的搬运,进一步减少冲突。当然,F407上这个方案已经足够满足绝大多数嵌入式产品的实际需求了。
我在实际调试中最大的体会是:这套方案真正难的不是配置DMA,也不是写环形队列,而是理解“DMA写的是缓冲区,程序读的是队列”这一层抽象关系。想明白数据从哪里来、到哪里去,之后遇到任何问题都能逻辑清晰地排查。希望这篇文章能帮你少走弯路。
本文还有配套的精品资源,点击获取