简介:面向STM32嵌入式开发者的串口通信参考代码包,基于STM32F405RGT6实现USART1至UART5共五路串口收发,适合正在学习HAL/标准外设库串口配置、需要多串口并行处理的中初级开发者参考。代码为每路串口定义了256字节接收缓冲区及计数、完成标志等全局变量,可直接对照理解中断接收与数据缓存的组织方式。压缩包共145个文件,约2.56MB,以42个h头文件与36个c源文件为主体,涵盖stm32f4xx_usart、rcc、tim、adc、can等外设驱动,另含Keil工程文件、启动汇编、链接脚本及hex、axf等编译产物,工程结构完整可直接打开编译。已有1218人学习下载。读者可从中获取五路串口初始化的完整写法、接收缓冲管理思路与工程模板,便于快速移植到自己的STM32F4项目中,减少底层配置的重复调试。
1. 从一块板子五个串口说起:这份 STM32F405 串口通信代码能解决什么
手上拿到一块 STM32F405RGT6 的核心板,五个 USART 全都要用起来,一个接调试口,一个接上位机,一个挂传感器,一个对接无线模块,最后一个留给备用扩展——这种场景在嵌入式硬件项目里太常见了。麻烦在于,很多人第一次配多串口时,要么中断优先级打架,要么 DMA 通道撞车,要么波特率对不上收一堆乱码。这份代码资源就是围绕 STM32F405RGT6 的 USART1 到 USART5 五路串口通信做的一套完整实现,覆盖轮询、中断、DMA 几种收发方式,适合正在做多串口数据采集、协议转发或者设备联调的开发者直接拿来改。它不玩虚的,核心就是让五个串口同时稳定跑起来,省掉你从零翻参考手册逐个核对引脚复用和时钟使能的时间。
2. 技术选型与工程结构:为什么是标准外设库加 HAL 双路线
2.1 芯片资源与串口分布先摸清楚
STM32F405RGT6 属于 STM32F4 系列,Cortex-M4 内核带 FPU,主频能到 168MHz,64 脚 LQFP 封装,Flash 1MB、SRAM 192KB。这个型号的串口资源分布是理解整份代码的前提:USART1 挂在 APB2 总线上,引脚默认是 PA9/PA10;USART2 在 APB1,PA2/PA3;USART3 也是 APB1,PB10/PB11;UART4 和 UART5 同样在 APB1,分别是 PC10/PC11 和 PC12/PD2。注意 UART5 的 RX 固定在 PD2,这个引脚没有重映射余地,布线时得提前留出来。
代码里对每个串口都做了独立的时钟使能和 GPIO 复用配置,没有图省事全塞在一个初始化函数里。这种拆法看着啰嗦,但后期单独调某一路串口时不用动其他三路,改起来心里有底。APB1 和 APB2 的时钟频率不同,波特率计算时如果搞混了总线时钟,算出来的分频值就是错的,收上来的全是乱码——这是多串口项目里翻车率最高的地方之一。
2.2 目录结构:按外设分层,不按功能堆砌
工程目录大致是这样组织的:
project/ ├── Core/ │ ├── Inc/ │ │ ├── usart1.h ~ usart5.h // 五路串口各自的头文件 │ │ ├── ring_buffer.h // 环形缓冲区 │ │ └── protocol.h // 帧解析接口 │ └── Src/ │ ├── usart1.c ~ usart5.c // 五路串口初始化与收发 │ ├── ring_buffer.c │ └── protocol.c ├── Drivers/ │ ├── CMSIS/ // 内核头文件 │ └── STM32F4xx_HAL_Driver/ // HAL 库 ├── BSP/ │ ├── bsp_clock.c // 系统时钟配置 │ └── bsp_gpio.c // 通用 GPIO └── main.c每路串口一个.c/.h对,好处是编译依赖清晰,改 USART3 不会牵连 USART1。环形缓冲区单独抽出来,是因为五路串口如果都用同一套收发缓冲逻辑,抽成公共模块比每路复制一遍强得多。协议解析层放在最上面,串口只负责搬字节,解析交给 protocol 模块,职责分开后调试时能快速定位是链路问题还是协议问题。
2.3 标准外设库和 HAL 怎么选
这份代码里能看到两种风格的痕迹:一部分用标准外设库的USART_InitTypeDef直接操作寄存器结构体,另一部分走 HAL 的HAL_UART_Init。实际项目里我一般不会混用,但作为学习资源,两条路线并存反而方便对照。标准外设库的代码更贴近寄存器,执行路径短,适合对时序敏感的场景;HAL 库封装度高,换芯片时移植成本低,但中断回调那套机制初次接触容易绕晕。
如果你是从 F1 系列转过来的,标准外设库的写法会更眼熟;如果是新项目起步,建议统一走 HAL,CubeMX 生成骨架后再把这份代码里的环形缓冲和协议解析搬进去。两种方式在波特率配置上的本质是一样的:都是往 BRR 寄存器写分频值,只是 HAL 帮你把UART_OVERSAMPLING_16这些细节包了一层。
3. 五路串口初始化拆解:从时钟使能到第一个字节发出
3.1 时钟与 GPIO 复用配置
以 USART1 为例,它挂在 APB2 上,引脚 PA9 做 TX、PA10 做 RX,复用模式要配成GPIO_Mode_AF,复用功能选GPIO_AF7_USART1。代码里这段是这样写的:
/* USART1 时钟与 GPIO 初始化 */ static void USART1_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 使能 GPIOA 和 USART1 时钟,注意 USART1 在 APB2 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); /* PA9 -> TX,复用推挽输出 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOA, &GPIO_InitStructure); /* PA10 -> RX,复用输入 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 关键一步:把引脚映射到 USART1 的复用功能 */ GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); }这里有几个参数值得说清楚。GPIO_OType_PP是推挽输出,串口 TX 必须用这个,开漏输出驱动能力不够,接长线时波形会塌。GPIO_PuPd_UP给 RX 上拉,空闲时保持高电平,避免悬空引脚引入噪声误触发起始位。GPIO_PinAFConfig这一步最容易被漏掉——只配了GPIO_Mode_AF但没做 AF 映射,引脚是复用模式了,可复用到哪个外设没指定,结果就是 TX 一点动静没有,拿示波器量也看不到波形。我见过不止一个新手卡在这里,查了半天以为是波特率问题。
USART2 到 USART5 的配置套路一样,区别只在时钟总线(APB1)和引脚。UART4/UART5 的复用功能编号和 USART1 不同,分别是GPIO_AF8_UART4和GPIO_AF8_UART5,写的时候别顺手复制成 USART 的 AF7。
3.2 波特率与帧格式配置
GPIO 配好后,串口本身的参数初始化:
/* USART1 参数配置:115200-8-N-1 */ void USART1_Init(uint32_t baudrate) { USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = baudrate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }USART_BaudRate传的是实际波特率数值,库函数内部会根据当前 APB 总线时钟自动算 BRR 分频值。这里有个隐含前提:调用这个函数之前系统时钟必须已经配好。如果SystemInit还没跑或者 PLL 没锁,APB1/APB2 的频率是默认的 HSI 16MHz,算出来的分频值偏大,实际波特率就飘了。常见做法是在main开头先调bsp_clock.c里的时钟配置,确认 168MHz 主频生效后再初始化串口。
帧格式 8-N-1 是绝大多数模块的默认值,但对接某些老设备时会遇到 7-E-1 或者 8-E-1,改USART_WordLength和USART_Parity就行。注意开了校验位之后字长要相应调整,8 位数据加校验实际占 9 位,USART_WordLength得设成USART_WordLength_9b,这个细节手册里写了但容易看漏。
3.3 中断优先级分组与 NVIC 配置
五路串口如果全开接收中断,优先级必须统一规划。代码里用的是 4 位抢占优先级、0 位子优先级的分配方式:
/* 五路串口中断优先级统一配置 */ static void USART_NVIC_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; /* 分组:4 位抢占,0 位子优先级 */ NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4); /* USART1 优先级最高,用于调试输出 */ NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); /* USART2~5 依次降低,避免高优先级长时间占用 */ NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_Init(&NVIC_InitStructure); /* USART3/4/5 同理,优先级设为 3/4/5 */ }抢占优先级决定了谁能打断谁。USART1 给最高,是因为它通常接调试口,出问题时得保证日志能优先打出来。USART2 到 USART5 依次递减,是为了防止某一路高频数据把其他路饿死。如果五路全设成同一优先级,中断响应顺序就变成先来先服务,某一路持续来数据时其他路可能长时间得不到处理,表现出来就是“某个串口偶尔丢包”。这个坑我在一个多传感器采集项目里踩过,后来把数据量最大的那一路优先级调低、配合 DMA 才稳住。
4. 收发核心链路:环形缓冲、中断与 DMA 三种姿势
4.1 环形缓冲区:解决中断里不能干重活的问题
串口接收中断里最忌讳做耗时操作。一个字节进来触发一次中断,如果在中断里直接解析协议、拼帧、做校验,主循环基本没机会跑。代码里的做法是中断只负责把字节塞进环形缓冲区,解析放到主循环:
/* 环形缓冲区结构,每路串口一个实例 */ typedef struct { uint8_t buffer[RX_BUF_SIZE]; // 缓冲区本体,大小 256 volatile uint16_t head; // 写指针,中断里更新 volatile uint16_t tail; // 读指针,主循环更新 } RingBuffer_t; /* 中断中调用:写入一个字节,满了就丢弃 */ void RingBuffer_Put(RingBuffer_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % RX_BUF_SIZE; if (next != rb->tail) { // 留一个空位区分满和空 rb->buffer[rb->head] = data; rb->head = next; } /* 缓冲区满时直接丢弃,不阻塞中断 */ } /* 主循环中调用:取出一个字节,返回 0 表示空 */ uint8_t RingBuffer_Get(RingBuffer_t *rb, uint8_t *data) { if (rb->head == rb->tail) return 0; *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RX_BUF_SIZE; return 1; }head和tail加了volatile,因为它们在中断和主循环两个上下文里被修改,编译器不能优化掉对它们的读写。缓冲区大小 256 是按最大帧长和中断响应间隔估的,如果某路串口波特率特别高、主循环又忙,可以加到 512 甚至 1024。留一个空位区分满和空是环形缓冲的经典做法,代价是牺牲一个字节容量,换来不用额外维护计数器。
4.2 中断接收与发送的完整流程
以 USART2 为例,中断服务函数里只做两件事:读数据寄存器、塞缓冲区;发送则用查询方式等 TXE 标志:
/* USART2 中断服务函数 */ void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART2); RingBuffer_Put(&rb_usart2, ch); // 只入缓冲,不解析 USART_ClearITPendingBit(USART2, USART_IT_RXNE); } /* 溢出错误处理:不清 ORE 会一直进中断 */ if (USART_GetFlagStatus(USART2, USART_FLAG_ORE) != RESET) { USART_ReceiveData(USART2); // 读 DR 清 ORE } } /* 阻塞式发送一个字节,带超时保护 */ uint8_t USART2_SendByte(uint8_t data) { uint32_t timeout = 0xFFFFF; while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET) { if (--timeout == 0) return 0; // 超时返回失败 } USART_SendData(USART2, data); return 1; }溢出错误 ORE 那段是血泪经验。串口接收溢出时硬件会置 ORE 标志,如果不清掉,中断会反复触发,程序看起来像死循环。清 ORE 的方法是先读 SR 再读 DR,代码里直接调USART_ReceiveData读一次 DR 就顺带清了。发送加了超时计数,防止 TXE 一直不置位时卡死——虽然正常情况不会发生,但硬件故障或者引脚短路时,没有超时保护的while循环就是黑匣子,调试器都断不下来。
4.3 DMA 方式:高波特率下的正确打开方式
USART1 接上位机、数据量大时,中断逐字节处理会成为瓶颈。代码里给 USART1 配了 DMA 接收,用空闲中断判断一帧结束:
/* USART1 DMA 接收初始化 */ void USART1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); /* USART1_RX 对应 DMA2 Stream2 Channel4 */ DMA_InitStructure.DMA_Channel = DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)dma_rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize = DMA_RX_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA2_Stream2, &DMA_InitStructure); DMA_Cmd(DMA2_Stream2, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 开空闲中断 }DMA 通道不能随便选,F405 的 DMA 映射表里 USART1_RX 固定是 DMA2 Stream2 Channel4,选错了数据根本进不来。循环模式让 DMA 自动回绕,配合空闲中断:一帧数据收完、总线空闲时触发 IDLE 中断,此时用DMA_GetCurrDataCounter算出已收字节数,再处理缓冲区。这种组合比逐字节中断效率高一个数量级,代价是要处理好缓冲区回绕时的数据拼接。
5. 避坑与排查:多串口项目里最容易翻车的几件事
5.1 波特率对不上,收上来全是乱码
现象:串口助手设 115200,板子也配 115200,收到的却是乱码或者一堆 0x00。原因多半是总线时钟没配对。USART1 在 APB2,其余在 APB1,如果系统时钟配置里 APB1 分频系数和代码假设的不一致,库函数算出的 BRR 就是错的。解决:在初始化串口前打印或者用调试器看RCC_GetClocksFreq返回的PCLK1_Frequency和PCLK2_Frequency,确认和预期一致;不一致就回头查bsp_clock.c里的 PLL 参数和分频设置。
5.2 中断进不去,或者进去就出不来
现象:数据明明到了 RX 引脚,中断服务函数就是不执行;或者执行一次后程序卡死。原因分两种:前者通常是 NVIC 没使能对应通道,或者USART_ITConfig忘了开 RXNE 中断;后者多半是 ORE 溢出标志没清,中断反复触发。解决:先确认NVIC_Init里NVIC_IRQChannelCmd是 ENABLE,再在中断里加上 ORE 判断和清除逻辑。用调试器在中断入口打断点,看是压根没进还是进了出不去,两种情况排查方向完全不同。
5.3 DMA 和中断同时开,数据重复或丢失
现象:USART1 开了 DMA 接收,同时又开了 RXNE 中断,结果缓冲区里数据重复或者缺字节。原因:DMA 和中断都在抢 DR 寄存器,谁先读走谁算数,另一个读到的是无效值。解决:同一路串口接收只选一种方式,要么 DMA 要么中断,不要同时开。如果确实需要 DMA 收大数据、中断处理异常,那中断里只处理错误标志,不读数据寄存器。
5.4 UART5 的 RX 引脚没反应
现象:USART1 到 UART4 都正常,唯独 UART5 收不到数据。原因:UART5 的 RX 固定在 PD2,很多人按习惯去配 PC12 或者别的引脚,配了个寂寞。解决:翻原理图确认 PD2 是否引出、是否被其他外设占用;初始化时 GPIO 时钟要使能 GPIOD 而不是 GPIOC,AF 映射用GPIO_AF8_UART5。这个引脚没有重映射选项,硬件上没留出来就只能换串口。
5.5 高波特率下偶发丢包
现象:115200 时正常,提到 921600 就开始偶发丢字节,主循环越忙丢得越多。原因:中断逐字节处理在高波特率下开销太大,中断响应不及时导致 ORE 溢出。解决:改用 DMA 接收加空闲中断,或者把该路串口中断优先级提高、精简中断里的操作。另外检查主循环里有没有长时间关中断的临界区,那段时间来的数据全靠硬件 FIFO 顶着,F4 的串口 FIFO 只有一级,顶不住就丢。
6. 二次开发:把五路串口改造成自己的协议网关
拿到这份代码后,最直接的改造方向是把它变成一个多路协议转发网关。五路串口各自跑不同的协议——比如 USART1 接上位机走自定义帧、USART2 挂 Modbus 传感器、USART3 对接无线模块——核心工作是在protocol.c里给每路配一个解析状态机,主循环轮询五个环形缓冲区,解析出完整帧后按路由表转发。
改造时我建议先做一件事:给每路串口定义一个描述结构体,把缓冲区、波特率、协议类型、回调函数打包在一起,这样新增或删减串口时只改配置表,不动主逻辑:
/* 串口通道描述,配置表驱动 */ typedef struct { USART_TypeDef *instance; // 外设基址,如 USART1 RingBuffer_t *rb; // 接收缓冲 uint32_t baudrate; uint8_t protocol_id; // 协议类型:0 自定义,1 Modbus void (*on_frame)(uint8_t *data, uint16_t len); // 帧回调 } UartChannel_t; /* 五路通道配置表,新增串口只加一行 */ UartChannel_t uart_channels[] = { { USART1, &rb_usart1, 115200, 0, gateway_on_frame }, { USART2, &rb_usart2, 9600, 1, modbus_on_frame }, { USART3, &rb_usart3, 115200, 0, wireless_on_frame }, { UART4, &rb_uart4, 57600, 0, sensor_on_frame }, { UART5, &rb_uart5, 9600, 0, backup_on_frame }, };主循环里遍历这张表,对每个通道调RingBuffer_Get取字节喂给对应状态机,凑够一帧就触发on_frame回调。这种写法把“哪路串口”和“怎么处理”解耦了,后面加第六路串口(比如用软件模拟或者换更大封装的芯片)时,配置表加一行、初始化函数补一个就行。
验证改造是否成功,我一般分三步走:先用串口助手单独测每一路的自发自收,确认物理链路和波特率没问题;再两两对接做透传,看数据完整性;最后五路同时跑,用逻辑分析仪抓其中两路的波形,看有没有互相干扰或者时序错乱。第三步最容易被跳过,但恰恰是多串口项目里问题最多的地方——单路测试全过、五路一起跑就丢包的情况太常见了。
有个习惯我保持了挺久:每次改完串口初始化代码,不管改动多小,都先用示波器量一下 TX 引脚的空闲电平和第一个字节的波形,确认波特率误差在允许范围内再往下调协议。早期有次偷懒跳过这步,结果因为 APB1 分频改了一位,五路串口波特率全偏了 3%,低波特率时勉强能通,高波特率时误码率飙升,查了大半天才定位到时钟配置。从那以后我每次动时钟或者串口初始化,都强制走一遍波形确认,省下来的调试时间远比量波形那几分钟多。希望这份代码和这些踩坑记录能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取