news 2026/10/16 3:37:27

STM32F429标准库串口通信实战:轮询、中断与DMA收发详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F429标准库串口通信实战:轮询、中断与DMA收发详解

简介:这份资源面向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确认一下,或者直接用示波器量一位的时间宽度反推。

参数典型值说明
PCLK290 MHzAPB2 总线时钟
目标波特率115200常用调试速率
实际分频781.2590M / 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 和环形缓冲配合是否稳定,拔掉串口线再插上看错误标志有没有正确处理。这些场景在实验室里不一定复现,但现场跑起来就是玄学问题的来源。从那以后我每次移植串口代码,都强制先跑一遍回环测试和压力测试,确认底层没问题再往上叠业务。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 3:37:05

AI辅助设计工具Ardot实操:从概念探索到高效出图的全流程指南

前阵子一个插画项目赶得特别紧&#xff0c;客户下午四点发来需求&#xff0c;晚上就要看到三个方向的概念稿。我抱着侥幸心理按老流程走——先翻了一小时素材站&#xff0c;再在画布上拼参考图&#xff0c;折腾到晚上八点连第一个方向都没定下来。最后临时把手头正在测试的Ardo…

作者头像 李华
网站建设 2026/10/16 3:36:45

Matlab手写RNN、LSTM与CNN实现:从跑通到二次开发

简介&#xff1a;这份资源聚焦RNN-LSTM卷积神经网络在Matlab环境下的实现&#xff0c;面向具备一定深度学习基础、希望用Matlab完成序列建模的开发者与研究人员&#xff0c;可用于时间序列预测、语音识别、自然语言处理等场景的入门与实验。压缩包共3个文件&#xff0c;均为m脚…

作者头像 李华
网站建设 2026/10/16 3:36:15

SpringBoot2+Vue3政务管理系统开发实战与避坑指南

做政务管理系统这几年&#xff0c;我带过好几个类似的项目&#xff0c;从最早SSH那套老古董&#xff0c;到后来SpringBootVue前后端分离&#xff0c;踩过的坑可以写一本书。最近正好把一个完整的“Java Web政务管理系统”源码整理出来&#xff0c;技术栈是SpringBoot2 Vue3 M…

作者头像 李华
网站建设 2026/10/16 3:36:15

两数之和与哈希表:从暴力O(n²)到O(n)的思维转换

我第一次认认真真刷题&#xff0c;翻开的第一道就是“两数之和”。那时候觉得简单&#xff0c;两层 for 循环套上去&#xff0c;半小时写出代码&#xff0c;还挺得意。后来去面试&#xff0c;面试官把这道题原封不动出给我&#xff0c;我条件反射写了暴力解&#xff0c;他追问了…

作者头像 李华
网站建设 2026/10/16 3:34:53

护网行动蓝队实战指南:从告警研判到应急响应的完整流程

护网行动&#xff0c;一个让安全圈里人又爱又恨的词。对于第一次被选进蓝队值守群的新人来说&#xff0c;最常见的第一感受往往不是紧张&#xff0c;而是懵&#xff1a;我到底要干什么&#xff1f;满屏的告警是每条都点开看&#xff0c;还是先挑高亮的颜色&#xff1f;遇到疑似…

作者头像 李华
网站建设 2026/10/16 3:34:05

UDP数据报套接字编程实战:C语言实现与可靠性补全

做网络编程绕不开UDP数据报套接字编程。我在给某跨平台系统做实时通信模块时&#xff0c;试过直接用TCP&#xff0c;结果在高并发低延迟场景下吃了不少苦头&#xff0c;后来换成UDP数据报套接字&#xff0c;整个方案才算顺畅。这篇内容就是我实际用C语言从零写UDP收发程序的经验…

作者头像 李华