资讯动态

STM32F407六串口同时收发的完整方案:中断、DMA与环形缓冲避坑指南

发布时间:2026/10/15 2:13:47 来源:尧图企业网站定制
简介基于STM32F407的串口多路通信工程模板面向需要同时管理6路UART的嵌入式开发者适用于工业控制、多从机通信与数据采集等场景也适合中高级嵌入式工程师与大学生创新项目参考学习。工程基于CubeMX图形化配置生成6个串口均启用DMA与空闲中断支持不定长度数据接收中断回调与缓冲区处理逻辑清晰可直接套用。包内共64个文件以40个HAL库头文件与19个C源码文件为主辅以IOC工程配置、MDK-ARM工程文件、启动汇编文件及uvprojx/uvoptx项目文件压缩包仅278KB便于下载与二次开发。若实际项目只需少量串口可在CubeMX中裁剪配置再删除少量与硬件相关的代码即可完成移植该工程默认使用片外8M晶振也可通过CubeMX调整适配不同主频需求。资源包目录划分明确便于按需检索调用。目前已有3674人学习下载。1. 六个串口同时用问题从来不在串口外设本身手上有 STM32F407 的板子你会发现它自带 USART1、USART2、USART3、UART4、UART5、USART6 这 6 个独立串口外设硬件上谁也不占谁的中断号、谁也不抢谁的 FIFO。可一旦真把它们全部打开跑数据情况就完全不一样了中断入口争抢 CPU、DMA 通道分配、共享内存的读写临界区任何一个环节没安排好轻则某个串口隔三差五丢字节重则整个系统看门狗复位。这篇文章不讲怎么点亮 LED而是直接拆解 6 个串口同时收发的完整方案——从时钟、引脚、中断优先级开始到中断架构设计、环形缓冲、DMA空闲中断接收最后给你一份可以直接抄的避坑清单和验证流程。适合正在做多路传感器采集、多设备通信网关、工控主板通信的开发者也适合已经调通一个串口、正准备把工程扩展到六路串口的人。读完你会明白六串口方案不是玄学是有一套固定套路可以稳定复现的。2. 先把六路串口的家底盘清楚时钟、引脚与初始化2.1 时钟树与总线分配USART1/6 和 USART2/3/UART4/5 不是一家人STM32F407 的系统主频跑在 168MHz 时APB1 总线默认 42MHzAPB2 总线默认 84MHz。这直接影响串口的波特率上限USART1 和 USART6 挂在 APB2 上最高可以跑到比较高的波特率USART2、USART3、UART4、UART5 挂在 APB1 上总线频率只有 APB2 的一半。实际做项目时921600bps 以下大家都能扛但如果你打算把某一路拉到 1.5Mbps 甚至更高建议优先把这一路分配给 USART1 或 USART6。另一个容易忽略的点是时钟使能。六个串口分布在两条总线上初始化时两条总线的时钟都要打开漏了一个总线时钟对应串口的寄存器写进去全是无效的。常见的翻车现象是单独初始化某一个串口正常一旦把六个串口的初始化代码合到一起某个串口不工作查了半天发现是该总线的 RCC 时钟没有使能。__HAL_RCC_USART1_CLK_ENABLE(); /* USART1 挂在 APB2 */ __HAL_RCC_USART6_CLK_ENABLE(); /* USART6 挂在 APB2 */ __HAL_RCC_USART2_CLK_ENABLE(); /* USART2 挂在 APB1 */ __HAL_RCC_USART3_CLK_ENABLE(); /* USART3 挂在 APB1 */ __HAL_RCC_UART4_CLK_ENABLE(); /* UART4 挂在 APB1 */ __HAL_RCC_UART5_CLK_ENABLE(); /* UART5 挂在 APB1 */这里有个细节GPIO 的时钟也要单独使能。比如你用 PA9/PA10 做 USART1那么__HAL_RCC_GPIOA_CLK_ENABLE()不能省。很多人初始化串口不工作不是 UART 配置错了而是 GPIO 复用根本没生效。2.2 引脚分配一张表避开复用冲突F407 的每个串口在复用功能AF映射表里都有多组可选引脚但六路同时使用时必须选一组互不冲突的组合。我常用下面这套在 LQFP100 封装下可以完整放出六个串口且每组引脚都避开 JTAG、以太网、USB 等常用接口。串口推荐 TX / RX 引脚AF 编号所在总线备注USART1PA9 / PA10AF7APB2可优先分配高速口USART2PA2 / PA3AF7APB1避开 PA4/PA5 模拟输入USART3PB10 / PB11AF7APB1注意和 I2C2 的复用冲突UART4PA0 / PA1AF8APB1PA0 同时是 ADC 输入引脚别混用UART5PC12 / PD2AF8APB1只有这一组引脚USART6PC6 / PC7AF8APB2高速数据线的备选选引脚时要养成查数据手册 AF 表的习惯不要背引脚号。F407 不同封装LQFP64、LQFP100、LQFP144、BGA176能引出的 GPIO 数量不一样比如 USART6 的 PG14/PG9 在 LQFP144 上才有小封装根本没办法用。2.3 六路串口初始化代码循环结构比复制粘贴好维护同时初始化六个串口代码写起来最容易膨胀。见过有人把HAL_UART_Init复制六份每份改个外设基址这样的代码不仅长改参数时还容易漏。我一般用一张配置表加一个循环来搞定#define UART_PORT_NUM 6 typedef struct { USART_TypeDef *instance; /* 外设基址 */ GPIO_TypeDef *tx_port; uint16_t tx_pin; GPIO_TypeDef *rx_port; uint16_t rx_pin; uint8_t af; /* 复用功能编号 */ } uart_hw_cfg_t; static const uart_hw_cfg_t uart_cfg[UART_PORT_NUM] { {USART1, GPIOA, GPIO_PIN_9, GPIOA, GPIO_PIN_10, GPIO_AF7_USART1}, {USART2, GPIOA, GPIO_PIN_2, GPIOA, GPIO_PIN_3, GPIO_AF7_USART2}, {USART3, GPIOB, GPIO_PIN_10, GPIOB, GPIO_PIN_11, GPIO_AF7_USART3}, {UART4, GPIOA, GPIO_PIN_0, GPIOA, GPIO_PIN_1, GPIO_AF8_UART4}, {UART5, GPIOC, GPIO_PIN_12, GPIOD, GPIO_PIN_2, GPIO_AF8_UART5}, {USART6, GPIOC, GPIO_PIN_6, GPIOC, GPIO_PIN_7, GPIO_AF8_USART6}, };初始化函数里先配置 GPIO 复用再填 UART 句柄参数最后使能中断UART_HandleTypeDef huart[UART_PORT_NUM]; void MX_UART_Init(void) { GPIO_InitTypeDef gpio {0}; uint32_t baudrate[UART_PORT_NUM] {115200, 115200, 115200, 115200, 115200, 115200}; for (uint8_t i 0; i UART_PORT_NUM; i) { /* 使能 GPIO 时钟简化写法实际建议按端口分组使能 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_GPIOD_CLK_ENABLE(); /* 配置 TX 引脚复用推挽输出 */ gpio.Pin uart_cfg[i].tx_pin; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Alternate uart_cfg[i].af; HAL_GPIO_Init(uart_cfg[i].tx_port, gpio); /* 配置 RX 引脚复用浮空输入 */ gpio.Pin uart_cfg[i].rx_pin; gpio.Mode GPIO_MODE_AF_INPUT; gpio.Pull GPIO_NOPULL; HAL_GPIO_Init(uart_cfg[i].rx_port, gpio); /* 填充 UART 句柄 */ huart[i].Instance uart_cfg[i].instance; huart[i].Init.BaudRate baudrate[i]; huart[i].Init.WordLength UART_WORDLENGTH_8B; huart[i].Init.StopBits UART_STOPBITS_1; huart[i].Init.Parity UART_PARITY_NONE; huart[i].Init.Mode UART_MODE_TX_RX; huart[i].Init.HwFlowCtl UART_HWCONTROL_NONE; huart[i].Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart[i]); } }参数说明OverSampling用 16 倍采样是默认推荐波特率误差表现最稳如需更高波特率可改 8 倍采样但必须确认接收端的时钟容差。GPIO 速度建议选HIGH六路一起收发时信号边缘更干净代价是轻微增加 EMI实际产品如果对辐射敏感可以只对长线串口用 HIGH短线用 MEDIUM。2.4 中断优先级分配这是多串口方案里最像玄学的一步F407 的 NVIC 中断优先级分组一般设成NVIC_PRIORITYGROUP_4即 0~15 全部作为抢占优先级没有子优先级这样行为最好预测。六个串口中断的优先级不能随便给我给出一个实测稳定的分配策略SysTick 保持最高优先级HAL 默认是 15 最低需要手动改六个串口按数据实时性和数据量从高到低分配 1~6 的优先级其他外设中断统一排到 7 以上。这个顺序为什么重要想象一个场景UART1 正在接收一帧数据此时 UART2、UART3 也来中断了。如果六个串口都是相同优先级NVIC 会按中断向量号大小决定响应顺序你无法预知哪路先服务。如果某个串口优先级设得特别低在主循环耗时操作或者看门狗喂狗期间它可能会连续多次丢失数据而且这种丢失很有隐蔽性——波特率不高时完全正常一提高波特率就丢。更麻烦的是 SysTick 优先级如果比串口低串口中断里调用HAL_Delay会直接卡死因为 SysTick 中断进不来系统时钟停摆。这个坑我在后面避坑章节还会细讲。3. 中断架构怎么设计六路串口的收发不能被一个回调函数堵死3.1 先别急着写六个中断回调用一个设备池管理六路状态HAL 库的中断机制是每个串口中断向量都走同一个HAL_UART_IRQHandler最终回调到HAL_UART_RxCpltCallback或者HAL_UARTEx_RxEventCallback。很多新手直接在回调里写if (huart huart[0]) ... else if (huart huart[1]) ...六个串口就是六个 else if代码丑不说回调里一旦有耗时操作比如处理协议、复制数据其他五个串口只能干等。回调函数是所有串口共享的入口必须保持短小把数据挪到缓冲区就立刻返回。我的做法是先定义一个串口设备池结构体typedef struct { UART_HandleTypeDef *huart; /* 关联到 init 阶段生成的句柄 */ uint8_t rx_frame[128]; /* 每路串口独立的接收缓冲 */ volatile uint16_t rx_len; volatile uint8_t rx_complete; /* 一帧接收完成标志 */ uint8_t tx_buf[128]; volatile uint8_t tx_busy; /* 发送忙标志 */ uint32_t error_cnt; /* 错误计数调试用 */ } uart_device_t; uart_device_t uart_dev[UART_PORT_NUM];这样每个串口的接收状态、发送状态、错误计数都是独立的。主循环里读rx_complete清零后处理协议中断回调里只往缓冲里放数据、置标志。更重要的是回调函数里通过句柄反查是第几路串口代码统一而简短static uint8_t get_uart_index(UART_HandleTypeDef *huart) { for (uint8_t i 0; i UART_PORT_NUM; i) if (uart_dev[i].huart huart) return i; return 0xFF; /* 无效索引 */ }注意回调函数里不要调用printf、不要做memcpy大块搬运、不要调用任何带延时的函数。数据量大时可以只置标志搬运交给主循环的轮询任务。这个设计原则是“中断只做最快的事”。3.2 逐字节中断接收 vs 空闲中断接收按数据形态选型六路串口同时工作接收方式必须仔细选。最简单的做法是每路HAL_UART_Receive_IT(huart, buf, 1)每收到一个字节进一次中断把字节写进环形缓冲。这个方案代码少、通用性强但缺点很明显每路每字节都打断 CPU 一次六路同时工作在 115200bps 时CPU 每秒要被打断约 6×11520 次整体响应变慢如果还在跑实时控制任务会影响控制周期。如果通信对象是以“帧”为单位的比如每条报文 10~40 字节、帧间有停顿推荐用空闲中断IDLE配合固定长度接收。空闲中断的原理是串口在一段字节流接收过程中总线空闲超过一个字节时间后触发 IDLE 中断此时一帧数据已经在 RX 寄存器里待着了。HAL 库新版提供了HAL_UARTEx_ReceiveToIdle_IT用法如下uint8_t rx_buf[6][128]; /* 每路独立接收缓冲 */ void uart_start_receive_all(void) { for (uint8_t i 0; i UART_PORT_NUM; i) { HAL_UARTEx_ReceiveToIdle_IT(huart[i], rx_buf[i], 128); } }接收完成的回调是HAL_UARTEx_RxEventCallbackvoid HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { uint8_t idx get_uart_index(huart); if (idx 0xFF) return; /* 把一帧完整数据交给协议解析只做入队和置标志 */ protocol_input(idx, rx_buf[idx], Size); /* 必须重新开启接收否则后续数据进不来 */ HAL_UARTEx_ReceiveToIdle_IT(huart, rx_buf[idx], 128); }参数说明Size是本次空闲中断时实际收到的字节数由 HAL 库内部根据 DMA/中断计数计算好。128是缓冲上限如果你的协议帧最长只有 60 字节把缓冲缩到 64 更省内存。重新开启接收时同一个缓冲要小心旧数据还没被主循环处理完新数据就覆盖进来了。所以要么准备双缓冲交替使用要么在回调里用队列把数据拷走要么在协议处理速度有保证的前提下靠rx_complete标志做覆盖。我一般会准备双缓冲rx_buf[2][128]空闲中断只在两个缓冲之间切换保证主循环永远有一次机会处理完整的一帧。3.3 中断服务函数与主循环的分工不让协议解析进中断中断服务函数写在stm32f4xx_it.c里六个串口各自有独立的中断向量但函数体都非常短void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart[0]); } void USART2_IRQHandler(void) { HAL_UART_IRQHandler(huart[1]); } void USART3_IRQHandler(void) { HAL_UART_IRQHandler(huart[2]); } void UART4_IRQHandler(void) { HAL_UART_IRQHandler(huart[3]); } void UART5_IRQHandler(void) { HAL_UART_IRQHandler(huart[4]); } void USART6_IRQHandler(void) { HAL_UART_IRQHandler(huart[5]); }主循环的任务是轮询串口设备池里的标志位逐路处理协议。这里的主循环不是一个 while(1) 里从头处理到尾而是按优先级轮询void uart_poll(void) { for (uint8_t i 0; i UART_PORT_NUM; i) { if (uart_dev[i].rx_complete) { uint8_t idx i; uart_dev[i].rx_complete 0; protocol_parse(idx, uart_dev[i].rx_frame, uart_dev[i].rx_len); } } }处理六路串口数据时绝对不能在一路的协议解析里阻塞太久否则其他路即便有中断缓冲也可能会因为缓冲耗尽而丢数据。一个实用的做法是每路协议解析设定单次最大耗时比如 1ms超过就提前退出剩下的状态机延到下一轮继续跑。还有rx_complete标志在中断里置位、在主循环里清零必须声明为volatile这个细节漏了编译器优化后可能出现标志永远读不到的情况。4. 环形缓冲与 DMA 接收六路高速收发不丢数据的关键4.1 多串口丢数据的真正原因很多人觉得丢数据是波特率不对、线接得不好其实对多串口系统来说最典型的丢数据场景是缓冲区竞争。中断把收到的一个字节放进缓冲区主循环同时从缓冲区取走数据如果这里没有环形缓冲或者锁机制不完善就会出现写指针覆盖读指针、数据错位的情况。另一个隐蔽原因是临界区过长。有些开发者在处理一路串口数据时习惯关总中断__disable_irq()处理完再打开。六路串口同时跑的时候关中断期间到来的串口字节全部堆在硬件寄存器里硬件 RX 寄存器只有一个字节的空间第二个字节进来时第一个字节就被覆盖了而且溢出标志ORE一旦置位会阻塞后续接收直到软件清除。这个现象在逻辑分析仪上看非常瘆人——波形明明对了但数据就是错。4.2 一个带溢出计数的环形缓冲实现环形缓冲是多串口方案的基石。它解决的是“中断写入 主循环读取”速度不匹配的问题。缓冲区大小取 2 的幂这样取模运算可以优化成按位与。#define RBUF_SIZE 1024 /* 必须是 2 的幂 */ typedef struct { uint8_t buffer[RBUF_SIZE]; volatile uint16_t head; /* 写指针只在中断/接收回调里修改 */ volatile uint16_t tail; /* 读指针只在主循环里修改 */ volatile uint16_t count; /* 当前有效字节数 */ volatile uint32_t overflow_cnt; /* 溢出次数供调试观察 */ } ring_buf_t; uint16_t ring_buf_write(ring_buf_t *rb, const uint8_t *data, uint16_t len) { uint16_t i; for (i 0; i len; i) { if (rb-count RBUF_SIZE) { rb-buffer[(rb-head) (RBUF_SIZE - 1)] data[i]; rb-head; rb-count; } else { rb-overflow_cnt; /* 缓冲满丢弃并记录 */ break; } } return i; /* 实际写入字节数 */ } uint16_t ring_buf_read(ring_buf_t *rb, uint8_t *out, uint16_t max_len) { uint16_t i; for (i 0; i max_len; i) { if (rb-count 0) break; out[i] rb-buffer[(rb-tail) (RBUF_SIZE - 1)]; rb-tail; rb-count--; } return i; }上面的实现里head和tail都是自增的环形关系由 (RBUF_SIZE - 1)完成。中断里写数据时只改head和count主循环读数据时只改tail和count。count是两处共享的需要volatile修饰但它的读写都是单条指令在 Cortex-M4 上不会撕裂。如果缓冲区是 32 位的unsigned int也是单条 LDR/STR同样安全。这里有个容易被忽略的问题如果串口中断的优先级足够高主循环里读缓冲时中断可能插入修改count导致主循环读到不一致的状态。实际项目中我会把ring_buf_read里的count先快照到局部变量uint16_t ring_buf_read(ring_buf_t *rb, uint8_t *out, uint16_t max_len) { uint16_t cnt rb-count; /* 快照之后即使中断更新也不影响本次读取 */ uint16_t i 0; while (cnt (i max_len)) { out[i] rb-buffer[(rb-tail) (RBUF_SIZE - 1)]; rb-tail (rb-tail 1) (RBUF_SIZE - 1); cnt--; } rb-count cnt; /* 注意这里重新赋值会丢掉中断期间新写入的增量所以实际上应该 */ return i; }停一下上面这段代码里rb-count cnt是有问题的写法。正确做法是递减rb-count而不是整体赋值uint16_t ring_buf_read(ring_buf_t *rb, uint8_t *out, uint16_t max_len) { uint16_t i 0; uint16_t cnt; /* 关闭对应串口中断避免读写竞争 */ __disable_irq(); cnt rb-count; __enable_irq(); while ((cnt 0) (i max_len)) { out[i] rb-buffer[(rb-tail) (RBUF_SIZE - 1)]; rb-tail (rb-tail 1) (RBUF_SIZE - 1); cnt--; /* 每取出一个字节count 减一 */ __disable_irq(); rb-count--; __enable_irq(); } return i; }这段代码用开关中断保证count的一致性但因为循环里频繁开关中断效率不高。更好的方案是用无锁环形缓冲读指针只由读者改写指针只由写者改两者各自独立count可以用head - tail推导完全不共享。这才是多串口中断场景下最稳的做法uint16_t ring_buf_read_nolock(ring_buf_t *rb, uint8_t *out, uint16_t max_len) { uint16_t i 0; while (i max_len) { if (rb-tail rb-head) /* 空 */ break; out[i] rb-buffer[(rb-tail) (RBUF_SIZE - 1)]; rb-tail (rb-tail 1) (RBUF_SIZE - 1); } return i; }在这个版本里读方只读tail写方只写head两者没有共享同一个变量自然没有竞争条件。count如果需要查看就用(head - tail) (RBUF_SIZE - 1)来计算。写方需要判断满时(head 1) mask tail不能让 head 追上 tail。这种设计在实践中完全避免了临界区问题值得直接抄下来用。4.3 DMA 接收把串口中断从每字节一次降到每帧一次如果六个串口都在高波特率下工作逐字节中断的 CPU 开销会非常可观。常见做法是 DMA 循环接收 空闲中断定帧DMA 负责把字节搬运进内存只有一帧接收完毕时 CPU 才被中断一次。配置 DMA 时有个关键点F407 的 DMA 请求映射是固定的每个串口的 RX 对应特定的 DMA 流和通道必须查自己手头参考手册的 DMA 请求映射表。以 USART1 为例我常用的配置是这样DMA_HandleTypeDef hdma_uart1_rx; static void MX_USART1_DMA_RX_Init(UART_HandleTypeDef *huart) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_uart1_rx.Instance DMA2_Stream2; /* USART1_RX 的典型映射 */ hdma_uart1_rx.Init.Channel DMA_CHANNEL_4; hdma_uart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_uart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_uart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_uart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_uart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_uart1_rx.Init.Mode DMA_CIRCULAR; /* 循环模式持续接收 */ hdma_uart1_rx.Init.Priority DMA_PRIORITY_HIGH; hdma_uart1_rx.Init.FIFOMode DMA_FIFOMODE_DISABLE; HAL_DMA_Init(hdma_uart1_rx); __HAL_LINKDMA(huart, hdmarx, hdma_uart1_rx); /* 接收缓冲和长度按实际需求定义例如外部传入 buf[128] */ HAL_UART_Receive_DMA(huart, dma_rx_buf[0], 128); /* 使能串口空闲中断 */ __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); }循环模式的意思DMA 从串口数据寄存器搬到内存搬运完设定的 128 字节后自动回卷到缓冲起始地址继续搬不需要软件干预。主循环和中断需要知道“新数据到哪了”最有效的办法是在空闲中断里读 DMA 当前计数器的值。void HAL_UART_IRQHandler(huart[0]); /* 在 USART1_IRQHandler 中会走到这里 */ /* 以 USART1 为例的 IDLE 中断处理思路 */ void handle_uart1_idle(UART_HandleTypeDef *huart) { if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart); /* F4 上该宏内部会先读 SR 再读 DR */ /* 计算 DMA 已接收字节数 缓冲总长 - DMA 剩余计数 */ uint16_t rx_count 128 - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_uart1_rx); if (rx_count 0) { /* 把 [dma_rx_buf[0], dma_rx_buf[0]rx_count) 这一帧送入协议层 */ protocol_input(0, dma_rx_buf[0], rx_count); } } }参数说明__HAL_DMA_GET_COUNTER返回的是 DMA 剩余要搬的字节数拿缓冲区总长减它得到“已搬入”的字节数。注意128 - counter得到的是累计值如果两次空闲中断之间 DMA 已经回绕过一次这个差值就会出错所以 128 字节缓冲要配合协议帧长度来设计最大帧必须小于 128。帧短、缓冲大这个方案就非常稳。DMA 接收的好处是 CPU 几乎不参与数据搬移六路串口同时满载时CPU 只需要在每帧结束时进一次中断做协议入队。坏处是帧边界必须靠空闲中断判断如果外部设备连续不断流式发送数据、没有帧间停顿空闲中断永远不会触发数据会一直在 DMA 缓冲里循环覆盖。针对流式数据就得改用 DMA 半满/全满中断或者定时扫描__HAL_DMA_GET_COUNTER的增量来切数据块。这是实战中比 D 收录瞬常被人忽略的边界条件。5. 六路串口同时用的 5 个必踩坑与排查思路5.1 开了六路串口后某一路突然“饿死”其他路正常现象单独测试每一路都通全部打开跑十分钟后UART4 完全收不到数据其他五路正常。重启后又能跑一段时间。原因UART4 的中断优先级设成了和其他某一路相同甚至更低。当多路串口同时有数据到达时高优先级的中断反复打断低优先级中断初始化流程导致低优先级那一路的中断请求永远来不及响应或者响应时数据已经被覆盖。解决把六个串口中断优先级明确拉开从高到低排列。UART4 如果数据量小、实时性要求低也要保证至少比它可能被打断的临界区优先级高。检查方法是给每个串口加中断次数计数器在中断里cnt主循环里每秒打印一次如果某一路的计数长时间不增长基本就是饿死了。5.2 串口中断回调里调了一次 HAL_Delay系统卡死现象USART1 的接收回调里想做一个延时处理加了HAL_Delay(1)结果串口一收数据整个系统就像死机一样。单独跑 USART1 的时候没这个问题六路全开时必现。原因HAL_Delay 依赖 SysTick 中断如果 SysTick 优先级低于这个串口中断优先级串口中断进来后 SysTick 无法抢占HAL_Delay里的uwTick永远不更新程序卡死在等待循环里。解决回调里坚决不用阻塞延时。需要延时就置一个标志让主循环里的状态机计时处理或者把 SysTick 优先级设为 0最高优先级但这会引入新的问题——任何临界区都要避让 SysTick所以治本办法是中断回调只做最轻量的标志位更新和缓冲写入。5.3 六路全开时偶发出现连续乱码单独测一路完全正常现象逻辑分析仪抓波形每个字节的电平宽度都对但接收方解析出来偶尔会多一个字节、丢一个字节。多发生在高波特率、多路同时收发时。原因跨串口的全局临界区太长。比如某路数据处理里为了统计信息执行了__disable_irq()中间有个耗时 500us 的浮点运算期间其他五路的硬件 RX 收到数据会因为 RX 寄存器被占满而触发 ORE 错误。这个错误不仅覆盖新数据而且不清除的话后续接收全废。解决把__disable_irq()/__enable_irq()只在必要的最短代码段内使用规范做法是改用无锁环形缓冲设计避免任何全局关中断。检查 ORE 错误可以在中断里读UART_FLAG_ORE全局搜代码里有没有不合理的大段关中断操作。5.4 HAL_UARTEx_ReceiveToIdle_IT 和 HAL_UART_Receive_DMA 同时用接收错乱现象一段代码里先用HAL_UART_Receive_DMA做接收又为了处理一帧边界调用了HAL_UARTEx_ReceiveToIdle_IT结果两个函数反复冲突移植到六路身上时各路数据错乱甚至 HAL 库断言报错。原因这两种方式本质上是同一个 HAL 接收状态机同时调用会把状态机的状态置乱。HAL 库内部通过huart-RxState判断当前是否在接收两个路径同时操作这个状态变量结果不可预期。解决同一路串口的“接收”全程只用同一种方式。要么纯 DMA 自己读 IDLE 标志要么用HAL_UARTEx_ReceiveToIdle_IT。如果要切换方式先调用HAL_UART_DMAStop或HAL_UART_AbortReceive把状态机复位。我一般把接收方式和协议帧格式绑定死不在运行期切换。5.5 六路串口同时接外部设备偶发数据错误和“烧”现象现象板卡自己和自己串口环回测试完全正常一旦接上外部传感器、GPS 模块、其他 MCU就出现整包错位、偶发乱码严重时串口芯片发烫。单独接一路问题小六路全接上很容易出问题。原因多路设备之间地电位不一致。TTL 电平更受地环路影响当外部设备的地和板卡地之间有电压差时串口 RX/TX 线上的噪声叠加导致采样点偏移。每隔几米线甚至几厘米线不同设备的地电位差就会显现。解决所有外部设备必须共地优先选用带隔离的串口方案如数字隔离器加 DC-DC或者至少用带电平转换的 RS232/RS485 方案。板卡和外部设备之间TX/RX 线不要跟电源线长距离并行走线。这个坑不是软件能补的设计阶段就定好隔离方案最省心。6. 回环测试给六路串口一起发体检报告把代码写完只是第一步六路同时验证需要一套系统性的回环测试流程。我习惯的做法是把六路串口的 TX 和 RX 用杜邦线直接短接然后在主循环里对每路串口做“发送固定序列 → 等待接收 → 比对数据”的测试。typedef struct { uint32_t tx_cnt; uint32_t rx_cnt; uint32_t err_cnt; uint32_t timeout_cnt; } loop_test_t; loop_test_t test_stat[UART_PORT_NUM]; void loopback_test(uint8_t idx) { uint8_t tx_buf[16], rx_buf[16]; for (uint8_t i 0; i 16; i) tx_buf[i] (uint8_t)(idx i); HAL_UART_Transmit_IT(huart[idx], tx_buf, 16); /* 等待接收完成超时时间按波特率估算例如 115200 下 16 字节约 1.4ms */ uint32_t timeout HAL_GetTick() 20; while (HAL_GetTick() timeout) { if (rx_len[idx] 16) { test_stat[idx].rx_cnt; break; } } if (HAL_GetTick() timeout) { test_stat[idx].timeout_cnt; return; } /* 比对接收缓冲 */ if (memcmp(rx_buf, tx_buf, 16) ! 0) test_stat[idx].err_cnt; else test_stat[idx].tx_cnt; }参数说明20ms超时对 115200bps 非常充裕如果测试波特率是 1200bps16 字节约要 133ms超时要放大到 300ms。测试时不要用接收中断回调里去比对会让整个测试失去真实性正确做法是开启 DMA 接收或者中断接收主循环只查标志。回环测试通过后还有一个更贴近实际场景的验证方法把六路串口接到一个可以同时发包的测试工具上用递增序号组包每包 20 字节按 20ms 间隔同时往六路发送。跑一小时统计每路收到的包序号是否有空洞序号断档就是丢包。这一步能暴露中断优先级问题和 DMA 缓冲回卷问题。进阶用法上我一般会保留一路串口作为调试口用printf重定向输出日志其他五路走 DMA 接收 环形缓冲。调试口的波特率故意设得比其他路低一档避免调试打印影响关键数据的时序。多串口工程最容易犯的错是“什么都想靠调试口打印来看”结果调试口成了瓶颈和干扰源。六路串口的正确打开方式是每路串口的收发路径完全独立调试口只做旁路观察不参与业务数据流。最后说一个习惯每次新板子拿到手我不会一口气把六路串口全部写好而是先让 UART1 跑通回环再逐个点亮 UART2、UART3……每增加一路跑 10 分钟回环确认不丢数据再动下一路。遇到过太多次“五路都正常、第六路一开第一路开始丢”的情况全都靠这个逐路增加的方法快速定位到中断优先级分配不合理或者 DMA 通道冲突。多串口方案没有太多高深理论把优先级、缓冲区、接收方式定死剩下的就是耐心验证。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑