资讯详情

STM32F407六个串口同时用:时钟、引脚与中断设计全解析

📅 2026/10/15 1:09:25 | 华诺云谱 👁 阅读
STM32F407六个串口同时用:时钟、引脚与中断设计全解析
简介基于STM32F407与CubeMx构建的六串口并发通信代码包面向需要多路UART同时收发、尤其是处理不定长帧数据的嵌入式开发者。包内自带CubeMx工程.ioc与MDK工程.uvprojx/.uvoptx并包含40个h头文件与19个c源文件可清晰查看串口DMA配置、空闲中断回调及中断服务函数等核心代码工程默认采用片外8M晶振也可在CubeMx中按需更改。六个串口均已启用DMA与空闲中断支持不定长度数据接收若要裁剪串口数量只需在CubeMx中重新配置并删去少量与移除硬件相关的底层代码即可移植。资源共64个文件压缩包仅278KB轻量紧凑适合物联网网关、工业控制等多串口设备开发者快速参考或直接复用。目前已有3674人学习下载。1. STM32F407 六个串口同时用先问自己一句真需要吗做车载网关、工控采集或无人机飞控的人迟早会撞上同一个需求一块 STM32F407 上要同时挂 GPS、4G 模块、调试口、传感器主板、上位机掰着指头数六个串口一个都不能少。STM32F407 恰好有 USART1、USART2、USART3 和 UART4、UART5、UART6 共六个串口但“有”和“能同时用”之间隔着一层时钟配置、引脚复用和中断设计的功夫。很多人在第 3 个串口上就翻车要么引脚冲突要么波特率失真要么中断互相踩踏。这篇文章就是把这六个串口从底层时钟到顶层缓冲一次性讲透照着配就能跑起来。适合手里已经有一个 F407 工程、想往上加串口却怕带崩现有系统的开发者看。2. 六个串口的底牌时钟树与引脚映射先对齐2.1 为什么时序问题总是出现在“第六个串口”F407 的六个串口挂在两条不同的总线时钟上USART1 和 USART6 挂在 APB2 上最高 84 MHzUSART2、USART3、UART4、UART5 挂在 APB1 上最高只有 42 MHz。这是一个常被忽略的点很多人按照 168 MHz 主频、4 分频得到 APB1 42 MHz再算出波特率寄存器值结果前四个串口没问题轮到 USART1 或 USART6 时收发就开始随机乱码。原因是 USART1/6 的时钟源是 84 MHz如果用 42 MHz 去算波特率分频出来的通信速率和标称值差了一大截。尤其波特率定在 115200 以上时误差直接冲破串口容忍的 ±2% 红线。所以拿代码之前先把你要用到的串口时钟分频写在工程配置里建议用一个全局宏统一管理#define APB1_CLK 42000000UL #define APB2_CLK 84000000UL #define UART_CLK(uart) \ (((uart) USART1 || (uart) USART6) ? APB2_CLK : APB1_CLK)这段宏的核心价值是把“哪个串口属于哪条总线”固化成规则后续任何波特率计算、超时换算都从这里取时钟源避免每个函数里各算一遍导致口径不一。实际使用时USART1 和 USART6 的时钟源在初始化时已经确定计算寄存器的统一公式为uint16_t usart_brr_calc(USART_TypeDef *uart, uint32_t baudrate) { uint32_t pclk UART_CLK(uart); uint32_t usartdiv (pclk baudrate / 2) / baudrate; return ((usartdiv 4) 4) | (usartdiv 0x0F); }(pclk baudrate / 2)是四舍五入操作避免直接整除产生 0.5 以上的截断误差。usartdiv的高 12 位写入 BRR 的 DIV_Mantissa低 4 位是 DIV_Fraction注意 F407 的分频小数部分直接以二进制表示不需要像 103 那样乘以 16。2.2 引脚映射表哪个串口能用在哪组引脚GPIO 复用是六个串口同时开时最容易拖垮项目的坑。先看一张整理好的引脚表这是初始化时的主要参考不是让你背而是让你在画原理图之前就排除冲突串口默认引脚重映射引脚复用功能USART1PA9 / PA10PB6 / PB7AF7USART2PA2 / PA3PD5 / PD6AF7USART3PB10 / PB11PD8 / PD9、PC10 / PC11AF7UART4PA0 / PA1PC10 / PC11AF8UART5PC12 / PD2无AF8USART6PC6 / PC7PG14 / PG15AF8必须牢记三条冲突线第一条是 USART3 的重映射 PC10/PC11 和 UART4 的重映射 PC10/PC11 撞车二者只能选一个第二条是 UART5 的 PD2 引脚和 SDIO 的 CMD 信号共用开了 SDIO 就不能用 UART5第三条是 100 脚封装下 PG14/PG15 根本没引出来USART6 只能走 PC6/PC7。 画原理图的时候先对着这张表把引脚分配好回头写代码才能顺畅。2.3 初始化 GPIO 复用把每个引脚从默认模式切到 AFGPIO 复用配置是 F407 特有的操作不能用 103 年代的GPIO_Remap思路而是给每个引脚单独指定 AF 编号。推荐写成一个查表驱动六个串口共用一套初始化逻辑typedef struct { USART_TypeDef *uart; GPIO_TypeDef *gpio_port; uint16_t tx_pin; uint16_t rx_pin; uint8_t af_config; } uart_pin_map_t; static const uart_pin_map_t pin_map[] { {USART1, GPIOA, GPIO_Pin_9, GPIO_Pin_10, GPIO_AF_USART1}, {USART2, GPIOA, GPIO_Pin_2, GPIO_Pin_3, GPIO_AF_USART2}, {USART3, GPIOB, GPIO_Pin_10, GPIO_Pin_11, GPIO_AF_USART3}, {UART4, GPIOA, GPIO_Pin_0, GPIO_Pin_1, GPIO_AF_UART4}, {UART5, GPIOC, GPIO_Pin_12, GPIO_Pin_2, GPIO_AF_UART5}, {USART6, GPIOC, GPIO_Pin_6, GPIO_Pin_7, GPIO_AF_USART6}, }; void uart_gpio_init(uint8_t idx) { GPIO_InitTypeDef gpio; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOC, ENABLE); gpio.GPIO_Mode GPIO_Mode_AF; gpio.GPIO_Speed GPIO_Speed_50MHz; gpio.GPIO_OType GPIO_OType_PP; gpio.GPIO_PuPd GPIO_PuPd_UP; gpio.GPIO_Pin pin_map[idx].tx_pin | pin_map[idx].rx_pin; GPIO_Init(pin_map[idx].gpio_port, gpio); GPIO_PinAFConfig(pin_map[idx].gpio_port, pin_map[idx].tx_pin, pin_map[idx].af_config); GPIO_PinAFConfig(pin_map[idx].gpio_port, pin_map[idx].rx_pin, pin_map[idx].af_config); }这里只开了一次 GPIOA/B/C 的时钟因为六个串口的引脚没有用到 GPIOD 和 GPIOG。GPIO_PinAFConfig的第二个参数是引脚编号也就是GPIO_Pin_9对应第二个参数为GPIO_PinSource9不需要手动转换函数内部会做移位处理。GPIO 上拉建议置 UP这样空闲状态下 RX 是高电平不会误触发起始位。3. 串口初始化与中断服务函数六个串口的统一管理框架3.1 用一张配置表管住六个串口的工作参数六个串口如果各写一套 init 函数代码翻三倍调试时改一个波特率要找六个地方不现实。工程上最常见的做法是配置表 统一 init 函数。每个串口需要管理四类参数串口外设指针、波特率、中断优先级、收发缓冲区。定义一个结构体数组索引 0 到 5 对应 USART1 到 USART6typedef struct { USART_TypeDef *uart; uint32_t baudrate; uint8_t rx_buffer[128]; volatile uint16_t rx_head; volatile uint16_t rx_tail; uint8_t tx_buffer[128]; volatile uint16_t tx_busy; } uart_handle_t; static uart_handle_t uart_handles[6] { {USART1, 115200, {0}, 0, 0, {0}, 0}, {USART2, 115200, {0}, 0, 0, {0}, 0}, {USART3, 9600, {0}, 0, 0, {0}, 0}, {UART4, 38400, {0}, 0, 0, {0}, 0}, {UART5, 9600, {0}, 0, 0, {0}, 0}, {USART6, 115200, {0}, 0, 0, {0}, 0}, };波特率表里的数值要按照实际外设需求配比如 GPS 一般是 96004G 模组常用 115200调试口也走高速这直接影响后面全体初始化函数的参数选择。tx_busy是发送忙标志用于轮询发送时避免上一个字节还没发完就覆盖寄存器接受缓冲的rx_head和rx_tail是环形缓冲区指针中断里只动 head主程序只动 tail这种单向写指针的单生产者单消费者模式不需要加锁。3.2 统一初始化函数一条代码让六个串口全部就绪初始化函数的核心思路是先清空缓冲区再配置 USART 寄存器最后使能中断。注意 USART_Init 的USART_Clock、USART_CPOL、USART_CPHA等字段用于同步模式我们平时用的异步串口用默认值即可不用专门去填。void uart_all_init(void) { USART_InitTypeDef usart; NVIC_InitTypeDef nvic; uint8_t i; RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2 | RCC_APB1Periph_USART3 | RCC_APB1Periph_UART4 | RCC_APB1Periph_UART5, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_USART6, ENABLE); for (i 0; i 6; i) { uart_gpio_init(i); usart.USART_BaudRate uart_handles[i].baudrate; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_HardwareFlowControl USART_HardwareFlowControl_None; usart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(uart_handles[i].uart, usart); USART_ITConfig(uart_handles[i].uart, USART_IT_RXNE, ENABLE); USART_Cmd(uart_handles[i].uart, ENABLE); nvic.NVIC_IRQChannel uart_irqn_table[i]; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); } }其中uart_irqn_table是一个查表数组依次填USART1_IRQn、USART2_IRQn、USART3_IRQn、UART4_IRQn、UART5_IRQn、USART6_IRQn。优先级这里先统一用抢占优先级 1、子优先级 0如果系统里还有其他中断再单独调整。这里有一个关键细节USART_ITConfig只开了接收中断没有开发送完成中断。六个串口如果同时开 TXE 中断主循环稍一卡顿中断嵌套就会很恐怖。发送继续用轮询接收用中断这是同时挂 6 路串口更稳的方案。3.3 六个中断服务函数接收逻辑只有一种重复六次每个串口的中断服务函数结构都是一样的以 USART1 为例void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); uint16_t next (uart_handles[0].rx_head 1) % 128; if (next ! uart_handles[0].rx_tail) { uart_handles[0].rx_buffer[uart_handles[0].rx_head] data; uart_handles[0].rx_head next; } } }关键动作有两处一是USART_ReceiveData在读数据的同时自动清掉 RXNE 标志位不需要手动USART_ClearITPendingBit二是写入前先判断next ! rx_tail缓冲区满时直接丢弃新数据而不是覆盖掉还没被主程序取走的数据。六个串口的 handler 里只有串口指针和 handle 索引不同逻辑完全一致。如果嫌六个函数长得太像可以抽一个静态函数uart_rx_handler(uart_handle_t *h)在六个 IRQHandler 里各调一次。3.4 发送函数轮询发送在六串口场景下的两个合理度发送逻辑比接收简单但有个容易忽略的点每次只发送一个字节且发送放到循环里需要把忙判断做对。常见做法是轮询 TXE 标志位这个标志位表示数据寄存器已空、可以写入下一个字节void uart_send_byte(uint8_t idx, uint8_t data) { while (USART_GetFlagStatus(uart_handles[idx].uart, USART_FLAG_TXE) RESET) { } USART_SendData(uart_handles[idx].uart, data); } void uart_send_string(uint8_t idx, const char *str) { while (*str) { uart_send_byte(idx, (uint8_t)(*str)); } }这个函数在大多数场景下够用但要意识到它的代价发送一整包数据时主循环会一直卡在 while 里。六路串口同时有数据要发时一旦某个外设的波特率低比如 9600整个主循环会被拖住几毫秒。对实时性要求高的项目建议至少把调试串口切成 DMA 发送或把发送也做成中断驱动——每发完一个字节触发 TXE 中断在中断里从发送缓冲区取下一个字节缓冲区空了就关掉 TXE 中断。但这个复杂度是后加的先跑通六路收发再优化。4. 从接收中断到数据解析环形缓冲区怎么用才不丢帧4.1 接收数据快照把 RXNE 中断里的数据取走的正确姿势中断里收到的是一堆裸字节主程序拿到它们之后要做的第一件事是判断一帧数据是否完整。最简单可靠的方案是帧头帧尾协议比如帧头 0xAA 0x55帧尾 0x0D 0x0A。主循环里从缓冲区逐字节扫描找到帧头且后面的长度满足条件再整帧取出。uint16_t uart_buffer_read(uint8_t idx, uint8_t *out, uint16_t max_len) { uart_handle_t *h uart_handles[idx]; uint16_t len 0; while (h-rx_tail ! h-rx_head len max_len) { out[len] h-rx_buffer[h-rx_tail]; h-rx_tail (h-rx_tail 1) % 128; } return len; }rx_tail只在主程序里修改rx_head只在中断里修改所以这里不需要关中断也不需要原子操作保护。读完后记得要判断返回长度如果收到半包数据更稳的做法是直接把整个缓冲区查一遍解析出完整帧后把已消费掉的数据丢弃。4.2 多串口的解析调度主循环轮流查询每个串口的缓冲区主程序中需要轮询六个串口是否有数据上来。最简单的策略就是一个一个来但要注意判断优先级解析程序不要长时间占用主循环否则其他串口数据积压导致缓冲区溢出。推荐先统计哪个串口的缓冲区 pending 数据最多先处理它uint8_t uart_busy_level(uint8_t idx) { uart_handle_t *h uart_handles[idx]; uint16_t pending (h-rx_head 128 - h-rx_tail) % 128; return pending 100 ? 2 : (pending 30 ? 1 : 0); }这个函数返回三档“忙度”主循环里先扫一遍六个串口找到 busy_level 最高的那个处理。如果某个串口长时间维持在 2 档说明它的数据到达速率已经超过主循环处理能力需要调整策略要么把它的接收缓冲区加大到 512 字节要么给数据到达频率高的串口单独开 DMA 接收。不要迷信中断接收就一定能扛住所有负载中断只负责把字节搬进缓冲区真正的瓶颈在主循环解析速度。4.3 溢出与脏数据串口满时的吞帧策略很多开发者忽视一个标志位OREOverrun Error。当接收寄存器还没被取走又有新数据到达时硬件会置 ORE 并停止接收此刻如果不清除标志位后续数据会全部丢失。中断服务函数里除了处理 RXNE还应该检查 OREvoid USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_ORE) ! RESET) { USART_ReceiveData(USART2); USART_ClearITPendingBit(USART2, USART_IT_ORE); } if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART2); uint16_t next (uart_handles[1].rx_head 1) % 128; if (next ! uart_handles[1].rx_tail) { uart_handles[1].rx_buffer[uart_handles[1].rx_head] data; uart_handles[1].rx_head next; } } }ORE 的清除方式比较特殊直接读一次 DR 寄存器相当于丢弃当前数据就能恢复但建议仍然显式调用USART_ClearITPendingBit因为不同库版本对清除动作的触发条件定义略有差异。数据溢出时我们选择静默丢弃这是工控场景下的默认策略——帧不完整就让上层超时重发比处理半包数据更可靠。5. 六路串口同时跑的避坑记录这些坑我挨个踩过5.1 现象UART5 配置正确但完全收不到数据原因UART5 的 RX 引脚是 PD2这个引脚在 F407 上同时连到 SDIO 的 CMD。如果你的工程里初始化过 SDIO哪怕只是挂载文件系统失败SDIO 外设会把 PD2 复用成 SDIO_CMDUART5 就失去引脚控制权反过来UART5 初始化后 SDIO 也无法工作。解决把 SDIO 和 UART5 在原理图阶段就拆到两个完全不冲突的方案里——要么 UART5 不用要么 SDIO 换 SPI 方式驱动 SD 卡。如果两边都一定要用只能换用 UART4 的 PC10/PC11 引脚或者改用其他引脚重映射方案。5.2 现象USART3 初始化正确但数据到 PC10/PC11 上不出来原因USART3 的重映射有两个来源可选PD8/PD9 或 PC10/PC11。PD8/PD9 在 100 脚封装上齐全PC10/PC11 也是但很多人忽略了一个问题——PC10/PC11 同时是 UART4 的重映射引脚。如果代码里初始化了 UART4 并且做了重映射两个外设把同一组引脚抢来抢去后初始化的那个会覆盖前一个的 AF 配置。解决查原理图确认 PC10/PC11 只被一个外设占用如果两路都要用且引脚不够就调整串口分配把 UART4 用回默认的 PA0/PA1。5.3 现象USART1 和 USART6 同时开波特率 921600 时乱码原因USART1/6 挂在 APB2 上时钟 84 MHz而 USART2/3/UART4/5 挂在 APB1 上时钟 42 MHz。初始化USART_Init时库里会根据 APB 时钟自动计算 BRR但它拿到的是哪条总线的时钟取决于 RCC 寄存器里的实际配置。如果你的系统时钟不是 168 MHz而是经过 PLL 倍频到了 180 MHz 等非标准频率APB1 和 APB2 的分频关系也要跟着调整此时 BRR 算出的值就会偏离。解决确认系统时钟、AHB 分频、APB1/APB2 分频的实际数值然后用这些实际数值反推波特率注意 921600 在 42 MHz 时钟下误差已经接近临界值建议降低到 460800 或改用 USART1/6。5.4 现象中断接收时一进入 IRQHandler 程序就死机原因多数是中断服务函数里调用了printf或者uart_send_string发送时一直等 TXE 标志位而发送函数的 while 循环卡死了当前中断。六路串口共用同一个调试串口输出日志时这种问题尤其常见。解决中断服务函数里只做数据搬运任何格式化输出、协议解析、事件回调都不做。调试日志放到主循环或专门的任务里处理中断里要发数据就置一个标志位主循环看到后再调用发送函数。这条规则要当作铁律不是建议。5.5 现象单步调试一切正常全速运行丢字节原因编译器把主循环里读环形缓冲区指针的代码优化掉了。rx_head和rx_tail在中断和主程序之间共享如果变量没有声明成volatile编译器认为主循环里读到的值一直没变就会把读操作提到循环外面导致缓冲区新数据永远不可见。解决结构体里的rx_head和rx_tail必须加volatile这也是我们定义uart_handle_t时强调这两字段是volatile uint16_t的原因。同时注意不要用普通 int 类型跨中断和主循环共享变量除非加锁或关中断。6. 把六个串口调试到“可交付”状态一个轻量级引脚回环测试法六个串口全部接上外设后现场调试最崩溃的事情是分不清是硬件接线问题、模块配置问题还是代码逻辑问题。我常用的做法是做一个 PCB 上的回环测试把每路串口的 TX 引脚用一个 0 欧电阻短接到自己的 RX 引脚或者用杜邦线在调试阶段临时短接然后让主程序发一串已知数据再从同一个串口读回来比对。这个改动能在十分钟内区分“代码问题”和“外部设备问题”。具体实现上不需要写复杂的测试用例直接复用uart_send_string和uart_buffer_readuint8_t uart_loopback_test(uint8_t idx) { char *test_pkt UART-LOOPBACK-TEST; uart_send_string(idx, test_pkt); uint32_t timeout 100000; uint8_t buf[32]; uint16_t len 0; while (len strlen(test_pkt) timeout--) { len uart_buffer_read(idx, buf len, sizeof(buf) - len); } if (len ! strlen(test_pkt)) return 0; return memcmp(buf, test_pkt, len) 0; }如果返回 1说明这一路串口的 GPIO 复用配置、波特率、中断、缓冲区全部正确可以把 0 欧电阻拆掉去接实际外设如果返回 0优先检查波特率寄存器的实际写入值和示波器上的波形而不是去翻应用层协议。这个方法一次能把六个串口都验一遍比逐个接外设调试省大量时间。另外建议在正式交付前做一次持续稳定性测试六个串口同时以各自的波特率向主控发数据主控连续跑 24 小时统计每个串口累计掉字节数和 ORE 中断触发次数。我遇到过在强电磁干扰环境下某一路串口瞬间涌入大量毛刺导致缓冲区满溢的问题后来在串口接收引脚对地加了一个 10nF 电容毛刺基本消失掉字节数从千分之三降到万分之一以下。这就是串口调试里的玄学时刻——代码没动硬件上一个小电容救回一整个项目。如果你在按照这套方案移植到自己的板子上建议先把所有串口的波特率统一配成 115200 跑通回环测试再逐个改成实际外设的波特率。因为 115200 在任何一条 APB 总线下误差都最小能排除波特率因素全部回环通过后再逐个修改波特率值这样哪个串口出错能立刻定位。六路串口同时用没有想象中那么可怕抓住时钟源、引脚冲突、中断三件事后面的路就顺了。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑