简介:这份资源面向STM32F429嵌入式开发初学者与需要快速搭建串口通信实验的工程师,提供基于标准库的完整串口通信工程示例,帮助解决GPIO复用配置、USART初始化、阻塞与非阻塞收发、中断与DMA传输以及错误回调处理等常见问题。压缩包共295个文件,约17.11MB,包含45个c源文件与46个h头文件,覆盖stm32f4xx_tim、rcc、rtc、adc、flash、fmc、can等外设驱动,另有62个o目标文件、61个crf交叉引用文件、1个uvprojx工程文件、1个axf可执行文件及map、lst等编译产物,并附带keilkilll.bat清理脚本,便于直接编译与调试。目前已有1029人学习下载。资源中给出了PA9、PA10作为USART1收发引脚的配置思路,以及HAL_UART_Transmit、HAL_UART_Receive、HAL_UART_Transmit_IT、HAL_UART_Receive_IT等函数的调用方式,还涉及HAL_UART_ErrorCallback错误回调与HAL_UART_AbortTransmit、HAL_UART_AbortReceive取消传输的用法,并提示可用DMA减轻CPU负担,适合对照工程结构理解串口通信的完整实现流程。
1. 串口通信在 STM32F429 标准库下的真实开发体验
很多人第一次拿到 STM32F429 这块板子,点灯跑通之后紧接着就想把串口调通,结果一上来就卡在乱码、收不到数据、中断进不去这些地方。这个项目就是一份基于标准库的串口通信例程,覆盖了轮询收发、中断收发、DMA 搬运这几条最常用的路径,适合刚接触 F429 标准库的开发者,也适合想从 HAL 库切回标准库做底层控制的老手。它不依赖任何 RTOS,纯裸机跑,工程结构清晰,拿来当串口调试的起点或者往项目里移植都合适。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆一遍。
2. 工程结构与串口外设初始化:从时钟到引脚的全链路
2.1 目录组织与标准库依赖关系
标准库工程的目录结构一般长这样,这个例程也遵循同样的组织方式:
Project/ ├── CMSIS/ │ ├── core_cm4.h │ └── stm32f4xx.h ├── FWlib/ │ ├── inc/ │ │ ├── stm32f4xx_usart.h │ │ ├── stm32f4xx_gpio.h │ │ ├── stm32f4xx_rcc.h │ │ └── stm32f4xx_dma.h │ └── src/ │ ├── stm32f4xx_usart.c │ ├── stm32f4xx_gpio.c │ ├── stm32f4xx_rcc.c │ └── stm32f4xx_dma.c ├── User/ │ ├── main.c │ ├── usart1.c │ ├── usart1.h │ └── stm32f4xx_it.c └── Output/FWlib放的是标准外设库源码,User放业务代码,CMSIS是内核头文件。这个分层的好处是移植时只动User目录,底层库不用碰。常见做法是把串口相关的初始化、收发函数单独抽成usart1.c/h,main.c只负责调用,这样后面加串口 2、串口 3 的时候直接复制一份改引脚和时钟就行。
2.2 时钟使能与 GPIO 复用配置
F429 的串口 1 挂在 APB2 上,GPIO 挂在 AHB1 上,时钟没开全就去配寄存器,现象就是引脚毫无反应,这种坑踩一次就记住了。
// usart1.c #include "usart1.h" void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; // 使能 USART1 和 GPIOA 时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // PA9 -> TX, PA10 -> RX, 复用为 USART1 GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; // 复用模式 GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; // 推挽输出 GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; // 上拉 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 串口参数:波特率、8 位数据、1 位停止、无校验、无流控 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); }这里有几个参数值得单独说。GPIO_PuPd_UP是给 RX 引脚一个确定的空闲电平,如果配成GPIO_PuPd_NOPULL而外部又没有上拉,空闲时引脚浮空,串口会收到一堆随机噪声。GPIO_Speed_50MHz对串口来说其实 2MHz 就够,但标准库例程习惯统一写 50MHz,不影响功能。USART_Mode同时开收发,如果只做发送可以只留USART_Mode_Tx,省一点功耗。
2.3 波特率计算与误差控制
标准库的USART_Init内部会根据PCLK和USART_BaudRate自动算USART_BRR寄存器,但前提是RCC_GetClocksFreq能拿到正确的时钟频率。F429 默认PCLK2是 90MHz,如果系统时钟改过而没同步更新,波特率就会偏。常见做法是在main里先调RCC_GetClocksFreq确认一下,或者直接用示波器量一位的时间宽度反推。
| 参数 | 典型值 | 说明 |
|---|---|---|
| PCLK2 | 90 MHz | APB2 总线时钟 |
| 目标波特率 | 115200 | 常用调试速率 |
| 实际分频 | 781.25 | 90M / 115200 |
| BRR 写入值 | 0x30D | 整数部分 781,小数部分 0.25 |
| 误差 | 0% | 整除时无误差 |
如果换成 921600 这种高速率,误差会放大,这时候要么换晶振,要么把PCLK2调成能被整除的值。我一般会在初始化后加一句回环测试,发一个字节看能不能收到,确认波特率没跑偏。
3. 三种收发模式实战:轮询、中断、DMA 怎么选
3.1 轮询收发:最简单也最容易阻塞
轮询模式适合调试阶段快速验证,代码短,逻辑直白。
// 发送一个字节,等待 TXE 置位 void USART1_SendByte(uint8_t data) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, data); } // 发送字符串 void USART1_SendString(char *str) { while (*str != '\0') { USART1_SendByte(*str++); } // 等最后一个字节真正发完 while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); } // 轮询接收一个字节,带超时 int USART1_RecvByte(uint8_t *data, uint32_t timeout) { while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET) { if (timeout-- == 0) return -1; // 超时 } *data = USART_ReceiveData(USART1); return 0; }TXE是发送数据寄存器空标志,置位说明可以往DR写下一个字节;TC是发送完成标志,整串发完后要等它,否则主函数跑飞了最后一个字节可能还没出移位寄存器。接收这边加了超时计数,避免死等。轮询的缺点是收发期间 CPU 完全被占住,115200 下发一个 100 字节的包大概要 8.7ms,这期间什么都干不了。
3.2 中断收发:把 CPU 解放出来
中断模式的核心是把收发动作拆成“中断里搬一个字节,主循环处理缓冲区”。
// usart1.c 中定义接收缓冲区 #define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0; // 中断里写 volatile uint16_t rx_tail = 0; // 主循环读 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { // 缓冲区没满 rx_buf[rx_head] = data; rx_head = next; } // 满了就丢弃,避免覆盖未处理数据 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } // 主循环里取数据 int USART1_ReadByte(uint8_t *data) { if (rx_head == rx_tail) return -1; // 无数据 *data = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; return 0; }这里用了一个环形缓冲区,rx_head在中断里推进,rx_tail在主循环里推进,两者相等表示空,next == rx_tail表示满。这种无锁设计在单生产者单消费者场景下是安全的,不需要关中断。初始化时别忘了开USART_IT_RXNE并在 NVIC 里使能USART1_IRQn:
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);优先级别设太高,串口中断频率在 115200 下大概每 87us 一次,如果抢占优先级比系统滴答还高,会影响其他时序。
3.3 DMA 收发:大批量数据的正确姿势
数据量上到几百字节以上,中断里一个字节一个字节搬也会累,这时候上 DMA。F429 的 USART1_TX 对应 DMA2 Stream7 Channel4,RX 对应 DMA2 Stream5 Channel4。
void USART1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); // TX 方向 DMA_InitStructure.DMA_Channel = DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)tx_dma_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral; DMA_InitStructure.DMA_BufferSize = TX_DMA_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_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream7, &DMA_InitStructure); // 使能串口 DMA 发送请求 USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); } // 启动一次 DMA 发送 void USART1_DMASend(uint8_t *buf, uint16_t len) { while (DMA_GetCmdStatus(DMA2_Stream7) != DISABLE); // 等上次发完 DMA_Cmd(DMA2_Stream7, DISABLE); DMA_SetCurrDataCounter(DMA2_Stream7, len); DMA_MemoryTargetConfig(DMA2_Stream7, (uint32_t)buf, DMA_Memory_0); DMA_Cmd(DMA2_Stream7, ENABLE); }DMA_Mode_Normal表示发完一轮就停,适合不定长数据;如果是固定长度的连续采集,可以改成DMA_Mode_Circular。DMA_FIFOMode_Disable在字节传输下够用,开 FIFO 主要用于外设和内存位宽不一致的场景。发送前必须等DMA_GetCmdStatus返回DISABLE,否则直接改计数器会导致上一包数据被截断。
4. 避坑与常见问题排查
4.1 现象:串口助手收到全是乱码
原因通常是波特率不匹配或者时钟源不对。F429 的外部晶振一般是 8MHz,如果SystemInit里 HSE 起振失败自动切到 HSI,主频会变成 16MHz,PCLK2跟着变,但代码里波特率还是按 90MHz 算的,实际输出速率就偏了。解决方法是先在main里读RCC_GetClocksFreq打印PCLK2,确认是 90MHz 再往下走。另一个可能是串口助手那边数据位、停止位、校验位没对齐,逐项核对一遍。
4.2 现象:中断进不去,主循环里收不到数据
先查 NVIC 有没有使能,USART_ITConfig和NVIC_Init两处都要开。再查stm32f4xx_it.c里USART1_IRQHandler的函数名有没有拼错,标准库靠名字匹配向量表,拼错就静默跳到默认死循环。还有一种情况是中断里忘了清RXNE标志,第一次进去之后一直挂在那里,主循环再也拿不到新数据。
4.3 现象:DMA 发送只发出第一包,后面没反应
DMA_Mode_Normal下传输完成后EN位自动清零,但DMA_GetCmdStatus读的是EN位,如果没重新DMA_Cmd(ENABLE)就不会再触发。另外DMA_SetCurrDataCounter必须在流关闭状态下写,运行中写会被忽略。我一般会在发送函数开头加一句DMA_Cmd(DMA2_Stream7, DISABLE)强制复位,再重新配置。
4.4 现象:接收数据偶尔丢字节
环形缓冲区满了之后新数据被丢弃,这是设计上的取舍。如果丢得频繁,要么把RX_BUF_SIZE加大,要么在主循环里提高取数频率。还有一种隐蔽情况是中断优先级太低,被其他高优先级中断打断太久,RXNE标志被新数据覆盖。把串口中断优先级提到比大部分外设高,但低于系统滴答,能缓解这个问题。
4.5 现象:上电后第一次发送正常,第二次就卡死
多半是TC标志没清。标准库的USART_SendData只写DR,不清标志,如果上一次发送后没等TC置位就进下一次,TXE可能一直是RESET,while就死循环了。解决方法是每次发送前先USART_ClearFlag(USART1, USART_FLAG_TC),或者干脆用 DMA 绕开这个坑。
5. 二次开发改造:从例程到项目级串口框架
把例程往实际项目里搬的时候,第一件事是把串口收发抽象成统一接口,别让main.c里到处散落USART_SendData。我一般会定义一个uart_dev结构体,把初始化、发送、接收回调都挂上去:
typedef struct { USART_TypeDef *instance; uint8_t *rx_buf; uint16_t rx_size; volatile uint16_t rx_head; volatile uint16_t rx_tail; void (*on_recv)(uint8_t *data, uint16_t len); } uart_dev_t; // 注册接收回调,主循环里轮询调用 void uart_poll(uart_dev_t *dev) { uint16_t tail = dev->rx_tail; uint16_t head = dev->rx_head; if (tail == head) return; uint16_t len = 0; static uint8_t tmp[256]; while (tail != head && len < sizeof(tmp)) { tmp[len++] = dev->rx_buf[tail]; tail = (tail + 1) % dev->rx_size; } dev->rx_tail = tail; if (dev->on_recv) dev->on_recv(tmp, len); }这样加串口 2 的时候只需要填一个uart_dev_t实例,中断服务函数里调uart_isr(&uart2_dev),业务层完全不用改。协议解析也建议单独抽一层,比如收到一帧数据后先找帧头帧尾,再校验,最后才交给业务。我见过太多项目把解析逻辑塞在中断里,结果一个memcpy耗时太长导致下一帧丢失。
改造时还有几个验证点值得走一遍:把波特率从 115200 切到 921600 看丢包率,连续发 10KB 数据看 DMA 和环形缓冲配合是否稳定,拔掉串口线再插上看错误标志有没有正确处理。这些场景在实验室里不一定复现,但现场跑起来就是玄学问题的来源。从那以后我每次移植串口代码,都强制先跑一遍回环测试和压力测试,确认底层没问题再往上叠业务。希望帮到你。
本文还有配套的精品资源,点击获取