STM32F405串口调不通?这份例程从轮询到DMA+空闲中断讲透底层

发布时间:2026/10/16 14:07:20
STM32F405串口调不通?这份例程从轮询到DMA+空闲中断讲透底层
简介面向单片机开发者的STM32F405RGT6串口通信例程基于HAL库实现重点演示USART1的初始化、数据收发、中断回显与DMA传输。工程适用于工业控制、物联网终端等场景适合需要快速上手STM32F4串口调试的初学者及进阶开发者。资源为完整Keil工程共460个文件以c/h源文件为主体附带编译生成的o、d、crf、hex、axf等文件以及工程配置与启动脚本压缩包约20.24MB解压后可直接打开编译运行。已有3748人学习内容覆盖GPIO复用、波特率配置、HAL_UART_Transmit/Receive、中断服务程序、DMA收发等关键模块还包含串口终端测试方法与错误处理分析。通过该例程读者可以理解串口底层原理掌握HAL库开发流程为后续复杂嵌入式项目打下基础。1. 串口调不通先别急着换线这份 F405 例程把底层的坑都替你踩过了做单片机开发的人几乎都有一段被串口折磨的经历。刚拿到 STM32F405RGT6 这块板子想先跑个串口打印调试信息结果连最简单的printf都出不来——要么乱码要么压根没输出要么收一发数据能丢半个包。很多人第一反应是检查 USB 转串口模块、换杜邦线、调波特率折腾半天发现问题根本不在物理链路而在初始化的细节里。这份 STM32F405RGT6 串口例程不是那种只点个灯、发个Hello World的玩具代码。它覆盖了从 GPIO 复用配置、时钟树分频、轮询收发、中断收发到 DMA 空闲中断接收不定长帧的完整链路。适合正在做串口调试的嵌入式开发者和刚入门 STM32F4 的新手直接照着抄能少走几天的弯路。2. 先把外设摸清楚F405RGT6 的串口资源与时钟配置2.1 六个串口挂在不同总线上时钟频率不一样STM32F405RGT6 一共有六个串口外设USART1、USART2、USART3、UART4、UART5、USART6。很多新手栽跟头的第一处就在这里——以为所有串口都在同一条总线上配置时钟时随手开一个就完事。实际上USART1 和 USART6 挂在 APB2 总线上最高时钟是 84MHz而 USART2、USART3、UART4、UART5 挂在 APB1 总线上最高时钟是 42MHz。这两个总线的基础时钟不同直接决定了你后续计算波特率寄存器 BRR 的值时fck参数填多少。从实际工程角度看我一般建议优先用 USART1 或 USART6 做调试串口原因有两个一是 APB2 频率高波特率在高位时误差更小二是这两个串口的中断在 NVIC 里的优先级配置比较灵活不容易和系统滴答定时器抢资源。2.2 波特率不是随便填的BRR 寄存器是有公式的F4 系列串口波特率计算比 F1 系列直观一些公式是这样的BaudRate fck / (16 * USARTDIV)USARTDIV 就是写入 BRR 寄存器的值。反过来你要算 BRR就是BRR fck / (16 * 目标波特率)。举个例子系统时钟 168MHzAPB1 总线时钟 42MHzAPB2 总线时钟 84MHz。如果用 USART1 跑 115200 波特率BRR 84000000 / (16 * 115200) 45.57取整后写入 45 或 46实际波特率会有极小偏差但远在串口容忍范围内。如果用 USART2 跑同样的 115200fck 是 42MHz算出来 BRR 就只有 22 出头量化误差占比更大。这不是玄学是数学。在 115200 这种常用波特率下两条总线都够用但如果跑到 921600 甚至更高USART1 的优势就出来了。2.3 GPIO 复用不是随便映射的查表比试错快F405 的串口引脚不是说你随便挑两个 GPIO 就能用。每个 USART 的 TX、RX 都有固定的复用功能映射表。比如 USART1 的 TX 可以是 PA9 或 PB6RX 是 PA10 或 PB7对应的复用功能编号是 AF7。写代码时的配置逻辑是// 开启 GPIOA 时钟和 USART1 时钟 RCC-AHB1ENR | RCC_AHB1ENR_GPIOAEN; RCC-APB2ENR | RCC_APB2ENR_USART1EN; // PA9 配置为 TXPA10 配置为 RX GPIOA-MODER ~(GPIO_MODER_MODER9 | GPIO_MODER_MODER10); GPIOA-MODER | (GPIO_MODER_MODER9_1 | GPIO_MODER_MODER10_1); // 复用模式 GPIOA-AFR[1] | (0x7 GPIO_AFRH_AFSEL9_Pos); // PA9 - AF7 GPIOA-AFR[1] | (0x7 GPIO_AFRH_AFSEL10_Pos); // PA10 - AF7 // 配置 USART1 参数 USART1-BRR 84000000 / (16 * 115200); USART1-CR1 | USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;代码逻辑说明第一步开时钟AHB1ENR 管 GPIOAPB2ENR 管 USART1两个都得开。第二步把 PA9、PA10 的 MODER 设置为复用模式然后把复用功能号 AF7 写入 AFR 寄存器的高位AFR[1] 对应 PA8~PA15。第三步配置 BRR 并使能发送、接收和串口总开关。这里最容易漏的是USART_CR1_UE这一位。不少新手配置完 TE 和 RE 发现串口没反应就是忘了使能整个外设。还有一个细节如果 RX 引脚对应的 GPIO 没有配置为复用模式接收会一直读到高电平表现为发什么都没反应。3. 从轮询到中断让串口从能发到好用3.1 轮询模式为什么只适合调试不适合生产轮询发送的代码很简单写一个字节就等发送完成标志位void uart_send_byte(USART_TypeDef *USARTx, uint8_t data) { while (!(USARTx-SR USART_SR_TXE)); // 等待发送数据寄存器空 USARTx-DR data; }这段代码放在裸机调试里没什么问题发送一个字节CPU 原地等着数据就出去了。但你得意识到CPU 在while循环里实际上被锁住了如果主循环里还有别的事要做比如读传感器、跑控制算法一次发 50 个字节就要卡掉一小段时间。更麻烦的是接收——轮询接收意味着 CPU 必须不停地去查 RXNE 标志位但凡你晚了一步数据就丢了。所以轮询模式我一般只在初始化自检或者临时打印时用正经的收发逻辑全部走中断。3.2 中断接收的三件事开中断、写服务函数、处理标志位中断收发的核心套路是三步在串口初始化里使能接收中断RXNEIE在 NVIC 里使能串口中断通道并设置优先级在中断服务函数里读 DR 寄存器、清标志位、把数据存起来很多人会在第三步翻车因为 STM32F4 的接收中断标志位清除方式比较隐蔽——读 SR 再读 DR 就能清除 RXNE 标志。如果你先读 SR 判断标志然后不读 DR 直接清标志反而可能引入问题。最简单可靠的做法就是让中断服务函数老老实实地把 DR 读出来。3.3 一个可以直接照搬的中断收发模板#define RX_BUF_SIZE 128 volatile uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_index 0; void USART1_IRQHandler(void) { if (USART1-SR USART_SR_RXNE) { uint8_t data (uint8_t)(USART1-DR 0xFF); if (rx_index RX_BUF_SIZE) { rx_buf[rx_index] data; } } }逻辑说明中断服务函数只做一件事——读数据、存数据。rx_index用volatile修饰是因为主循环和中断共享它不加的话编译器可能把它优化进寄存器导致主循环一直读到旧的索引值。参数说明RX_BUF_SIZE是接收缓冲区大小这个值要按你的实际帧长设定我一般留两倍余量。中断里只做存数据帧的处理逻辑放在主循环里轮询rx_index这样不会阻塞中断。还有一个关键点这个模板没有处理接收溢出如果数据超过了缓冲区大小多出来的会被丢弃这在下一章讲帧接收时会处理。4. 收发一帧完整数据空闲中断与 DMA 是串口进阶的分水岭4.1 一个字节一个字节地收和收一整帧是两个世界中断接收如果只是把每个字节存起来那实际用起来还是很累。你面对的真实问题是对方发过来的是一个命令帧比如AA 05 01 02 03 04 05你要做的是把这一帧完整地识别出来而不是收到 7 个孤立的字节。怎么确认这一帧收完了常见做法是按帧头帧尾判断比如以0xAA开头、以0x55结尾但这种方式要求协议里约定好帧尾而且如果通信双方约定了不定长帧处理起来会很难受。更优雅的方案是使用串口的空闲中断IDLE——当串口总线上出现一个字节时间的空闲时硬件会产生 IDLE 中断。这意味着你只需要在中断里记下此刻收到了一批数据然后去把缓冲区里的数据取走不需要关心帧头帧尾。4.2 空闲中断的配置与两个容易混淆的标志空闲中断的配置比普通接收中断多一步需要操作 USART_CR1 的 IDLEIE 位。它的中断标志 IDLE 在 SR 寄存器的第 4 位。清除 IDLE 标志的方式和 RXNE 不一样要先把 SR 寄存器的值读出来然后再读一次 DR 寄存器。很多人在这一步踩坑用调试器看 SR 的 IDLE 位一直是 1然后中断一直在触发就是因为他只读了 SR 没读 DR。一个完整的空闲中断接收可以用下面这段逻辑if (USART1-SR USART_SR_IDLE) { // 读取 SR 和 DR 以清除 IDLE 标志 volatile uint32_t tmp_sr USART1-SR; volatile uint32_t tmp_dr USART1-DR; // 此时 rx_index 记录了本次收到的字节数交由主循环处理 g_rx_frame_len rx_index; rx_index 0; }逻辑说明在这个过程中rx_index的当前值就是这一帧的字节数主循环把rx_buf从头取g_rx_frame_len个字节就是一整帧数据。之后把rx_index清零用于接收下一帧。两个volatile临时变量是为了防止编译器把读操作优化掉。4.3 DMA 加空闲中断把 CPU 从字节搬运中解放出来如果串口数据速率高或者接下来还要做其他实时性要求高的处理让 CPU 在中断里一个个搬字节就不是最优解了。这时候把 DMA 拉进来让 DMA 把串口接收寄存器里的数据直接搬到内存CPU 只在空闲中断里处理一帧数据是 F4 平台上很成熟的方案。以下是用标准外设库风格的 DMA 配置思路DMA_InitTypeDef DMA_InitStructure; RCC-AHB1ENR | RCC_AHB1ENR_DMA2EN; // USART1_RX 使用 DMA2 DMA_DeInit(DMA2_Stream2); DMA_InitStructure.DMA_Channel DMA_Channel_4; // USART1_RX 对应通道 4 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)rx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize RX_BUF_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_InitStructure.DMA_FIFOMode DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream2, DMA_InitStructure); DMA_Cmd(DMA2_Stream2, ENABLE);参数说明里有三个关键点DMA_BufferSize设置的缓冲区大小决定了 DMA 搬多少字节后触发传输完成中断或循环回绕这个值不要设成全缓冲区大小否则你还得在传输完成中断里处理半满情况DMA_Mode_Circular让 DMA 在缓冲区满了之后自动回到起点继续搬中间不需要 CPU 干预DMA 的外设地址固定指向USART1-DR内存地址自动递增所以一次配置之后数据就一直往rx_buf里堆。然后配合空闲中断来定帧边界空闲中断触发时读一下 DMA 的当前计数寄存器DMA_GetCurrDataCounter用RX_BUF_SIZE减去当前计数就是这一帧实际收到的字节数。这个组合是目前裸机串口接收里我觉得最省心的方案发送部分如果也需要高速 DMA一般配置为 Normal 模式每次发完一个 buffer 后重新使能 DMA。5. 串口避坑手册乱码、丢字节、中断卡死都在这5.1 乱码不是玄学先查波特率误差现象板子发出的数据和预期完全对不上甚至同一段数据每次打印结果都不一样。原因波特率寄存器计算错误导致实际波特率和目标值偏差超过 2%。我用过一个例子某开发者把 APB1 的 42MHz 当成 84MHz 去算 USART2 的 BRR结果 115200 的配置实际跑了 230400终端收到的自然是天书。解决回到公式BRR fck / (16 * baud)重新算。如果你用的是 CubeMX 生成的代码去时钟树配置里看 APB1、APB2 的实际频率确认串口挂在哪条总线上。还有一个细节如果外部晶振不是标准的 8MHz比如 25MHz 或 12MHz整个总线频率都会变化这是 CubeMX 自动生成代码时最容易忽略的地方。5.2 中断服务里做了太多事丢字节是必然的现象低波特率9600 或 115200下中断收发正常一旦升到 460800 或更高接收的数据开始间歇性丢失。原因中断服务函数里处理逻辑太多比如在中断里解析帧、搬运数据、操作外设导致 CPU 退出中断时下一个字节已经到了但 RXNE 标志还没被及时处理硬件上的接收移位寄存器被覆盖。解决中断函数只做一件事——把 DR 寄存器读出来放进缓冲区。帧解析、协议处理全部放到主循环。如果数据速率实在太高直接切换 DMA 方案让 DMA 在后台搬运CPU 只在空闲中断时取数据。5.3 接收缓冲区溢出了程序却看起来正常现象程序跑一段时间后串口似乎停止了接收新数据但发送、中断这些功能都正常。原因接收缓冲区满了之后后续数据无处存放代码里可能只是丢掉了新数据而没有做任何提示。更隐蔽的是如果写缓冲区时用了类似rx_index这样的索引而不检查边界可能会有越界写风险。解决在写入缓冲区前做边界判断满了之后要么清空缓冲区重新开始要么通知主循环尽快取数据。另外DMA 循环模式不存在这个问题但要注意 DMA 计数器的回绕逻辑。5.4 开了中断但进不去检查 NVIC 优先级分组现象串口初始化代码和数据收发代码都写好了RXNE 中断也开了但断点打不进中断服务函数。原因NVIC 优先级分组配置错了。如果多个外设中断共享同一个抢占优先级且串口中断优先级设置得比别的中断低就可能被其他高频中断“饿死”。我碰到过一次一个定时器中断以极高频率触发把串口中断挤得根本没机会执行。解决配置 NVIC 分组时统一用NVIC_PriorityGroup_44 位抢占优先级0 位子优先级给串口中断分配一个合理的抢占优先级比如 3 或 4定时器中断分配到更高优先级同时保证串口中断服务函数足够短。5.5 GPIO 复用冲突是排查盲区现象换了一个引脚做串口配置和代码逻辑都对了但信号就是出不来。原因目标引脚可能已经被另一个外设占用比如 ADC、定时器通道或者调试接口的 SWD 引脚。F405 的 PA13、PA14 固定作为 SWDIO 和 SWCLK如果你把串口映射到了这两个脚下载完程序就再也连不上调试器。解决选引脚之前先查数据手册的复用功能表避开 SDIO、SWD、HSE 等固定功能的引脚。另外有些低功耗模式下会把 USART 关闭如果你在调试低功耗唤醒先确认这个因素。6. 把串口调成生产力printf 重定向与环形缓冲区串口例程跑到这里收发都已经跑通了但真正让开发效率提升一个量级的是把串口变成调试工具。两个技巧我觉得优先级最高一个是printf重定向一个是环形缓冲区。printf重定向到串口在 GCC 环境下最简单的方式是用_write系统调用接口int _write(int fd, char *ptr, int len) { for (int i 0; i len; i) { while (!(USART1-SR USART_SR_TXE)); USART1-DR ptr[i]; } return len; }这样做好了之后你的代码里就可以直接用printf(adc value: %d\r\n, adc_val)来打印调试信息。注意两点一是\r\n必须写全很多终端只认\r\n不认单独的\n否则日志会挤在一行里二是不要在中断服务函数里调用printf否则一旦发送卡住整个中断就堵死了。一个轻量环形缓冲区我自己的习惯是用一个环形缓冲区串接中断接收和主循环处理typedef struct { uint8_t buf[256]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; void rb_write(ring_buffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % sizeof(rb-buf); if (next ! rb-tail) { rb-buf[rb-head] data; rb-head next; } } int rb_read(ring_buffer_t *rb, uint8_t *data) { if (rb-head rb-tail) return 0; *data rb-buf[rb-tail]; rb-tail (rb-tail 1) % sizeof(rb-buf); return 1; }这个实现的妙处在于中断服务函数只调rb_write主循环只调rb_read读写互不冲突CPU 也不需要关中断来保护临界区。head和tail用volatile修饰防止编译优化导致读到旧值。缓冲区大小 256可以根据你的帧长调整建议保持 2 的幂次这样取模运算可以优化成位运算。从那以后我每次初始化串口都会强制走一遍流程先确认串口挂在哪条总线上算波特率再查引脚复用号最后才写代码。这套例程里省掉的那些排查时间换成任何一个晚上熬夜调串口的人都知道值多少钱。希望帮到你。本文还有配套的精品资源点击获取