ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F407串口收发方案:环形队列+DMA+空闲中断完整实现

STM32F407串口收发方案:环形队列+DMA+空闲中断完整实现 简介基于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作为调试串口波特率1152008位数据无校验1位停止位。用的PA9和PA10。硬件上如果走RS232或者RS485要额外加电平转换芯片这部分不影响DMA逻辑。2.2 CubeMX里必须勾选哪些选项打开CubeMX后选择芯片型号配置时钟树把USART1模式设为“Asynchronous”。然后在“DMA Settings”标签页添加USART1_RX和USART1_TX两个DMA请求。RX通道模式设置为Circular循环模式方向PeripheralToMemoryTX通道模式设置为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则回绕到0count加一。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优先级设为2DMA优先级设为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写的是缓冲区程序读的是队列”这一层抽象关系。想明白数据从哪里来、到哪里去之后遇到任何问题都能逻辑清晰地排查。希望这篇文章能帮你少走弯路。本文还有配套的精品资源点击获取
返回列表