简介:面向使用STM32F407进行多串口通信开发的嵌入式学习者与工程师,提供一套基于CubeMX配置的UART1至UART6完整工程。六路串口均启用DMA与空闲中断,可接收不定长度数据,适合多设备通信、调试信息输出及串口协议验证等场景。压缩包共67个文件,以41个头文件和19个C源码为核心,另含Keil工程、启动文件、ioc配置与说明文本,整体约581KB;工程默认采用片外8MHz晶振,可在CubeMX中调整。读者可据此编译验证六串口并行收发,也能按需裁剪串口数量,只需删除少量硬件相关代码即可移植到自有项目;工程结构清晰,便于对照HAL库串口初始化、DMA搬运、空闲中断分包和中断服务函数组织。已有7803人学习下载,可作为多串口项目的起步模板与排错参考,尤其适合快速搭建多路串口收发框架、验证通信稳定性的读者。
1. 六个串口全开,上电后只有一路出数:先把引脚和时钟摊在桌面上
板子回板第一天,我把 USART1 到 USART6 一股脑勾进工程,编译零警告,烧录绿灯,串口助手上却只有 USART1 在吐数据,另外五路安静得像没焊。查了两小时才反应过来不是代码写错:USART3 的部分重映射和 UART4 的重映射同时盯着 PC10/PC11,UART5 的 RX 又占着 PD2,而这块板子的 SDIO 走的是四位模式。STM32F407 想让六个串口同时跑,第一件事从来不是写初始化,而是把六个口的引脚、时钟域和互斥关系摊在纸上核对一遍。下面按我实际落地的顺序来:先把环境和工程骨架备齐,再逐口配置,然后分中断和 DMA,最后拿回环把六个口一个一个验过去,验完再上业务。
2. 工程骨架:STM32F407 六串口的环境准备与勾选顺序
2.1 工具链与固件包:三条路各自适合谁
做 F407 的串口工程,常见三条路。第一条是 Keil MDK 配 CubeMX 生成代码,工程结构清爽,调试器接上就能单步,多数人走这条;第二条是 STM32CubeIDE 一体到底,CubeMX 内嵌在里面,省掉两个软件来回倒腾的麻烦;第三条是纯手写加 Makefile 加 arm-none-eabi,适合要在 CI 里自动编译的团队。三条路用的 HAL 驱动是同一份,区别只在工程组织和编译脚本,所以选哪条不影响后面六串口的配置逻辑。
真正会出事的是固件包版本。CubeMX 生成代码时会把你本机安装的那份 STM32CubeF4 固件包里的 HAL 拷进工程,几个人协作时如果一个人用旧包、一个人用新包,互相覆盖Drivers/STM32F4xx_HAL_Driver目录,编译能过,运行时的行为差异却很难查。我一般要求同组的人先跑一遍下面这两条命令,把版本对在纸面上再动手:
# 确认工程里 HAL 驱动的主版本号,多人协作时先对齐这一行 grep -n "STM32F4xx_HAL_VERSION_MAIN\|STM32F4xx_HAL_VERSION_SUB1" \ Drivers/STM32F4xx_HAL_Driver/Inc/stm32f4xx_hal.h # 确认器件宏定义是 F407 系列,选错宏会导致外设基地址和中断号全错 grep -rn "STM32F407xx" Core/Inc/stm32f4xx_hal_conf.h第一条命令的输出里,MAIN和SUB1两个数字就是 HAL 版本,两次提交之间这行变了,就得把 diff 看一遍。第二条命令确认STM32F407xx这个宏被打开,它是所有外设寄存器定义和USART1_IRQn这类中断号能不能对上的前提,宏写错成 F405 或 F429,编译照样过,中断向量表却会错位。
如果走 Makefile 路线,编译命令里有两个参数必须显式写死,别指望默认值:
# -mcpu 指定内核,-mfpu 和 -mfloat-abi 决定浮点调用约定 # 这三项和启动文件、链接脚本必须一致,否则 HardFault 会在第一次浮点运算时出现 arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb \ -mfpu=fpv4-sp-d16 -mfloat-abi=hard \ -DSTM32F407xx -DUSE_HAL_DRIVER \ -O2 -g3 -Wall \ -TSTM32F407ZGTx_FLASH.ld \ Core/Src/main.c Core/Src/stm32f4xx_it.c \ -o build/firmware.elf-mfpu=fpv4-sp-d16对应 F407 的单精度 FPU,-mfloat-abi=hard表示浮点参数走 FPU 寄存器。这两项一旦和链接进来的库不一致,表现是程序跑到某个浮点运算就进 HardFault,看起来和串口毫无关系,实际是编译选项的锅。
2.2 CubeMX 里一次勾满六个串口
进 CubeMX 之后按这个顺序点,比一个口一个口回头改省事得多。先定时钟树:HSE 接 8MHz 晶振,PLL 倍频到 SYSCLK 168MHz,APB1 分频 /4 得 42MHz,APB2 分频 /2 得 84MHz。这一步必须最先做,因为它直接决定后面六个串口的波特率寄存器值。
然后在 Connectivity 里把 USART1、USART2、USART3、UART4、UART5、USART6 全部设成 Asynchronous 模式。参数统一 115200、8 数据位、1 停止位、无校验、无硬件流控——六个口先跑同一套参数,联调时少一个变量。NVIC Settings 标签页里,六个口的 global interrupt 全勾上;DMA Settings 里先只给 USART1 加 RX,等单口验证通过再统一铺开,一次全加进来出问题时不好定位。
Project Manager 里有一项一定要改:Generate peripheral initialization as a pair of .c/.h files。默认是把所有初始化塞进main.c,六个串口加上 DMA 和 NVIC,main.c会膨胀到没法看,分开生成之后每个外设一个文件,改哪个口的参数一目了然。
六个口的配置在 CubeMX 里最后应该长这样:
| 外设 | 模式 | 波特率 | 数据格式 | 时钟域 | 典型引脚 |
|---|---|---|---|---|---|
| USART1 | Asynchronous | 115200 | 8N1 | APB2 84MHz | PA9 / PA10 |
| USART2 | Asynchronous | 115200 | 8N1 | APB1 42MHz | PA2 / PA3 |
| USART3 | Asynchronous | 115200 | 8N1 | APB1 42MHz | PB10 / PB11 |
| UART4 | Asynchronous | 115200 | 8N1 | APB1 42MHz | PA0 / PA1 |
| UART5 | Asynchronous | 115200 | 8N1 | APB1 42MHz | PC12 / PD2 |
| USART6 | Asynchronous | 115200 | 8N1 | APB2 84MHz | PC6 / PC7 |
这张表里最容易忽略的是「时钟域」那一列。USART1 和 USART6 挂在 APB2 上,另外四个挂在 APB1 上,同样是 115200,两组算出来的 BRR 值完全不一样。后面第 3 章会专门算这个误差。
2.3 生成代码后必须手动补的三处
CubeMX 生成的代码不是万能的,六串口场景下有三处它管不到或者容易漏。
第一处是HAL_UART_MspInit。如果中途改过引脚,MSP 函数里的 GPIO 初始化可能还是旧的复用编号,得逐个口核对。USART1/2/3 的复用功能是 AF7,UART4/5 和 USART6 是 AF8,这一条写错的表现是引脚上完全没有波形:
/* USART1 的 MSP 初始化片段,其余五个口结构相同,只改实例、引脚和 AF 编号 */ if (huart->Instance == USART1) { __HAL_RCC_USART1_CLK_ENABLE(); /* 外设时钟,漏了这行寄存器写不进去 */ __HAL_RCC_GPIOA_CLK_ENABLE(); /* 引脚所在端口的时钟 */ gpio.Init.Pin = GPIO_PIN_9 | GPIO_PIN_10; gpio.Init.Mode = GPIO_MODE_AF_PP; /* 复用推挽,TX 必须用这个 */ gpio.Init.Pull = GPIO_PULLUP; /* RX 上拉,悬空时不会收到随机噪声 */ gpio.Init.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Init.Alternate = GPIO_AF7_USART1; /* USART1/2/3 是 AF7,别和 UART4/5/6 的 AF8 搞混 */ HAL_GPIO_Init(GPIOA, &gpio); }第二处是中断服务函数。stm32f4xx_it.c里应该有USART1_IRQHandler到USART6_IRQHandler六个函数,每个里面只调一次HAL_UART_IRQHandler(&huartX)。CubeMX 有时只生成你勾了 NVIC 的那几个,后来补勾的口不会自动补函数,得手动加,漏了就是「中断标志置位了但没人清,进一次死循环」。
第三处是优先级分组。HAL_Init()里默认设成NVIC_PRIORITYGROUP_4,也就是 4 位全部给抢占优先级,没有子优先级。如果工程里还跑着 FreeRTOS,这一项不能随便改,改了会影响configMAX_SYSCALL_INTERRUPT_PRIORITY的判断逻辑。我一般把这一行显式写在main()开头,不依赖默认值:
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); /* 4 位抢占、0 位子优先级,和 FreeRTOS 兼容 */3. 逐口落地:USART1 到 USART6 的引脚、时钟源与波特率参数
3.1 先把 APB1 和 APB2 这两条时钟线分清
F407 的六个串口不是挂在同一根总线上的。USART1 和 USART6 在 APB2 上,最高 84MHz;USART2、USART3、UART4、UART5 在 APB1 上,最高 42MHz。这个差别直接决定波特率寄存器的整数部分,也决定误差大小。
很多人在这里踩的第一个坑是把 APB1 也配成 84MHz。CubeMX 的时钟树里 APB1 预分频只有 /1、/2、/4、/8、/16 五档,SYSCLK 168MHz 时选 /2 就超了 APB1 的 42MHz 上限,芯片不会立刻炸,但串口、I2C、CAN 的时序全都不对。正确配法是 APB1 选 /4 得 42MHz,APB2 选 /2 得 84MHz。
还有一个容易混淆的点:APB1 预分频不为 1 时,定时器时钟会额外乘 2,所以 TIM2 到 TIM7 跑在 84MHz。但串口不享受这个待遇,USART2 的时钟就是实打实的 42MHz。把定时器的时钟当成串口时钟去算 BRR,算出来的波特率会差一倍,表现是能收到数据但全是乱码。
如果项目对 USART2 到 UART5 的波特率精度要求高,可以把系统时钟降到 120MHz,APB1 配 /2 得 60MHz,这样 115200 的误差会比 42MHz 下更小。代价是主频降低,跑图像处理或者大量运算时要重新评估。我的做法是先按 168MHz 跑,实测误码率不达标再降频,不提前牺牲性能。
3.2 六组引脚的可选组合与互斥关系
F407 的六个串口每个都有默认引脚,部分口还有重映射引脚,选哪组不是随便挑的,得看板子上还占着什么外设。下面这张表是我实际画板前会对照的版本:
| 外设 | 默认 TX/RX | 重映射 TX/RX | 主要冲突外设 |
|---|---|---|---|
| USART1 | PA9 / PA10 | PB6 / PB7 | USB_OTG_FS(PA9 作 VBUS)、I2C1(PB6/PB7) |
| USART2 | PA2 / PA3 | PD5 / PD6 | TIM2_CH3/CH4、以太网 MII |
| USART3 | PB10 / PB11 | PC10/PC11 或 PD8/PD9 | SDIO_D2/D3(PC10/PC11)、I2S3 |
| UART4 | PA0 / PA1 | PC10 / PC11 | TIM2_CH1/CH2、SDIO_D2/D3 |
| UART5 | PC12 / PD2 | 无 | SDIO_CK(PC12)、SDIO_CMD(PD2) |
| USART6 | PC6 / PC7 | PG14 / PG9 | I2S2、以太网 MII_RX_ER |
看这张表能得出一个结论:SDIO 四位模式是六串口的头号杀手。SDIO 四位模式下 PC8 到 PC12 加 PD2 全被占走,UART5 两个引脚全废,USART3 的部分重映射和 UART4 的重映射也一起废掉。如果板子上要挂 SD 卡并且用四位模式,UART5 基本只能放弃,或者改用软件模拟串口。
USART3 和 UART4 同时抢 PC10/PC11 这件事,是第二个常见翻车点。这两个外设的这两个引脚在芯片内部就是同一组复用,物理上不可能同时用。想六口全开,USART3 必须留在默认的 PB10/PB11,UART4 留在 PA0/PA1,不能图布线方便去选重映射。
USART1 的重映射也要留意。PB6/PB7 在很多板子上接了 I2C1 的 EEPROM,把 USART1 挪过去,I2C1 就用不了了。默认的 PA9/PA10 更安全,唯一要确认的是 USB OTG FS 有没有做 host 模式——如果做了,PA9 被 VBUS 使能占用,USART1 也得让路。
3.3 波特率寄存器怎么算,42MHz 下 115200 到底差多少
STM32 的 USART 波特率寄存器是 16 位,高 12 位整数、低 4 位小数,公式是USARTDIV = fCK / (16 × baud)。HAL 库的HAL_UART_Init会自动算,但知道怎么算才能判断误差能不能接受。下面这个函数把计算过程摊开,方便在选型阶段先估算:
/* 输入外设时钟和波特率,返回可直接写进 BRR 寄存器的值 */ static uint32_t uart_calc_brr(uint32_t pclk, uint32_t baud) { /* 放大 100 倍做定点运算,避免浮点;16 是过采样倍数 */ uint32_t div100 = (pclk * 100U) / (16U * baud); uint32_t mantissa = div100 / 100U; /* 小数部分换算成 1/16 的倍数,加 50 做四舍五入 */ uint32_t frac = ((div100 % 100U) * 16U + 50U) / 100U; if (frac >= 16U) { /* 进位处理,否则会写出非法的小数位 */ mantissa += 1U; frac = 0U; } return (mantissa << 4) | (frac & 0x0FU); }按这个算法,APB1 的 42MHz 和 APB2 的 84MHz 算出来的结果差别不小:
| 波特率 | 时钟 42MHz 的 BRR | 实际波特率 | 误差 | 时钟 84MHz 的 BRR | 实际波特率 | 误差 |
|---|---|---|---|---|---|---|
| 9600 | 0x1117 | 9600 | 0% | 0x222E | 9600 | 0% |
| 19200 | 0x88C | 19195 | -0.03% | 0x1117 | 19200 | 0% |
| 57600 | 0x2D9 | 57631 | +0.05% | 0x5B2 | 57608 | +0.01% |
| 115200 | 0x16D | 115068 | -0.11% | 0x2D9 | 115247 | +0.04% |
| 921600 | 0x2E | 913043 | -0.93% | 0x5B | 922637 | +0.11% |
结论很直接:115200 及以下,APB1 的 42MHz 误差都在 0.15% 以内,串口通信的容忍上限一般是 2% 到 3%,完全够用。但到了 921600 这种高速率,APB1 的误差逼近 1%,加上时钟源本身的温漂,长帧传输就会偶发错位。高速率优先放到 USART1 或 USART6 上,走 APB2 的 84MHz,误差小一个数量级。
真正需要警惕的不是静态误差,而是两端误差叠加。如果对端设备用的是晶振精度 ±2% 的廉价模块,你这边再差 0.9%,加起来就超过 3% 的容忍线,表现是短帧正常、长帧必错,或者室温下正常、加热后开始丢包。这种情况换波特率或者给对端换晶振,比在代码里调参数有效。
4. 中断与 DMA 分配:六个口同时收发时 NVIC 和 DMA1/DMA2 怎么排
4.1 六个 IRQ 编号与优先级分组
F407 的六个串口各有独立的中断入口,编号不是连续的,写 NVIC 使能时不能想当然按 37 到 42 排:
| 外设 | 中断号宏 | 数值 | 建议抢占优先级 |
|---|---|---|---|
| USART1 | USART1_IRQn | 37 | 5 |
| USART2 | USART2_IRQn | 38 | 5 |
| USART3 | USART3_IRQn | 39 | 5 |
| UART4 | UART4_IRQn | 52 | 6 |
| UART5 | UART5_IRQn | 53 | 6 |
| USART6 | USART6_IRQn | 71 | 5 |
USART1 到 USART3 是连续的,UART4 和 UART5 跳到 52、53,USART6 单独在 71。这个分布在写初始化循环时没法用for一把梭,得老老实实六行。
优先级怎么分配,取决于哪个口是主控通道。我一般把 USART1 和 USART6(APB2 上的两个)设成抢占 5,另外四个设 6。理由是主控通道通常跑协议帧,对实时性要求高;从机通道可以稍后处理。如果工程里跑 FreeRTOS 并且在串口中断里调xQueueSendFromISR,抢占优先级数值必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY(常见配置是 5),否则会触发断言。
/* 六个口的中断使能,逐行写清楚,别用循环硬凑 */ HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); HAL_NVIC_SetPriority(USART2_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART2_IRQn); HAL_NVIC_SetPriority(USART3_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART3_IRQn); HAL_NVIC_SetPriority(UART4_IRQn, 6, 0); /* UART4/5 优先级低一档,让主控通道先处理 */ HAL_NVIC_EnableIRQ(UART4_IRQn); HAL_NVIC_SetPriority(UART5_IRQn, 6, 0); HAL_NVIC_EnableIRQ(UART5_IRQn); HAL_NVIC_SetPriority(USART6_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART6_IRQn);这段代码放在MX_USARTx_UART_Init()之后、主循环之前。SetPriority的第二个参数是抢占优先级,第三个是子优先级,在NVIC_PRIORITYGROUP_4分组下子优先级不生效,统一填 0 就行。
4.2 DMA1 只有八条流,六个口的通道怎么排
六串口全开收发,最紧张的资源是 DMA 流。F407 有两个 DMA 控制器,每个 8 条流,一共 16 条,看起来够用,但每条流支持的通道是固定的,排不好就会撞车。下面这张表是我实际用下来不冲突的一组分配:
| 外设 | 方向 | DMA 控制器 | Stream | Channel | 备注 |
|---|---|---|---|---|---|
| USART1 | RX | DMA2 | Stream 5 | 4 | 可选 Stream 2,但会和 USART6 抢 |
| USART1 | TX | DMA2 | Stream 7 | 4 | 固定 |
| USART2 | RX | DMA1 | Stream 5 | 4 | 固定 |
| USART2 | TX | DMA1 | Stream 6 | 4 | 固定 |
| USART3 | RX | DMA1 | Stream 1 | 4 | 固定 |
| USART3 | TX | DMA1 | Stream 3 | 4 | 可选 Stream 4 |
| UART4 | RX | DMA1 | Stream 2 | 4 | 固定 |
| UART4 | TX | DMA1 | Stream 4 | 4 | 固定 |
| UART5 | RX | DMA1 | Stream 0 | 4 | 固定 |
| UART5 | TX | DMA1 | Stream 7 | 4 | 固定 |
| USART6 | RX | DMA2 | Stream 2 | 5 | 不能用 Stream 1,会和 USART3 的请求线打架 |
| USART6 | TX | DMA2 | Stream 6 | 5 | 不能用 Stream 7,USART1_TX 占了 |
这张表里有一处必须重点说:USART1_TX 和 USART6_TX 的首选都是 DMA2 Stream 7,只是通道号不同(Ch4 和 Ch5)。DMA 流是按流号仲裁的,同一条流同一时刻只能服务一个通道,两个串口都想用 Stream 7,结果就是其中一个发不出去。解决办法是把 USART6_TX 挪到 Stream 6,它是官方支持的备选,功能完全一样。
同理 USART1_RX 如果图省事选了 DMA2 Stream 2,就会和 USART6_RX 的 Stream 2 撞上。正确做法是 USART1_RX 走 Stream 5,USART6_RX 走 Stream 2,两条流互不干扰。
DMA 配置里还有一个参数经常被忽略:FIFO 模式和突发传输。串口的数据宽度是 8 位,DMA 配置成DMA_PFCTRL_DISABLE、DMA_MDATAALIGN_BYTE、DMA_PDATAALIGN_BYTE、DMA_MODE_NORMAL就够,别去开 FIFO,开了之后突发长度和实际字节数对不上,收包会少几个字节,查起来非常费劲。
4.3 空闲中断收不定长,发送走 DMA 还是阻塞
六个口同时收发,最怕的是接收长度不定。用HAL_UART_Receive_DMA固定长度接收,短帧要等超时,长帧会溢出。我的做法是 DMA 加空闲中断,靠总线空闲这个硬件事件判断一帧结束:
#define RX_BUF_SIZE 256 static uint8_t rx_buf[6][RX_BUF_SIZE]; /* 每个口一块独立缓冲,别共用 */ /* 启动某个口的空闲中断接收,上电时对六个口各调一次 */ void uart_start_rx(UART_HandleTypeDef *huart, uint8_t idx) { /* ReceiveToIdle 会在 IDLE 事件或缓冲写满时回调,Size 是实际收到的字节数 */ HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf[idx], RX_BUF_SIZE); /* 半传输中断会让回调触发两次,六串口场景下纯属噪音,关掉 */ __HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT); } /* 所有串口共用的接收事件回调 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { uint8_t idx = uart_index_of(huart); /* 自己的映射函数:句柄换算成 0 到 5 */ if (Size > 0 && Size <= RX_BUF_SIZE) { ringbuf_push(&rb[idx], rx_buf[idx], Size); /* 立刻搬进环形缓冲,别在中断里做解析 */ } /* 必须重新挂起,否则这个口只收一帧就静默了 */ HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf[idx], RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT); }这段代码有三个关键点。一是每个口必须有独立的接收缓冲,六个口共用一个数组,数据会互相覆盖,表现是收到的帧内容张冠李戴。二是回调里不能做协议解析,只能搬数据,解析放到主循环或者任务里,中断里待久了会拖慢其他五个口的响应。三是回调末尾必须重新调用ReceiveToIdle,HAL 的 DMA 接收是一次性的,不重新挂起,这个口收完第一帧就再也不进中断了。
发送方向的选择要看数据量。少量短帧用HAL_UART_Transmit阻塞发送最省事,但六个口都在发的时候,一个口阻塞会卡住整个主循环。超过 32 字节的帧我统一走HAL_UART_Transmit_DMA,发送完成中断里置个标志位,主循环轮询标志判断能不能发下一帧。这里有一个坑:HAL_UART_TxCpltCallback在 DMA 模式下触发时,最后一个字节其实还在移位寄存器里没出去,如果这时立刻切到 RS485 的收发方向控制,最后一个字节会被截断。稳妥的做法是回调里再等一次__HAL_UART_GET_FLAG(huart, UART_FLAG_TC),或者干脆用HAL_UART_Transmit_DMA之后在 TC 中断里处理。
5. 避坑与排查:六串口联调里最容易翻车的五个现场
5.1 引脚互斥:SDIO 一开,UART5 和 USART3 重映射全废
现象:板子上挂了 SD 卡,用四位模式初始化之后,UART5 完全收不到数据,示波器量 PC12 没有任何波形;同时 USART3 如果之前配的是 PC10/PC11 重映射,也一起失效。
原因:SDIO 四位模式占用 PC8 到 PC12 加 PD2 共六个引脚,其中 PC12 是 SDIO_CK,PD2 是 SDIO_CMD,PC10/PC11 是 SDIO_D2/D3。UART5 的 TX 正好是 PC12、RX 是 PD2,物理上被完全占死。USART3 的部分重映射和 UART4 的重映射都在 PC10/PC11 上,同样被占。
解决:板子设计阶段就把 SDIO 和 UART5 的取舍定下来。要用 UART5,SDIO 只能走一位模式(只占 PC8/PC9/PC12/PD2,其中 PC12 和 PD2 还是冲突,实际上还是不行),更现实的做法是 UART5 改用软件模拟,或者把 SD 卡换成 SPI 模式接在别的引脚上。软件层面无法绕过,这是芯片内部的复用矩阵决定的,只能在选型阶段避开。
5.2 波特率对不上:42MHz 下的偶发乱码
现象:USART2 和 PC 端通信,短命令正常,发 200 字节以上的数据包时偶尔出现一两个字节错位,换个波特率就好了,换回来又复现。
原因:APB1 的 42MHz 下 115200 的实际值是 115068,误差 -0.11%。单看这个数字没问题,但 PC 端串口芯片的晶振误差可能到 ±1.5%,两边一叠加就逼近 2% 的容忍线。加上线缆较长、电平转换芯片有延迟,采样点偏移累积到帧尾就采错了。
解决:先确认两端的实际波特率,用逻辑分析仪量一位的宽度,算出真实值。如果对端误差大,把自己这边改成整数分频能精确命中的波特率,比如 9600 在 42MHz 下误差为 0,或者把 USART2 的时钟源切到 APB2 的思路走不通,就得降主频让 APB1 落在更友好的频率上。还有一个办法是打开 USART 的过采样 8 倍模式,HAL_UART_Init里把OverSampling设成UART_OVERSAMPLING_8,采样点更靠中间,容忍度会好一些,代价是最高波特率减半。
5.3 只有第一个口进中断:NVIC 使能漏了
现象:六个口都初始化成功,HAL_UART_Receive_IT也调了,串口助手往 USART2 发数据,程序毫无反应,打断点发现USART2_IRQHandler根本没进去。USART1 一切正常。
原因:HAL_UART_Receive_IT只负责开外设内部的中断使能位(CR1 的 RXNEIE),NVIC 那一层的HAL_NVIC_EnableIRQ必须单独调。CubeMX 里如果只在 NVIC 标签页勾了 USART1,另外五个口的 NVIC 使能不会自动生成,代码编译能过,中断就是进不来。
解决:把六个HAL_NVIC_EnableIRQ全部显式写出来,写完之后用调试器看一眼 NVIC 的 ISER 寄存器,六个对应的位是不是都置 1 了。这一条看着低级,实际在「先调通一个口再复制」的开发节奏里非常容易漏,复制代码时把初始化函数复制了,NVIC 那几行忘了带。
5.4 DMA 收包丢尾字节:IDLE 标志和 NDTR 的读取时序
现象:用 DMA 加空闲中断收不定长数据,大部分帧完整,偶尔最后一两个字节丢失,丢的位置不固定。
原因:IDLE 中断触发后,代码里先读hdma->Instance->NDTR算长度,再去清 IDLE 标志。这两个动作之间如果正好来了新字节,DMA 已经把数据写进缓冲、NDTR 也减了,但清除标志的操作会把新的一次 IDLE 事件一并清掉,结果是长度算多了或者这次接收被吞掉。
解决:按「先清标志、再读 NDTR、最后停 DMA」的顺序处理,或者直接用 HAL 的HAL_UARTEx_RxEventCallback,它在内部已经处理好了时序。自己写中断服务函数的话,进中断第一件事是读SR再读DR把标志清干净(这是 F4 的清标志要求,只读 SR 不清),然后再算长度。另外 DMA 缓冲建议按 2 的幂开,且比最大帧长留出至少 32 字节余量,避免刚好写满时 NDTR 回绕导致长度算出天文数字。
5.5 上电就 HardFault:优先级分组写晚了
现象:程序烧进去,复位后立刻进 HardFault,调试器看调用栈停在HAL_NVIC_SetPriority里面,或者根本看不出在哪。
原因:HAL_NVIC_SetPriority内部会检查当前的优先级分组,如果分组还没设置(AIRCR 的 PRIGROUP 字段是复位默认值),传入的抢占优先级和子优先级参数超出当前分组允许的范围,HAL 会直接进assert_failed死循环。六串口工程里初始化函数多,很容易出现某个口的 NVIC 配置写在HAL_Init()之前的情况。
解决:HAL_Init()必须是main()里第一个被调用的 HAL 函数,它在内部完成优先级分组设置和 SysTick 配置。所有HAL_NVIC_SetPriority调用都放在HAL_Init()之后。如果工程里用了 FreeRTOS,HAL_Init()之后、osKernelInitialize()之前不要改分组,FreeRTOS 的移植层会自己设成它需要的值,两边打架的结果就是任务调度和中断优先级全乱。调试时可以在main()开头加一句读 AIRCR 的打印,确认分组值是不是 3(对应 GROUP_4)。
6. 验收:用三级回环把六个口逐个跑通再上业务
六串口工程最忌讳「编译通过就算装完」。我习惯分三级验收,每级都有明确的通过标准,任何一级不过就不往下走。
第一级是单口自发自收。把每个口的 TX 和 RX 用杜邦线短接,发一串 0x00 到 0xFF 的递增数据,收回来逐字节比对。这一级验证的是引脚复用、时钟使能、波特率寄存器这三件事,任何一项配错都会在这一步暴露。六个口挨个跑,不要图快一次全短接,出问题时分不清是哪个口。
/* 单口回环测试:发送 256 字节递增序列,收到的数据存进 cmp 缓冲后逐字节比对 */ uint8_t pattern[256], cmp[256]; for (int i = 0; i < 256; i++) pattern[i] = (uint8_t)i; for (int port = 0; port < 6; port++) { memset(cmp, 0, sizeof(cmp)); HAL_UART_Transmit(&huart[port], pattern, 256, 100); /* 阻塞发送,测试阶段不追求效率 */ HAL_UART_Receive(&huart[port], cmp, 256, 200); /* 超时 200ms,收不满就是有问题 */ if (memcmp(pattern, cmp, 256) != 0) { printf("port %d loopback FAIL\r\n", port); /* 打印到调试串口,别用被测口输出 */ } }第二级是双口互发。USART1 发、USART2 收,然后反过来,交叉验证四个组合。这一级验证的是两个口同时工作时中断和 DMA 有没有互相干扰。测试时把两边的波特率故意设成不一样,确认接收端确实报溢出错误而不是静默丢弃——这能证明错误标志的处理逻辑是对的。
第三级是六口并发压测。六个口全部接到 PC 的六路 USB 转串口上,用脚本同时往六个口发随机长度的数据包,跑半小时以上,统计每个口的误码率和丢包数。Python 的 pyserial 写这种脚本很顺手:
import serial, random, time # 六个口对应 PC 上的六个 COM,波特率和板子一致 ports = [serial.Serial(f"COM{i}", 115200, timeout=1) for i in range(3, 9)] deadline = time.time() + 1800 # 压测半小时 err = [0] * 6 while time.time() < deadline: for i, p in enumerate(ports): n = random.randint(1, 200) # 随机包长,覆盖短帧和长帧 p.write(bytes([random.randint(0, 255) for _ in range(n)])) back = p.read(n) # 板子做原样回显 if len(back) != n: err[i] += 1 # 长度不符记一次丢包 print("丢包统计:", err)半小时跑完,六个口的丢包数都应该是 0。如果有口持续丢包,回到第 5 章按现象对一遍;如果是偶发丢一两个,优先查 DMA 缓冲和 IDLE 时序,别急着怀疑芯片。
最后一步是用逻辑分析仪或者示波器抓一路波形,确认空闲电平、起始位宽度、停止位位置都对。软件层面全部通过、波形也干净,这套六串口配置才算真正装完。
从那以后我每次开新板子,都会先把六个口的引脚表和 SDIO、USB、I2C 这些外设的占用列在一张纸上,确认没有互斥再动 CubeMX;回环测试也一定从单口开始,绝不六个口一起上电赌运气。希望帮到你。
本文还有配套的精品资源,点击获取