资讯动态

STM32F407六路串口DMA收发实战:资源分配与避坑指南

发布时间:2026/10/20 20:50:00 来源:尧图企业网站定制
简介这份资源面向使用STM32F407进行多串口通信开发的嵌入式工程师与学生基于标准库实现6路USART配置并配套DMA发送与中断接收的完整工程代码可解决多设备并发通信、模块化设计及通信中心搭建等场景下的串口资源管理问题。压缩包共115个文件以50个.c源文件与50个.h头文件为核心涵盖stm32f4xx系列外设驱动与用户代码另含2个hex烧录文件、Keil工程文件uvprojx、uvopt及bat批处理脚本整体约552KB结构清晰便于直接编译与二次开发。目前已有3893人学习下载热度较高。读者可从中获取6路串口初始化、DMA通道分配与中断服务例程的完整实现思路理解GPIO、时钟与中断资源的协调配置并参考回调函数处理不同串口数据流适合作为多串口项目开发的参考模板与排错依据。1. 六路串口同时跑STM32F407 标准库这套 DMA 收发方案到底稳在哪去年帮一个做工业网关的朋友调板子STM32F407ZGT6 上挂了六路 RS485要求每路都能独立收发、互不阻塞。他一开始用最朴素的方式——每路串口都开接收中断主循环里轮询发送标志。结果波特率一上到 115200六路同时来数据CPU 有将近四成时间耗在中断进出上偶尔还会丢字节。后来换成 DMA 发送加中断接收的组合CPU 占用直接降到个位数连续跑七十二小时没再丢过一包。这套方案的核心思路不复杂发送用 DMA 把数据从内存搬到 USART 数据寄存器CPU 只管触发和等完成接收用 RXNE 中断逐字节取配合环形缓冲区做应用层解耦。STM32F407 有六个 USARTUSART1/2/3/6 UART4/5刚好够六路。标准库虽然现在新项目用得少了但存量项目维护、老代码移植、教学场景里依然是主力把六路串口的 DMA 收发吃透换到 HAL 库也是同样的底层逻辑。适合谁看手上有 F407 板子、需要多路串口并发、正在被中断风暴或丢包折磨的嵌入式开发者。下面从时钟和引脚分配开始一步步把六路配置、DMA 通道映射、中断接收和排错讲清楚。2. 六路 USART 的时钟、引脚与 DMA 通道分配先把资源表算明白2.1 为什么六路串口不能随便挑引脚STM32F407 的六个串口并不是每个都能自由映射到任意 GPIO复用功能AF是固定的。以 100 脚以上的封装为例常用分配是这样的串口TX 引脚RX 引脚AF 编号总线DMA 发送通道DMA 接收通道USART1PA9PA10AF7APB2DMA2_Stream7_CH4DMA2_Stream5_CH4USART2PA2PA3AF7APB1DMA1_Stream6_CH4DMA1_Stream5_CH4USART3PB10PB11AF7APB1DMA1_Stream3_CH4DMA1_Stream1_CH4UART4PC10PC11AF8APB1DMA1_Stream4_CH4DMA1_Stream2_CH4UART5PC12PD2AF8APB1DMA1_Stream7_CH4DMA1_Stream0_CH4USART6PC6PC7AF8APB2DMA2_Stream6_CH5DMA2_Stream1_CH5这张表是整套配置的地基。注意几个容易翻车的点USART1 和 USART6 挂在 APB2 上其余四个挂 APB1时钟使能要分开写UART5 的 RX 在 PD2 而不是 PC 口很多人照着 UART4 抄就错了DMA2 的通道和 DMA1 不通用USART1 的 TX 必须走 DMA2_Stream7 的 Channel4选错通道数据发不出去还不报错。提示如果你用的是 64 脚封装PC 口可能不全UART4/UART5 的引脚未必引出选型阶段就要确认封装。2.2 时钟树配置APB1 和 APB2 分频别搞混F407 默认跑 168MHzAPB1 最高 42MHzAPB2 最高 84MHz。串口波特率的计算依赖对应总线的时钟频率标准库里USART_Init的USART_BaudRate参数最终会通过RCC_GetClocksFreq反推分频值。如果 APB1 分频系数设错波特率会整体偏掉。// 系统时钟配置简化只列串口相关部分 // 假设外部晶振 8MHzPLL 倍频到 168MHz // APB1 42MHz, APB2 84MHz RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2 | RCC_APB1Periph_USART3 | RCC_APB1Periph_UART4 | RCC_APB1Periph_UART5, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_USART6, ENABLE); // GPIO 时钟PA、PB、PC、PD 全部使能 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOC | RCC_AHB1Periph_GPIOD, ENABLE); // DMA 时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1 | RCC_AHB1Periph_DMA2, ENABLE);这段代码的逻辑很直白先把六个串口按总线分组使能再把用到的 GPIO 端口和 DMA 控制器时钟打开。参数上唯一要注意的是RCC_APB1Periph_UART4和RCC_APB1Periph_UART5的宏名里是 UART 不是 USART写错了编译能过但时钟没开串口就是死的。2.3 GPIO 复用配置AF 编号写错是最隐蔽的坑每个串口的 TX/RX 都要配成复用模式AF 编号按 2.1 的表来。以 USART1 和 UART4 为例GPIO_InitTypeDef GPIO_InitStructure; // USART1: PA9(TX) PA10(RX) AF7 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd GPIO_PuPd_UP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); // UART4: PC10(TX) PC11(RX) AF8 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10 | GPIO_Pin_11; GPIO_Init(GPIOC, GPIO_InitStructure); GPIO_PinAFConfig(GPIOC, GPIO_PinSource10, GPIO_AF_UART4); GPIO_PinAFConfig(GPIOC, GPIO_PinSource11, GPIO_AF_UART4);GPIO_PinAFConfig的第二个参数是引脚源编号0~15第三个是 AF 编号。这里最常见的错误是把GPIO_AF_USART1写成GPIO_AF_USART2引脚配好了但信号走不到串口外设示波器量 TX 没波形。另一个坑是GPIO_PuPd设成GPIO_PuPd_NOPULLRS485 收发器在没有上拉时空闲电平不确定接收端会收到随机起始位。我一般接收脚统一上拉发送脚也上拉省得现场出玄学问题。3. DMA 发送配置从内存到 USART 数据寄存器的搬运逻辑3.1 DMA 发送的触发链路和通道选择DMA 发送的本质是应用层把待发数据放进缓冲区配置好 DMA 源地址内存、目标地址USART-DR、传输长度然后使能串口的 DMAT 位和 DMA 流。之后每次 USART 的 TXE 事件会触发 DMA 搬运一个字节直到传输完成产生中断。关键点在于通道映射不能错。F407 的 DMA 请求映射是硬件固定的USART1_TX 只能走 DMA2_Stream7 的 Channel4USART2_TX 走 DMA1_Stream6 的 Channel4以此类推。标准库里用DMA_InitStructure.DMA_Channel指定通道用DMA_InitStructure.DMA_PeripheralBaseAddr指定外设地址。void USART1_DMA_TX_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA2_Stream7); // 等待流复位完成否则后续配置可能不生效 while (DMA_GetCmdStatus(DMA2_Stream7) ! DISABLE) {} DMA_InitStructure.DMA_Channel DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)usart1_tx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_MemoryToPeripheral; DMA_InitStructure.DMA_BufferSize USART1_TX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_Medium; DMA_InitStructure.DMA_FIFOMode DMA_FIFOMode_Disable; DMA_InitStructure.DMA_FIFOThreshold DMA_FIFOThreshold_HalfFull; DMA_InitStructure.DMA_MemoryBurst DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream7, DMA_InitStructure); // 使能 DMA 传输完成中断 DMA_ITConfig(DMA2_Stream7, DMA_IT_TC, ENABLE); // 先不使能流等有数据要发时再开 DMA_Cmd(DMA2_Stream7, DISABLE); }逐参数说明DMA_DIR_MemoryToPeripheral表示内存到外设DMA_PeripheralInc_Disable是因为外设地址固定为 DR 寄存器DMA_MemoryInc_Enable让内存地址自增数据宽度都设 Byte和串口 8 位数据一致DMA_Mode_Normal表示发完一轮就停适合不定长发送如果要用循环模式得改成DMA_Mode_Circular。DMA_FIFOMode_Disable在直接模式下工作FIFO 模式适合需要打包不同位宽的场景串口发送用不上。3.2 发送函数如何判断上一包发完了DMA 发送最怕的是上一包还没发完就改缓冲区导致数据错乱。标准库没有现成的“发送完成”标志得自己用 TC 中断或者查DMA_GetCurrDataCounter来判断。volatile uint8_t usart1_tx_busy 0; void USART1_SendData(uint8_t *buf, uint16_t len) { if (usart1_tx_busy) { // 上一包还在发这里可以选择等待或丢弃 // 实际项目里我一般加超时等待避免死等 uint32_t timeout 0xFFFFF; while (usart1_tx_busy timeout--) {} if (usart1_tx_busy) return; // 超时放弃本次发送 } usart1_tx_busy 1; // 关闭 DMA 流才能改配置 DMA_Cmd(DMA2_Stream7, DISABLE); while (DMA_GetCmdStatus(DMA2_Stream7) ! DISABLE) {} DMA_SetCurrDataCounter(DMA2_Stream7, len); DMA_MemoryTargetConfig(DMA2_Stream7, (uint32_t)buf, DMA_Memory_0); // 清标志再使能 DMA_ClearFlag(DMA2_Stream7, DMA_FLAG_TCIF7 | DMA_FLAG_TEIF7 | DMA_FLAG_DMEIF7 | DMA_FLAG_FEIF7); DMA_Cmd(DMA2_Stream7, ENABLE); // 使能串口的 DMA 发送请求 USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); } // DMA 传输完成中断里清 busy 标志 void DMA2_Stream7_IRQHandler(void) { if (DMA_GetITStatus(DMA2_Stream7, DMA_IT_TCIF7)) { DMA_ClearITPendingBit(DMA2_Stream7, DMA_IT_TCIF7); DMA_Cmd(DMA2_Stream7, DISABLE); USART_DMACmd(USART1, USART_DMAReq_Tx, DISABLE); usart1_tx_busy 0; } }逻辑上分三步检查忙标志、重配 DMA、使能传输。DMA_SetCurrDataCounter必须在流关闭时调用否则新长度不生效。DMA_MemoryTargetConfig用来切换内存缓冲区地址标准库里这个函数比直接改寄存器安全。中断里除了清标志还要关掉串口的 DMA 请求不然 TXE 还会继续触发。六路串口各有一套这样的 busy 标志和中断处理DMA1 和 DMA2 的中断向量不同别把处理函数写串了。3.3 六路 DMA 发送的资源冲突排查DMA1 有 8 个流DMA2 有 8 个流六路串口发送占掉 6 个流接收如果也用 DMA 就再占 6 个总共 12 个理论上够。但要注意同一个 DMA 控制器里不同流的通道可以相同流不能相同。比如 DMA1_Stream6 和 DMA1_Stream5 分别给 USART2_TX 和 USART2_RX这没问题但如果把 UART5_TX 也分到 DMA1_Stream7而 USART1_TX 用了 DMA2_Stream7这也不冲突因为分属不同控制器。真正会翻车的是中断优先级。六个发送完成中断如果都设成同一优先级高波特率下会互相打断导致某个流的 TC 标志被覆盖。常见做法是把 DMA 发送完成中断优先级设得比串口接收中断低让接收先走发送完成晚一点处理没关系。4. 中断接收与环形缓冲区让六路数据不打架4.1 RXNE 中断接收的基本流程接收不用 DMA 而用中断是因为不定长数据用 DMA 接收需要配合空闲中断IDLE才能判断一帧结束标准库下配置更繁琐而且六路都开空闲中断对 CPU 还是有一定压力。RXNE 中断每来一个字节进一次配合环形缓冲区应用层从缓冲区取数据实时性够用。#define RX_BUF_SIZE 256 typedef struct { uint8_t buf[RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; ring_buf_t usart1_rx_ring; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); uint16_t next (usart1_rx_ring.head 1) % RX_BUF_SIZE; if (next ! usart1_rx_ring.tail) { usart1_rx_ring.buf[usart1_rx_ring.head] data; usart1_rx_ring.head next; } // 缓冲区满则丢弃不阻塞中断 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }环形缓冲区的 head 由中断写tail 由应用层读两者都是 volatile。判断满的条件是(head 1) % SIZE tail留一个空位区分满和空。中断里不做任何耗时操作取字节、存缓冲区、清标志三步结束。六路串口各有一个这样的中断处理函数和环形缓冲区互不干扰。4.2 应用层读取什么时候取、取多少应用层在主循环里轮询各路的 head 和 tail有数据就取出来处理。取的时候要注意临界区保护虽然单字节读写是原子的但 head 可能在读取过程中被中断修改。uint16_t USART1_ReadData(uint8_t *out, uint16_t max_len) { uint16_t count 0; while (count max_len usart1_rx_ring.tail ! usart1_rx_ring.head) { out[count] usart1_rx_ring.buf[usart1_rx_ring.tail]; usart1_rx_ring.tail (usart1_rx_ring.tail 1) % RX_BUF_SIZE; } return count; }这个函数返回实际读到的字节数应用层根据协议判断一帧是否完整。如果协议有帧头帧尾可以在应用层做状态机解析如果是定长帧读够长度就处理。六路可以共用一个读取函数传入不同的 ring_buf_t 指针即可减少重复代码。4.3 中断优先级分组六路接收怎么排F407 的中断优先级用 NVIC 管理分组方式决定抢占优先级和子优先级的位数。六路串口接收中断如果都设成一样高优先级的中断会打断低优先级的但同优先级之间不能互相打断。我一般把六路接收设成同一抢占优先级、不同子优先级这样它们之间不会互相打断但都能被更高优先级的系统中断比如 DMA 错误打断。NVIC_InitTypeDef NVIC_InitStructure; // 优先级分组2 位抢占2 位子优先级 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // USART2 子优先级 1以此类推 NVIC_InitStructure.NVIC_IRQChannel USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_Init(NVIC_InitStructure);抢占优先级相同意味着它们不能互相打断子优先级只在同时挂起时决定谁先响应。这样六路接收公平竞争不会出现某一路被其他路打断导致丢字节的情况。DMA 发送完成中断的抢占优先级设成 3比接收低保证接收优先。5. 避坑与排查六路串口 DMA 收发最容易翻车的五个点5.1 现象某一路发送没波形其他路正常原因DMA 通道选错或者 GPIO 的 AF 编号写错。USART1_TX 必须用 DMA2_Stream7 的 Channel4如果误用了 DMA1 的流DMA 请求根本不会触发。AF 编号写错则信号到不了串口外设。解决对照 2.1 的资源表逐项核对DMA_Channel和GPIO_PinAFConfig的 AF 参数。用示波器量 TX 引脚如果配置正确但没波形检查USART_DMACmd是否使能以及 DMA 流是否真的启动了。5.2 现象接收偶尔丢一两个字节波特率越高越明显原因中断里做了耗时操作或者环形缓冲区太小导致溢出。另一个常见原因是 RXNE 标志清除时机不对标准库里USART_ReceiveData读 DR 会自动清 RXNE但如果先清标志再读数据可能丢字节。解决中断里只做取数据和存缓冲区其他逻辑全部挪到主循环。缓冲区至少 256 字节高波特率下建议 512。清除标志用USART_ClearITPendingBit放在读数据之后或者干脆不手动清靠读 DR 自动清。5.3 现象DMA 发送完一包后第二包数据错位原因第一包还没发完就改了 DMA 的内存地址或长度。DMA_SetCurrDataCounter和DMA_MemoryTargetConfig必须在流关闭时调用流还在跑的时候改配置DMA 内部指针会乱。解决发送函数里先DMA_Cmd(DISABLE)然后while (DMA_GetCmdStatus() ! DISABLE)等流真正停下来再改配置。TC 中断里也要把流关掉避免残留状态影响下一包。5.4 现象六路同时收发时系统偶尔死机原因中断优先级配置不当DMA 发送完成中断和串口接收中断互相抢占导致栈溢出或标志位竞争。另一个可能是 DMA 传输错误中断没处理出错后流卡死。解决接收中断抢占优先级高于发送完成中断。使能 DMA 的 TE传输错误中断在错误处理里清标志、关流、置错误码。栈大小检查一下中断嵌套层数多的时候默认栈可能不够。5.5 现象波特率实际值和设定值偏差大原因APB1 或 APB2 的时钟频率和预期不符比如外部晶振没起振系统跑在内部 HSI 上。或者USART_Init里的USART_BaudRate传了整数但实际需要小数分频。解决用RCC_GetClocksFreq打印实际总线频率确认 PLL 配置正确。F407 的串口波特率寄存器支持小数分频标准库会自动算但如果时钟源偏差超过 2%通信就会不稳定。换晶振或者调整 PLL 参数。6. 进阶技巧用 DMA 双缓冲和空闲中断把接收也卸下来六路接收全用 RXNE 中断在 115200 波特率下每路每秒最多 11520 次中断六路加起来将近七万次CPU 还是有一定负担。如果波特率再高或者数据量再大可以把接收也改成 DMA配合空闲中断判断帧结束。思路是每路接收配一个 DMA 流循环模式缓冲区设成两半。DMA 搬运一半时产生 HT 中断搬完一整轮产生 TC 中断同时串口的 IDLE 中断在总线空闲时触发。在 IDLE 中断里计算DMA_GetCurrDataCounter的剩余量就能知道这一帧收了多少字节。#define RX_DMA_BUF_SIZE 128 uint8_t usart1_rx_dma_buf[RX_DMA_BUF_SIZE]; void USART1_RX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA2_Stream5); while (DMA_GetCmdStatus(DMA2_Stream5) ! DISABLE) {} DMA_InitStructure.DMA_Channel DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)usart1_rx_dma_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize RX_DMA_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream5, DMA_InitStructure); DMA_Cmd(DMA2_Stream5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); } // 空闲中断里计算帧长度 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 读 SR 再读 DR 清 IDLE 标志 volatile uint32_t tmp USART1-SR; tmp USART1-DR; (void)tmp; uint16_t remain DMA_GetCurrDataCounter(DMA2_Stream5); uint16_t recv_len RX_DMA_BUF_SIZE - remain; // recv_len 就是这一帧的字节数交给应用层处理 // 处理完记得重置 DMA 计数器 DMA_Cmd(DMA2_Stream5, DISABLE); DMA_SetCurrDataCounter(DMA2_Stream5, RX_DMA_BUF_SIZE); DMA_Cmd(DMA2_Stream5, ENABLE); } }这套方案的好处是接收几乎不占 CPU每帧只进一次 IDLE 中断。代价是配置复杂度上升六路都要配 DMA 流和 IDLE 中断DMA 流资源要重新规划。另外循环模式下缓冲区是覆盖写的如果应用层处理不及时新数据会盖掉旧数据所以 IDLE 中断里要尽快把数据拷走。我一般会在 IDLE 中断里只做长度计算和数据拷贝到二级缓冲区解析逻辑放到主循环。拷贝用memcpy长度就是recv_len。如果六路都这么搞DMA1 和 DMA2 的流基本占满得仔细排一下资源表避免流冲突。从那以后我每次配多路串口都先把资源表和中断优先级画在纸上确认 DMA 流不冲突、AF 编号没错、时钟分组正确再动手写代码。这套流程帮我省了不少返工时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑