资讯详情 STM32F407六路串口配置:时钟、引脚与中断资源分配
📅 2026/10/26 11:42:24
简介面向STM32F407多串口通信开发的嵌入式学习者与工程师这套工程示例聚焦UART1至UART6同时启用时的初始化、收发与移植裁剪问题。代码由CubeMX生成六个串口均启用DMA与空闲中断可接收不定长度数据适用于多传感器、多模块并行通信和串口协议调试等场景。压缩包共67个文件、约581KB含41个头文件、19个C源文件以及CubeMX配置、Keil MDK工程、启动汇编和说明文本便于直接编译并按需裁剪串口数量。默认使用片外8MHz晶振可在图形化配置中修改裁剪后只需删除少量与硬件资源相关的代码即可复用。已有7803人学习工程结构完整可参考HAL库串口驱动、DMA空闲中断处理、中断服务与时钟配置快速搭建六串口通信框架并迁移到自己的项目。1. 六路串口同时跑卡住你的从来不是第六个初始化函数去年帮人调一块 F407 的板子上面挂了 GPS、蓝牙、RS485 总线、上位机、调试口和一个预留的无线模块座。六路串口每一路单独写测试代码都通六路一起上电就只活下来三路。折腾到半夜才发现问题根本不在“第六个 USART 怎么初始化”而在开头没算清的两件事USART1 和 USART6 挂在 APB2 上其余四路在 APB1 上两条总线的时钟差了整整一倍UART5 的 TX 和 RX 还不在同一个 GPIO 端口上。STM32F407 的六串口同时使用本质上是一道资源分配题——时钟总线、复用引脚、中断优先级、接收缓冲这四笔账在写第一行代码之前就得算平否则后面每加一路都是在上一次的错误上叠加。这份配置资源就是把这四笔账一次性摊开的一份表驱动的初始化框架加上六路中断/DMA 的通道划分板子换引脚时只改一张表。适合已经把单路串口跑通、正要往多外设集成上走的嵌入式开发者。2. 时钟与引脚先算账APB1/APB2 分家和 6 组复用脚的取舍2.1 六路串口不在一条总线上波特率天花板直接差一倍STM32F407 的六个串口并不是平均分布的。USART1 和 USART6 走 APB2USART2、USART3、UART4、UART5 走 APB1。在 168MHz 主频的标准配置下APB2 是 84MHzAPB1 被限制在 42MHz这是 F4 系列的硬性上限超过就跑飞。过采样 16 倍时波特率的天花板就是总线频率除以 16所以 APB2 上的两路理论能到 5.25MbpsAPB1 上的四路只有 2.625Mbps。这个差异在 115200 这种低速下完全看不出来但一旦有一路要跑到 921600 甚至 1.5Mbps选哪一路就是生死问题。我的习惯是把最高速的那路业务比如图像模块或者高速采样的上位机通道优先安排给 USART1 或 USART6。串口挂载总线总线时钟168MHz 主频时钟使能宏理论最高波特率USART1APB284 MHzRCC_APB2Periph_USART15.25 MbpsUSART6APB284 MHzRCC_APB2Periph_USART65.25 MbpsUSART2APB142 MHzRCC_APB1Periph_USART22.625 MbpsUSART3APB142 MHzRCC_APB1Periph_USART32.625 MbpsUART4APB142 MHzRCC_APB1Periph_UART42.625 MbpsUART5APB142 MHzRCC_APB1Periph_UART52.625 Mbps表里的“理论最高”是按 USARTDIV 最小值 1 算出来的极限实际工程上一般打对折用。因为波特率越接近上限BRR 寄存器的取整误差占比越大抗时钟漂移的余量就越少。提示UART4 和 UART5 的名字里没有 “S”不是 USART它们不支持同步模式和 IrDA 等部分功能但异步收发用法和 USART2/3 完全一致寄存器基址不同而已。2.2 引脚分配表和三处必须提前躲开的复用冲突F407 的串口引脚大部分有重映射备选这给了布局很大自由度但也埋了坑。下面这张表是六路全开时最常用的一组分配我把每一路的 TX/RX 端口、复用编号和典型冲突都列了出来串口TXRXAF 编号备选引脚需要警惕的冲突USART1PA9PA10AF7PB6/PB7PA9 兼 USB_OTG_FS_VBUSPA11/PA12 也是 USBUSART2PA2PA3AF7PD5/PD6PA2 兼 ETH_MDIO带以太网的板子要留意USART3PB10PB11AF7PC10/PC11、PD8/PD9PB10/PB11 兼 I2C2PC10/PC11 兼 SDIO_D2/D3UART4PA0PA1AF8PC10/PC11PA0 是 WKUP 唤醒脚PA1 兼 ETH_RMII_REF_CLKUART5PC12PD2AF8无PC12 兼 SDIO_CK 和 SPI3_MOSIPD2 兼 SDIO_CMDUSART6PC6PC7AF8PG14/PG9PC6/PC7 兼 SDIO_D6/D7、TIM8_CH1/CH2、I2S2三处最容易翻车的地方UART5 的 TX 和 RX 跨端口。这是六路里唯一一路 TX 在 GPIOC、RX 在 GPIOD 的串口。很多人照着前四路的模板复制粘贴把两个引脚都往 GPIOC 上挂结果 RX 永远收不到东西示波器量 PD2 明明有波形。初始化结构体里必须把 TX 和 RX 的端口、引脚、时钟分开存。USART6 和 SDIO 抢 PC6/PC7。如果板子上有 SD 卡走 4 位模式PC6/PC7 就是 SDIO_D6/D7两者只能二选一。要么 SD 卡降到 1 位模式只用 PC8/PC9/PC10/PC11/PC12/PD2要么 USART6 换到 PG14/PG9——但 PG 口只在 144 脚以上的封装才有。UART4 占了 PA0。PA0 同时是 WKUP 引脚如果产品需要按键唤醒低功耗这一路就得让出去。另外 PA0/PA1 还是 TIM2_CH1/CH2 的默认脚用 TIM2 做输入捕获测频率的话UART4 得换到 PC10/PC11而 PC10/PC11 又和 USART3 的重映射、SDIO_D2/D3 冲突——这一圈绕下来六路全开时基本没有“完美方案”只能按项目实际取舍。2.3 168MHz 时钟树的参数怎么填所有串口的波特率都是从 PCLK1/PCLK2 分出来的时钟树配错一个分频六路波特率会集体偏移。下面这套参数是 F407 跑 168MHz 的标准解HSE 按 8MHz 晶振算参数取值说明HSE8 MHz外部晶振频率板子不同必须改PLL_M8VCO 输入 8MHz / 8 1 MHzPLL_N336VCO 输出 1MHz × 336 336 MHzPLL_P2SYSCLK 336 / 2 168 MHzPLL_Q748 MHz供 USB/SDIO 用AHB 分频/1HCLK 168 MHzAPB1 分频/4PCLK1 42 MHzF407 上限就是 42APB2 分频/2PCLK2 84 MHz上限 84Flash 等待周期5 WS168MHz 3.3V 供电必须 5 个等待周期Flash 等待周期这一行是我见过最多的“玄学”故障源。配少了不会立刻报错而是随机死机、串口偶尔收到乱码排查起来极其痛苦。/* 系统时钟HSE 8MHz - SYSCLK 168MHzAPB1 42MHzAPB2 84MHz */ void sysclk_168m(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); while (RCC_WaitForHSEStartUp() ! SUCCESS) { /* 晶振起振失败会死在这里先量一下 HSE 引脚有没有波形 */ } RCC_PLLConfig(RCC_PLLSource_HSE, 8, 336, 2, 7); /* M, N, P, Q */ RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET) { } /* 顺序有讲究先定分频再切时钟源最后才能开外设 */ RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB 168 MHz */ RCC_PCLK1Config(RCC_HCLK_Div4); /* APB1 42 MHz */ RCC_PCLK2Config(RCC_HCLK_Div2); /* APB2 84 MHz */ FLASH_SetLatency(FLASH_Latency_5); /* 168MHz 必须 5WS少一个都可能跑飞 */ FLASH_PrefetchBufferCmd(ENABLE); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08) { } }这段的要点在注释里分频必须先于时钟源切换配置。如果先切到 PLL 再改 APB1 分频中间会有一小段时间 APB1 跑在 84MHz超过额定值外设行为不可预测。另外RCC_DeInit()会把所有外设时钟关掉所以这段必须放在uart_init_all()之前调用。3. 表驱动初始化一份结构体管住 USART1~USART63.1 六个复制粘贴的初始化函数改一个波特率要改六处新手最常见的写法是uart1_init()到uart6_init()六个函数每个两三百行内容 90% 重复。这么写前期很快但后期改一个波特率要动六处加一路 DMA 要复制六遍第六遍里大概率会漏掉一句RCC_APB2PeriphClockCmd。更麻烦的是排查问题时无法批量比对——你不知道六路的配置到底哪里不一样。表驱动的思路是把“每一路不同的东西”抽成数据把“六路一样的流程”抽成代码。不同的只有外设指针、中断号、总线归属、时钟使能位、引脚、复用编号、波特率、优先级。这些塞进一个结构体数组初始化和收发逻辑就只写一遍。3.2 配置表与串口句柄结构体定义/* board_uart.h */ #ifndef BOARD_UART_H #define BOARD_UART_H #include stdint.h #define UART_RX_BUF_SIZE 256u /* 必须是 2 的幂取模用 比 % 快一个量级 */ #define UART_TX_BUF_SIZE 512u typedef struct { uint8_t rx_buf[UART_RX_BUF_SIZE]; volatile uint16_t rx_head; volatile uint16_t rx_tail; uint8_t tx_buf[UART_TX_BUF_SIZE]; volatile uint16_t tx_head; volatile uint16_t tx_tail; volatile uint16_t rx_overflow; /* ORE 和缓冲满的次数压测时看这个数 */ volatile uint8_t rx_idle; /* 一帧结束标志按帧解析的协议用得上 */ } uart_fifo_t; typedef struct { const char *name; USART_TypeDef *inst; /* USART1 ... USART6 */ IRQn_Type irqn; uint8_t bus; /* 1 APB1, 2 APB2 */ uint32_t rcc_mask; /* RCC_APBxPeriph_USARTx */ GPIO_TypeDef *tx_port; uint16_t tx_pin; uint32_t tx_port_clk; GPIO_TypeDef *rx_port; /* 单独存因为 UART5 的 TX/RX 不同端口 */ uint16_t rx_pin; uint32_t rx_port_clk; uint8_t af; /* 复用编号USART1~3 是 7UART4/5/6 是 8 */ uint32_t baud; uint8_t preempt_prio; /* 抢占优先级 0~3 */ uint8_t sub_prio; /* 子优先级 0~3 */ uart_fifo_t fifo; } uart_dev_t; extern uart_dev_t g_uarts[6]; void uart_init_all(void); int uart_write(uart_dev_t *d, const uint8_t *p, uint16_t len); uint16_t uart_read(uart_dev_t *d, uint8_t *p, uint16_t len); #endiftx_port和rx_port分开存就是被 UART5 逼出来的设计。rx_head/rx_tail用volatile修饰因为它们在中断和主循环两个上下文里被改编译器不知道这件事一旦开-O2优化就会把判断优化掉表现为“仿真器单步正常、全速跑丢数据”。3.3 初始化四步走时钟、GPIO、USART、NVIC配置表按 2.2 节的引脚方案填波特率按业务需求给/* board_uart.c —— 配置表板子换引脚只改这里 */ uart_dev_t g_uarts[6] { /* 名称 外设 中断号 总线 时钟使能 TX端口 TX脚 TX时钟 RX端口 RX脚 RX时钟 AF 波特率 抢占 子 */ { U1, USART1, USART1_IRQn, 2, RCC_APB2Periph_USART1, GPIOA, GPIO_Pin_9, RCC_AHB1Periph_GPIOA, GPIOA, GPIO_Pin_10, RCC_AHB1Periph_GPIOA, 7, 921600, 0, 0 }, { U2, USART2, USART2_IRQn, 1, RCC_APB1Periph_USART2, GPIOA, GPIO_Pin_2, RCC_AHB1Periph_GPIOA, GPIOA, GPIO_Pin_3, RCC_AHB1Periph_GPIOA, 7, 115200, 1, 0 }, { U3, USART3, USART3_IRQn, 1, RCC_APB1Periph_USART3, GPIOB, GPIO_Pin_10, RCC_AHB1Periph_GPIOB, GPIOB, GPIO_Pin_11, RCC_AHB1Periph_GPIOB, 7, 115200, 2, 0 }, { U4, UART4, UART4_IRQn, 1, RCC_APB1Periph_UART4, GPIOA, GPIO_Pin_0, RCC_AHB1Periph_GPIOA, GPIOA, GPIO_Pin_1, RCC_AHB1Periph_GPIOA, 8, 115200, 2, 0 }, { U5, UART5, UART5_IRQn, 1, RCC_APB1Periph_UART5, GPIOC, GPIO_Pin_12, RCC_AHB1Periph_GPIOC, GPIOD, GPIO_Pin_2, RCC_AHB1Periph_GPIOD, 8, 115200, 2, 0 }, { U6, USART6, USART6_IRQn, 2, RCC_APB2Periph_USART6, GPIOC, GPIO_Pin_6, RCC_AHB1Periph_GPIOC, GPIOC, GPIO_Pin_7, RCC_AHB1Periph_GPIOC, 8, 115200, 1, 0 }, };注意第 5 行TX 的时钟是RCC_AHB1Periph_GPIOCRX 的时钟是RCC_AHB1Periph_GPIOD两个不一样。这就是 2.2 节提到的坑在表里显式写出来之后就不可能漏了。下面是三个初始化函数GPIO_Pin_x宏本身是1 x所以可以用前导零指令直接反推引脚编号省掉一张查表/* GPIO_Pin_9 0x0200 1931 - clz(0x0200) 9 */ static uint8_t pin_source(uint16_t pin) { return (uint8_t)(31u - __builtin_clz((uint32_t)pin)); } static void uart_gpio_init(uart_dev_t *d) { GPIO_InitTypeDef g; RCC_AHB1PeriphClockCmd(d-tx_port_clk | d-rx_port_clk, ENABLE); /* TX复用推挽。RX复用 上拉防止悬空时被噪声打成假起始位 */ g.GPIO_Mode GPIO_Mode_AF; g.GPIO_OType GPIO_OType_PP; g.GPIO_PuPd GPIO_PuPd_UP; g.GPIO_Speed GPIO_Speed_50MHz; g.GPIO_Pin d-tx_pin; GPIO_Init(d-tx_port, g); g.GPIO_Pin d-rx_pin; GPIO_Init(d-rx_port, g); GPIO_PinAFConfig(d-tx_port, pin_source(d-tx_pin), d-af); GPIO_PinAFConfig(d-rx_port, pin_source(d-rx_pin), d-af); } static void uart_hw_init(uart_dev_t *d) { USART_InitTypeDef u; uint32_t pclk (d-bus 2u) ? 84000000u : 42000000u; if (d-bus 2u) { RCC_APB2PeriphClockCmd(d-rcc_mask, ENABLE); } else { RCC_APB1PeriphClockCmd(d-rcc_mask, ENABLE); } USART_DeInit(d-inst); u.USART_BaudRate d-baud; u.USART_WordLength USART_WordLength_8b; u.USART_StopBits USART_StopBits_1; u.USART_Parity USART_Parity_No; u.USART_HardwareFlowControl USART_HardwareFlowControl_None; u.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(d-inst, u); /* 显式落一遍 BRR过采样 16 时 BRR round(PCLK / baud) 整数部分就是尾数低 4 位就是小数部分一条除法搞定 */ d-inst-BRR (pclk d-baud / 2u) / d-baud; USART_Cmd(d-inst, ENABLE); } static void uart_nvic_init(uart_dev_t *d) { NVIC_InitTypeDef n; n.NVIC_IRQChannel d-irqn; n.NVIC_IRQChannelPreemptionPriority d-preempt_prio; n.NVIC_IRQChannelSubPriority d-sub_prio; n.NVIC_IRQChannelCmd ENABLE; NVIC_Init(n); } void uart_init_all(void) { /* 分组必须放在所有 NVIC_Init 之前中途改会打乱已配置的优先级 */ NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); /* 2 位抢占 2 位子优先级 */ for (uint8_t i 0; i 6u; i) { uart_dev_t *d g_uarts[i]; uart_gpio_init(d); uart_hw_init(d); uart_nvic_init(d); /* RXNE 和 IDLE 是六路都要开的两个中断源 */ USART_ITConfig(d-inst, USART_IT_RXNE, ENABLE); USART_ITConfig(d-inst, USART_IT_IDLE, ENABLE); } }BRR那一行的算法值得单独说过采样 16 倍时USARTDIV PCLK / (16 × baud)而 BRR 寄存器里存的正好是 USARTDIV × 16所以 BRR 的整数值就等于PCLK / baud。42MHz 下 115200 算出 364.58四舍五入成 365即尾数 22、小数 13实际波特率 115068误差 -0.11%完全够用。SPL 的USART_Init()内部会根据RCC_GetClocksFreq()自己算一遍但如果你用的是自己写的时钟配置比如没走标准库的 RCC 函数库算出来的 PCLK 可能是错的。显式再写一次 BRR等于把这个不确定性掐掉。注意NVIC_PriorityGroupConfig全工程只能有一处调用而且必须在第一个NVIC_Init之前。放在uart_init_all()开头是安全的但如果工程里还有定时器、DMA 中断把它们统一挪到main()开头的board_init()里更好。3.4 环形缓冲中断只搬运业务在主循环做六路串口同时收数据最忌讳在中断里解析协议。中断服务函数只做一件事把 DR 里的字节搬进环形缓冲然后退出。解析、校验、判断帧头全部放到主循环。/* 中断上下文只搬字节绝不调用任何阻塞函数 */ static void uart_isr(uart_dev_t *d) { uint32_t sr d-inst-SR; /* 溢出ORE 置位时 RXNE 也会置位所以在这里一并处理。 必须先读 SR 再读 DR否则 ORE 清不掉会反复进中断 */ if (sr USART_SR_RXNE) { uint8_t ch (uint8_t)d-inst-DR; uint16_t next (uint16_t)((d-fifo.rx_head 1u) (UART_RX_BUF_SIZE - 1u)); if (next ! d-fifo.rx_tail) { d-fifo.rx_buf[d-fifo.rx_head] ch; d-fifo.rx_head next; } else { d-fifo.rx_overflow; /* 缓冲满丢字节但不阻塞 */ } } /* IDLE总线空闲说明一帧收完了。读 SR 再读 DR 清除 */ if (sr USART_SR_IDLE) { (void)d-inst-DR; d-fifo.rx_idle 1u; } /* TXE发送保持寄存器空从 TX 缓冲里取下一个字节 */ if ((sr USART_SR_TXE) (d-inst-CR1 USART_CR1_TXEIE)) { if (d-fifo.tx_tail ! d-fifo.tx_head) { d-inst-DR d-fifo.tx_buf[d-fifo.tx_tail]; d-fifo.tx_tail (uint16_t)((d-fifo.tx_tail 1u) (UART_TX_BUF_SIZE - 1u)); } else { d-inst-CR1 ~USART_CR1_TXEIE; /* 发完就关避免空转占 CPU */ } } } /* 六个入口只做转发 */ void USART1_IRQHandler(void) { uart_isr(g_uarts[0]); } void USART2_IRQHandler(void) { uart_isr(g_uarts[1]); } void USART3_IRQHandler(void) { uart_isr(g_uarts[2]); } void UART4_IRQHandler(void) { uart_isr(g_uarts[3]); } void UART5_IRQHandler(void) { uart_isr(g_uarts[4]); } void USART6_IRQHandler(void) { uart_isr(g_uarts[5]); } /* 发送塞进缓冲就返回不阻塞主循环 */ int uart_write(uart_dev_t *d, const uint8_t *p, uint16_t len) { uint16_t used (uint16_t)((d-fifo.tx_head - d-fifo.tx_tail) (UART_TX_BUF_SIZE - 1u)); if ((uint16_t)(UART_TX_BUF_SIZE - 1u - used) len) { return -1; /* 缓冲不够直接失败让调用方决定重试还是丢弃 */ } for (uint16_t i 0; i len; i) { d-fifo.tx_buf[d-fifo.tx_head] p[i]; d-fifo.tx_head (uint16_t)((d-fifo.tx_head 1u) (UART_TX_BUF_SIZE - 1u)); } d-inst-CR1 | USART_CR1_TXEIE; return 0; } /* 主循环里调用取走已收到的数据 */ uint16_t uart_read(uart_dev_t *d, uint8_t *p, uint16_t len) { uint16_t n 0; while (n len d-fifo.rx_tail ! d-fifo.rx_head) { p[n] d-fifo.rx_buf[d-fifo.rx_tail]; d-fifo.rx_tail (uint16_t)((d-fifo.rx_tail 1u) (UART_RX_BUF_SIZE - 1u)); } return n; }环形缓冲的容量设计有个经验公式缓冲深度 ≥ 波特率 ÷ 10 × 主循环最长阻塞时间 × 1.5。比如 115200 波特率下每秒约 11520 字节主循环最长 10ms 不回来缓冲至少要有 173 字节取 256 刚好。如果主循环里有 Flash 擦写一次几十毫秒256 就不够了得加到 1024 或者直接上 DMA。uart_write返回 -1 而不是阻塞等待这个设计在六路并发时特别重要。一旦某一路允许阻塞主循环就被那一路绑架其余五路的接收缓冲全都有溢出的风险。4.本文还有配套的精品资源点击获取