简介:这份资源面向使用STM32F407标准库进行多串口开发的嵌入式工程师与学生,聚焦6路USART的完整配置方案,解决多设备通信、模块化设计及并发数据流处理中的串口资源管理问题。包内共115个文件,以50个.c与50个.h源码文件为核心,涵盖USART、TIM、RCC、ADC、CAN等外设驱动,另含2个hex烧录文件、Keil工程文件(uvprojx、uvopt)及bat批处理脚本,压缩包约552KB,结构完整可直接编译。已有3893人学习下载,说明该方案在同类多串口应用中具有较高参考价值。读者可从中获取6路串口初始化、DMA发送通道分配、中断接收回调及多串口并行运行的实现思路,理解GPIO、时钟与DMA资源不冲突的配置方法,并借助工程文件快速搭建调试环境,适合需要提升串口通信效率与系统稳定性的中高级开发者参考。
1. 六路串口一起上:STM32F407 标准库环境到底怎么搭
第一次在 STM32F407 上同时开六路串口,是在一个多传感器采集的项目里。六路 UART 要同时跑,每路波特率还不一样,有的接上位机、有的接模块、有的做调试口。当时想当然地以为“标准库嘛,复制粘贴改个 USART 编号就行”,结果第一版代码烧进去,串口要么一个都收不到数据,要么 DMA 发到一半就卡死,调试口打印出来的全是乱码。后来才发现,标准库下多串口配置远不是改个宏定义那么简单——时钟使能、GPIO 复用、中断优先级、DMA 通道映射,每一项都有坑。
这套环境适合两类人:一是刚接触 STM32F407 标准库、想从单串口过渡到多串口的开发者;二是项目里确实需要多路串口并发、又不想上 HAL 库的团队。标准库虽然老,但在 F407 这种经典芯片上资料多、代码量小、执行效率高,很多工业现场还在用。装不好会怎样?轻则某一路串口不工作,重则 DMA 和中断打架,整个系统跑飞。下面按我实际搭环境的顺序,从工程创建到六路串口全部跑通,一步步来。
2. 工程骨架与时钟配置:标准库版本选择和基础环境搭建
2.1 标准库版本怎么选,别拿 F1 的库硬套 F4
STM32F407 属于 F4 系列,标准库要用STM32F4xx_DSP_StdPeriph_Lib_V1.8.0或更高版本。我见过有人拿 F1 的stm32f10x.h改改就往上套,编译能过,但时钟树配置完全对不上,串口波特率算出来全是错的。F4 的库文件结构是这样的:
STM32F4xx_DSP_StdPeriph_Lib_V1.8.0/ ├── Libraries/ │ ├── CMSIS/ │ │ ├── Device/ST/STM32F4xx/ │ │ │ ├── Include/ # stm32f4xx.h 等头文件 │ │ │ └── Source/Templates/ # system_stm32f4xx.c 启动文件 │ │ └── Include/ # core_cm4.h 等内核头文件 │ └── STM32F4xx_StdPeriph_Driver/ │ ├── inc/ # 外设驱动头文件 │ └── src/ # 外设驱动源文件 ├── Project/ │ └── STM32F4xx_StdPeriph_Templates/ │ ├── main.c │ ├── stm32f4xx_conf.h # 外设配置裁剪头 │ └── stm32f4xx_it.c/h # 中断服务函数 └── Utilities/我一般会把Libraries整个拷到工程目录下,然后在 IDE 里添加头文件搜索路径。关键一步是stm32f4xx_conf.h,这个文件决定哪些外设驱动参与编译。六路串口要用到USART、GPIO、DMA、RCC、NVIC,对应的宏都要打开:
/* stm32f4xx_conf.h 中确保以下模块被使能 */ #include "stm32f4xx_rcc.h" #include "stm32f4xx_gpio.h" #include "stm32f4xx_usart.h" #include "stm32f4xx_dma.h" #include "misc.h" /* NVIC 优先级分组配置 */这里有个细节:misc.h不在STM32F4xx_StdPeriph_Driver里,它在Libraries/CMSIS/Device/ST/STM32F4xx/下面,路径别加错。少了它,NVIC_Init和NVIC_PriorityGroupConfig都找不到。
2.2 系统时钟树配置:168MHz 下串口波特率才准
F407 标准库默认的系统时钟是 16MHz 内部 RC,直接拿来跑串口,波特率误差大得离谱。我一般用外部 8MHz 晶振,PLL 倍频到 168MHz。这段代码在system_stm32f4xx.c里改,或者自己写一个SystemInit覆盖:
/* 系统时钟配置:HSE 8MHz -> PLL -> 168MHz */ void SystemClock_Config(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); /* 等待 HSE 就绪,超时则停在这里方便排查 */ if (RCC_WaitForHSEStartUp() != SUCCESS) { while (1); } RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB = 168MHz */ RCC_PCLK1Config(RCC_HCLK_Div4); /* APB1 = 42MHz,USART2~5 挂这里 */ RCC_PCLK2Config(RCC_HCLK_Div2); /* APB2 = 84MHz,USART1/6 挂这里 */ RCC_PLLConfig(RCC_PLLSource_HSE, 8, 336, 2, 7); RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() != 0x08); }参数说明:RCC_PLLConfig的五个参数分别是 PLL 源、M、N、P、Q。M=8 表示 8MHz 先分频到 1MHz,N=336 倍频到 336MHz,P=2 再分频得到 168MHz。Q=7 是给 USB 用的,这里不影响串口。APB1 分频 4 得到 42MHz,APB2 分频 2 得到 84MHz,这两个频率直接决定串口波特率的计算基准。如果 APB 频率设错,USART_Init里算出来的 BRR 值就是错的,现象就是能发不能收,或者收全是乱码。
提示:改完时钟后,先用调试口打印一下
RCC_GetClocksFreq的结果,确认 PCLK1 和 PCLK2 跟预期一致,再往下配串口。
3. 六路串口逐个配:GPIO 复用、USART 参数与中断优先级
3.1 六路串口的引脚分配和时钟使能
F407 有 6 个 USART/UART:USART1、USART2、USART3、UART4、UART5、USART6。我用的引脚分配如下,这套分配避开了 JTAG 和 SDIO 的冲突引脚:
| 串口 | TX 引脚 | RX 引脚 | 总线 | 时钟宏 |
|---|---|---|---|---|
| USART1 | PA9 | PA10 | APB2 | RCC_APB2Periph_USART1 |
| USART2 | PA2 | PA3 | APB1 | RCC_APB1Periph_USART2 |
| USART3 | PB10 | PB11 | APB1 | RCC_APB1Periph_USART3 |
| UART4 | PC10 | PC11 | APB1 | RCC_APB1Periph_UART4 |
| UART5 | PC12 | PD2 | APB1 | RCC_APB1Periph_UART5 |
| USART6 | PC6 | PC7 | APB2 | RCC_APB2Periph_USART6 |
时钟使能要分两条总线写,别一股脑全塞进 APB2:
/* 使能 GPIO 和串口时钟 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOC | RCC_AHB1Periph_GPIOD, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_USART6, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2 | RCC_APB1Periph_USART3 | RCC_APB1Periph_UART4 | RCC_APB1Periph_UART5, ENABLE);GPIO 初始化时,模式要设成GPIO_Mode_AF,输出类型GPIO_OType_PushPull,复用功能用GPIO_PinAFConfig指定。以 USART1 为例:
GPIO_InitTypeDef GPIO_InitStruct; GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_100MHz; GPIO_InitStruct.GPIO_OType = GPIO_OType_PushPull; GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1);其他五路照这个模板改引脚和复用宏就行。注意 UART4 和 UART5 的复用宏是GPIO_AF_UART4和GPIO_AF_UART5,不是GPIO_AF_USART4,写错了编译不报错但引脚没复用上。
3.2 USART 参数配置:波特率、字长和校验位
六路串口波特率我设成三组:USART1 和 USART6 用 115200,USART2 和 USART3 用 9600,UART4 和 UART5 用 38400。配置函数统一写一个,传参进去:
void USART_Config(USART_TypeDef* USARTx, uint32_t baudrate) { USART_InitTypeDef USART_InitStruct; USART_InitStruct.USART_BaudRate = baudrate; USART_InitStruct.USART_WordLength = USART_WordLength_8b; USART_InitStruct.USART_StopBits = USART_StopBits_1; USART_InitStruct.USART_Parity = USART_Parity_No; USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USARTx, &USART_InitStruct); USART_Cmd(USARTx, ENABLE); }USART_BaudRate填的是实际波特率数值,标准库内部会根据 PCLK 频率自动算 BRR。如果 PCLK 配置错了,这里填 115200 实际输出可能是 9600 的波形。USART_WordLength_8b配合USART_Parity_No就是最常见的 8N1 格式。如果接的模块需要偶校验,把USART_Parity改成USART_Parity_Even,同时字长要改成USART_WordLength_9b,否则校验位会挤掉数据位。
3.3 中断接收配置:NVIC 优先级分组和使能
六路串口都要中断接收,NVIC 优先级分组必须先定好。我一般用NVIC_PriorityGroup_2,2 位抢占优先级、2 位响应优先级,够用且不容易乱:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitTypeDef NVIC_InitStruct; NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0; NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStruct);六路串口的中断通道分别是USART1_IRQn、USART2_IRQn、USART3_IRQn、UART4_IRQn、UART5_IRQn、USART6_IRQn。抢占优先级我按串口重要性排:USART1 最高设 1,USART6 设 2,其余设 3。响应优先级全设 0。这样高优先级串口的中断可以打断低优先级串口的处理,适合调试口和关键指令口。
使能接收中断用USART_ITConfig(USARTx, USART_IT_RXNE, ENABLE),然后在对应的stm32f4xx_it.c里写中断服务函数。每个串口的中断函数名不能写错,比如 UART4 的是UART4_IRQHandler,不是USART4_IRQHandler,写错了编译能过但中断永远不进。
4. DMA 发送配置:通道映射、传输模式和发送完成判断
4.1 DMA 通道映射表:六路串口分别走哪个流
F407 的 DMA 有两个控制器,DMA1 有 7 个流,DMA2 有 7 个流。串口发送的 DMA 请求映射是固定的,不能随便选:
| 串口 | DMA 控制器 | 流 | 通道 |
|---|---|---|---|
| USART1_TX | DMA2 | Stream7 | Channel4 |
| USART2_TX | DMA1 | Stream6 | Channel4 |
| USART3_TX | DMA1 | Stream3 | Channel4 |
| UART4_TX | DMA1 | Stream4 | Channel4 |
| UART5_TX | DMA1 | Stream7 | Channel4 |
| USART6_TX | DMA2 | Stream6 | Channel5 |
这张表我建议贴在显示器边上,配错一个流,对应的串口就发不出数据,而且不会报错,只是 DMA 传输完成标志永远不置位。
4.2 DMA 初始化:内存到外设模式的关键参数
以 USART1 的 DMA 发送为例:
DMA_InitTypeDef DMA_InitStruct; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_DeInit(DMA2_Stream7); DMA_InitStruct.DMA_Channel = DMA_Channel_4; DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)tx_buffer; DMA_InitStruct.DMA_DIR = DMA_DIR_MemoryToPeripheral; DMA_InitStruct.DMA_BufferSize = tx_len; DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStruct.DMA_Mode = DMA_Mode_Normal; DMA_InitStruct.DMA_Priority = DMA_Priority_Medium; DMA_InitStruct.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream7, &DMA_InitStruct);DMA_DIR_MemoryToPeripheral表示从内存搬到外设,这是发送方向。DMA_PeripheralInc_Disable是因为外设地址固定是USART1->DR,不能自增。DMA_Mode_Normal表示传完指定长度就停,适合不定长发送;如果要循环发送固定数据,改成DMA_Mode_Circular。DMA_FIFOMode_Disable在直接模式下用,开了 FIFO 反而容易出玄学问题,我一般关掉。
使能 DMA 发送前,要先使能串口的 DMA 发送请求:
USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); DMA_Cmd(DMA2_Stream7, ENABLE);4.3 发送完成判断:别用延时,用标志位
DMA 发送最忌讳发完就delay_ms等。正确做法是查DMA_FLAG_TCIF7或者开传输完成中断:
/* 等待 DMA 发送完成 */ while (DMA_GetFlagStatus(DMA2, DMA_FLAG_TCIF7) == RESET); DMA_ClearFlag(DMA2, DMA_FLAG_TCIF7);如果是六路同时发,每路的标志位不一样,要分别判断。我一般给每路串口维护一个发送状态标志,DMA 传输完成中断里置位,主循环里检查。这样六路 DMA 可以并发启动,互不阻塞。
注意:DMA 传输完成标志在
DMA_Mode_Normal下不会自动清除,必须手动DMA_ClearFlag,否则第二次发送时标志一直是置位的,判断逻辑就失效了。
5. 避坑与排查:六路串口跑不起来时先查这五处
5.1 现象:某一路串口完全无输出,其他五路正常
原因:DMA 流或通道映射错了。F407 的 DMA 请求映射是硬件固定的,比如 UART5_TX 只能用 DMA1_Stream7_Channel4,换成别的流,DMA 控制器收不到串口的请求信号,数据永远搬不出去。
解决:对照 4.1 的映射表逐路核对DMA_InitStruct.DMA_Channel和DMA_DeInit的流编号。改完后先单独测这一路,确认能发再合入六路。
5.2 现象:串口能发不能收,或者收几个字节就卡死
原因:中断优先级分组没设,或者NVIC_PriorityGroupConfig在NVIC_Init之后才调用。F407 的优先级分组是全局的,必须在所有NVIC_Init之前设好。另外,如果中断服务函数里没有清USART_IT_RXNE标志,会反复进中断,主循环被拖死。
解决:把NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)放到main开头,所有外设初始化之前。中断服务函数里第一句就USART_ClearITPendingBit(USARTx, USART_IT_RXNE),再读USART_ReceiveData。
5.3 现象:DMA 发送的数据只有第一个字节对,后面全是错位
原因:DMA_MemoryInc设成了Disable,内存地址不自增,DMA 每次都从同一个地址搬数据,发出去的全是第一个字节。或者DMA_PeripheralDataSize和DMA_MemoryDataSize不一致,比如外设设 Byte、内存设 HalfWord,数据就乱了。
解决:发送方向必须DMA_MemoryInc_Enable,外设地址DMA_PeripheralInc_Disable。两边数据宽度统一设DMA_PeripheralDataSize_Byte和DMA_MemoryDataSize_Byte,除非你确定要传半字。
5.4 现象:六路串口同时收发时,系统偶尔死机
原因:DMA 传输完成中断和串口接收中断的优先级冲突,或者两个中断里操作了同一个全局变量没有保护。标准库没有自动的临界区保护,六路并发时共享的发送缓冲区索引、接收环形缓冲区指针都可能被踩。
解决:给每路串口独立的发送缓冲区和接收缓冲区,不要共用。DMA 传输完成中断的抢占优先级设得比串口接收中断低,让接收先处理。共享的计数器用__disable_irq()和__enable_irq()包住,或者用原子操作。
5.5 现象:波特率高的串口(115200)误码率高,低的正常
原因:APB 时钟分频不对,导致高波特率下 BRR 误差超过 3%。F407 的 USART1 和 USART6 挂 APB2,最高 84MHz;USART2~5 挂 APB1,最高 42MHz。如果 APB1 分频设成 2 得到 84MHz,超了 42MHz 上限,时钟树虽然能跑,但串口时序会飘。
解决:确认RCC_PCLK1Config是RCC_HCLK_Div4,RCC_PCLK2Config是RCC_HCLK_Div2。用示波器量一下 TX 引脚波形,115200 波特率下位宽应该是 8.68us 左右,偏差超过 0.3us 就要回头查时钟。
6. 装完怎么验证:六路串口回环测试和 DMA 发送计数
环境搭完,别急着接实际模块,先做回环测试。把每路串口的 TX 和 RX 用杜邦线短接,发什么收什么,六路同时跑,看有没有丢包。我一般写一个简单的测试函数:
/* 六路串口回环测试:发送递增数据,接收比对 */ void USART_LoopbackTest(void) { uint8_t tx_data[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; uint8_t rx_data[6] = {0}; USART_TypeDef* usart_list[6] = {USART1, USART2, USART3, UART4, UART5, USART6}; DMA_Stream_TypeDef* dma_stream[6] = {DMA2_Stream7, DMA1_Stream6, DMA1_Stream3, DMA1_Stream4, DMA1_Stream7, DMA2_Stream6}; uint32_t dma_flag[6] = {DMA_FLAG_TCIF7, DMA_FLAG_TCIF6, DMA_FLAG_TCIF3, DMA_FLAG_TCIF4, DMA_FLAG_TCIF7, DMA_FLAG_TCIF6}; DMA_TypeDef* dma_ctrl[6] = {DMA2, DMA1, DMA1, DMA1, DMA1, DMA2}; for (int i = 0; i < 6; i++) { /* 启动 DMA 发送 */ DMA_SetCurrDataCounter(dma_stream[i], 1); DMA_Cmd(dma_stream[i], ENABLE); /* 等待发送完成 */ while (DMA_GetFlagStatus(dma_ctrl[i], dma_flag[i]) == RESET); DMA_ClearFlag(dma_ctrl[i], dma_flag[i]); /* 等待接收 */ while (USART_GetFlagStatus(usart_list[i], USART_FLAG_RXNE) == RESET); rx_data[i] = USART_ReceiveData(usart_list[i]); /* 比对 */ if (rx_data[i] != tx_data[i]) { /* 记录哪一路出错,方便定位 */ printf("USART%d loopback fail: tx=0x%02X rx=0x%02X\r\n", i + 1, tx_data[i], rx_data[i]); } } }这段代码一次测六路,每路发一个字节收一个字节。如果某一路rx_data和tx_data对不上,先查这一路的 GPIO 复用和 DMA 映射,再查波特率。六路全过之后,再把数据长度加到 64 字节、256 字节,测 DMA 的BufferSize和内存自增有没有问题。
验证 DMA 发送是否真的搬完了数据,不能只看DMA_Cmd使能了,要看DMA_GetCurrDataCounter是不是减到 0。我习惯在发送完成后读一次计数器:
/* 确认 DMA 实际传输的字节数 */ uint16_t remaining = DMA_GetCurrDataCounter(DMA2_Stream7); if (remaining != 0) { /* 没传完,可能是流被意外关闭或请求没使能 */ printf("DMA not finished, remaining=%d\r\n", remaining); }还有一个容易忽略的点:六路串口同时做回环时,如果某一路的接收中断优先级设得太低,被其他串口的中断连续打断,USART_FLAG_RXNE可能溢出。现象是偶尔收到 0x00 或者上一次的残留数据。解决办法是把接收中断的抢占优先级统一设成一样,响应优先级按串口编号排,保证每个中断都能及时进。
从那以后我每次配多路串口,都强制走一遍「时钟树确认 → 引脚复用核对 → DMA 映射表对照 → 回环测试」这四步,少一步后面就要花两倍时间查玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取