简介:基于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 总线默认 42MHz,APB2 总线默认 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 编号 | 所在总线 | 备注 |
|---|---|---|---|---|
| USART1 | PA9 / PA10 | AF7 | APB2 | 可优先分配高速口 |
| USART2 | PA2 / PA3 | AF7 | APB1 | 避开 PA4/PA5 模拟输入 |
| USART3 | PB10 / PB11 | AF7 | APB1 | 注意和 I2C2 的复用冲突 |
| UART4 | PA0 / PA1 | AF8 | APB1 | PA0 同时是 ADC 输入引脚,别混用 |
| UART5 | PC12 / PD2 | AF8 | APB1 | 只有这一组引脚 |
| USART6 | PC6 / PC7 | AF8 | APB2 | 高速数据线的备选 |
选引脚时要养成查数据手册 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_RxEventCallback:
void 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 非常充裕;如果测试波特率是 1200bps,16 字节约要 133ms,超时要放大到 300ms。测试时不要用接收中断回调里去比对,会让整个测试失去真实性,正确做法是开启 DMA 接收或者中断接收,主循环只查标志。
回环测试通过后,还有一个更贴近实际场景的验证方法:把六路串口接到一个可以同时发包的测试工具上,用递增序号组包,每包 20 字节,按 20ms 间隔同时往六路发送。跑一小时,统计每路收到的包序号是否有空洞,序号断档就是丢包。这一步能暴露中断优先级问题和 DMA 缓冲回卷问题。
进阶用法上,我一般会保留一路串口作为调试口,用printf重定向输出日志,其他五路走 DMA 接收 + 环形缓冲。调试口的波特率故意设得比其他路低一档,避免调试打印影响关键数据的时序。多串口工程最容易犯的错是“什么都想靠调试口打印来看”,结果调试口成了瓶颈和干扰源。六路串口的正确打开方式是:每路串口的收发路径完全独立,调试口只做旁路观察,不参与业务数据流。
最后说一个习惯:每次新板子拿到手,我不会一口气把六路串口全部写好,而是先让 UART1 跑通回环,再逐个点亮 UART2、UART3……每增加一路,跑 10 分钟回环确认不丢数据,再动下一路。遇到过太多次“五路都正常、第六路一开第一路开始丢”的情况,全都靠这个逐路增加的方法快速定位到中断优先级分配不合理或者 DMA 通道冲突。多串口方案没有太多高深理论,把优先级、缓冲区、接收方式定死,剩下的就是耐心验证。希望帮到你。
本文还有配套的精品资源,点击获取