简介:这份资源面向使用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 有六个 USART(USART1/2/3/6 + UART4/5),刚好够六路。标准库虽然现在新项目用得少了,但存量项目维护、老代码移植、教学场景里依然是主力,把六路串口的 DMA 收发吃透,换到 HAL 库也是同样的底层逻辑。
适合谁看:手上有 F407 板子、需要多路串口并发、正在被中断风暴或丢包折磨的嵌入式开发者。下面从时钟和引脚分配开始,一步步把六路配置、DMA 通道映射、中断接收和排错讲清楚。
2. 六路 USART 的时钟、引脚与 DMA 通道分配:先把资源表算明白
2.1 为什么六路串口不能随便挑引脚
STM32F407 的六个串口并不是每个都能自由映射到任意 GPIO,复用功能(AF)是固定的。以 100 脚以上的封装为例,常用分配是这样的:
| 串口 | TX 引脚 | RX 引脚 | AF 编号 | 总线 | DMA 发送通道 | DMA 接收通道 |
|---|---|---|---|---|---|---|
| USART1 | PA9 | PA10 | AF7 | APB2 | DMA2_Stream7_CH4 | DMA2_Stream5_CH4 |
| USART2 | PA2 | PA3 | AF7 | APB1 | DMA1_Stream6_CH4 | DMA1_Stream5_CH4 |
| USART3 | PB10 | PB11 | AF7 | APB1 | DMA1_Stream3_CH4 | DMA1_Stream1_CH4 |
| UART4 | PC10 | PC11 | AF8 | APB1 | DMA1_Stream4_CH4 | DMA1_Stream2_CH4 |
| UART5 | PC12 | PD2 | AF8 | APB1 | DMA1_Stream7_CH4 | DMA1_Stream0_CH4 |
| USART6 | PC6 | PC7 | AF8 | APB2 | DMA2_Stream6_CH5 | DMA2_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 默认跑 168MHz,APB1 最高 42MHz,APB2 最高 84MHz。串口波特率的计算依赖对应总线的时钟频率,标准库里USART_Init的USART_BaudRate参数最终会通过RCC_GetClocksFreq反推分频值。如果 APB1 分频系数设错,波特率会整体偏掉。
// 系统时钟配置(简化,只列串口相关部分) // 假设外部晶振 8MHz,PLL 倍频到 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_NOPULL,RS485 收发器在没有上拉时空闲电平不确定,接收端会收到随机起始位。我一般接收脚统一上拉,发送脚也上拉,省得现场出玄学问题。
3. DMA 发送配置:从内存到 USART 数据寄存器的搬运逻辑
3.1 DMA 发送的触发链路和通道选择
DMA 发送的本质是:应用层把待发数据放进缓冲区,配置好 DMA 源地址(内存)、目标地址(USART->DR)、传输长度,然后使能串口的 DMAT 位和 DMA 流。之后每次 USART 的 TXE 事件会触发 DMA 搬运一个字节,直到传输完成产生中断。
关键点在于通道映射不能错。F407 的 DMA 请求映射是硬件固定的,USART1_TX 只能走 DMA2_Stream7 的 Channel4,USART2_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 编号没错、时钟分组正确,再动手写代码。这套流程帮我省了不少返工时间。希望帮到你。
本文还有配套的精品资源,点击获取