news 2026/10/14 16:56:27

STM32 USART串口配置详解:从寄存器到中断的嵌入式实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 USART串口配置详解:从寄存器到中断的嵌入式实践

1. 为什么第四章先讲串口,而不是直接上定时器

1.1 串口在嵌入式开发里的角色

翻过前三章的朋友应该都有体会,从点亮一颗LED到摆弄外部中断,STM32的学习曲线其实并不陡,真正让人开始觉得"有点东西"的,往往是第四章这个位置。我见过太多人在这个阶段卡住:明明GPIO和时钟都配置对了,程序也烧进去了,可串口就是不出数据,或者出来一堆乱码。于是整本手册在这章搁浅,转头去查各种教程的评论区。

串口(USART)在嵌入式开发里就是一个"基础设施"。它不像是PWM那样天生为了某种炫酷效果,也不像I2C那样牵涉复杂的从机协议,它干的事情说白了就是两件:把数据发出去,把数据收进来。开发阶段的调试信息靠它打印,量产之后和上位机通讯靠它对接,哪怕是做传感器采集,也得先把串口搞通了才能看到数据长什么样。所以这章如果不到位,后面每章都像是蒙着眼睛在写代码。

以我实际带过的项目为例,模拟项目X的主控芯片选的就是一颗STM32F103系列,整个系统的调试口、日志口、配置下发口全部走USART1,硬件上只飞了三根线——TX、RX、GND。就是这么简单的三根线,撑起了整块板子和电脑之间所有的交互。你可以不理解中断优先级配置的详细内幕,但你不能不清楚串口为什么发不出一个字节。

1.2 一本白话手册该有的取舍

写这一章之前我在几个技术社区里逛了一圈,发现大家对寄存器版教程的呼声很高,但抱怨也很集中:官方参考手册写得太"官方"了,每个寄存器给你列几十个位的含义,看得人头皮发麻。而大部分网上的分享又走另一个极端——直接丢代码,让人复制粘贴跑通就算完事,至于那堆寄存器为什么这样配,没人说得清。

所以这本手册的第四章,我给自己定的原则是:寄存器一个不少,但每个寄存器只讲这一章用得上的位,其余的全靠后文按需补充。USART相关的寄存器有十来个,真正初期开发必须掌握的其实只有SR、DR、BRR、CR1、CR2,外加后面中断要用的CR3和NVIC相关配置。把这几个讲透,比把官方手册抄一遍有价值得多。

这章还有一个隐藏目标:让读者彻底告别"调库侠"的路径依赖。STM32的HAL库用起来确实省心,一个HAL_UART_Transmit就能发数据,但出了问题你根本不知道它在底层干了什么。寄存器操作虽然啰嗦,但它逼着你去理解芯片的真实工作过程。理解了这个过程,你以后看任何一款MCU的串口模块,半小时之内都能上手。

2. 用生活化的方式看懂USART的寄存器地图

2.1 收发数据的两扇门:DR和SR

USART整个模块的工作方式,可以想象成一个收发快递的收发室。数据要发出去,得先放进一个暂存柜,芯片外设自己会把这个柜子里的东西搬到线上去;数据要收进来,外设会把收到的字节放在另一个柜子里,等你去取。这两个柜子在STM32里共用同一个门牌号,就是DR寄存器,也就是数据寄存器。

DR寄存器是16位的,但在我们常用的8位数据帧模式下,真正用到的只有低8位。往DR里写数据就是发送,从DR里读数据就是接收。这里有个特别容易犯的错:DR同时承担收发,并没有分家的TX_DR和RX_DR,所以代码里千万别想着先写再读,那会把刚发出去的字节又读回来,掉进自己挖的坑。

光有收发室还不够,你得知道柜子什么时候空、什么时候满,这就需要状态寄存器SR来报告。SR里这一章最核心的两个位:TXE(发送数据寄存器空)和RXNE(接收数据非空)。TXE为1表示DR已经空了,你可以往里写下一个字节;RXNE为1表示DR里已经收到一个新字节,你要赶紧去读,否则下一个字节进来会把当前数据覆盖掉。

我用一个很直白的比喻帮你记这两个标志位:TXE是"柜子空了你快来放东西",RXNE是"柜子里有货了你快来取"。写代码的时候就是要不断盯着这两个信号,一个负责发货节奏,一个负责收货提醒。理解了这两个位,USART的收发逻辑你就懂了一半。

2.2 配置串口的三个控制调节杆:CR1、CR2、BRR

接下来是让串口按照你的意愿工作的控制寄存器。CR1是主控制寄存器,掌管最基础的总开关和模式选择。这个寄存器有十几个位,这一章必须先搞清楚五个。

UE是USART的总开关,置1外设才真正开始工作,这相当于给收发室通电。M位决定数据帧长度,0代表8位数据,1代表9位数据,绝大多数场景用8位就够,也就是M=0。TE和RE分别是发送使能和接收使能,字面意思,开哪个哪个才工作,两个都开就是全双工模式。还有一个RXNEIE,这是接收中断使能,后面搞中断接收的时候会用到,轮询模式下可以先不管它。

CR2寄存器这一章只需要关心STOP位,也就是停止位的配置。停止位是每帧数据结束时的标志信号,常用的是1位停止位,所以STOP位写成00或01都行,我习惯用00。这里要注意,停止位的设置必须和上位机或者对方设备保持一致,比如你的设备配了2位停止位,对方却按1位停止位来解析,通信就会不稳定,表现就是偶发地多收一个字节或者丢帧。关于这个后面在问题排查部分会细讲。

BRR寄存器是波特率寄存器,它的作用就是告诉你时钟频率和波特率之间的换算关系。STM32的USART比较灵活,支持16倍过采样和8倍过采样,我们这章先用标准的16倍过采样。公式很简单:USARTDIV = 外设时钟频率 / (16 × 目标波特率)。算出来的USARTDIV被拆成整数部分和小数部分,分别塞到BRR的高16位和低4位里。这个公式是本章的重点中的重点,我会在下一节带你把一个实际例子算到明明白白。

3. 一步步配置一个115200波特率的串口

3.1 时钟使能:所有配置的第一步

很多人配置外设上来就直接操作寄存器,结果发现怎么写都没反应,先检查时钟。STM32的外设都有一个时钟总闸,不打开的话寄存器写了等于没写,模块压根没有通电运行。

USART1在STM32F103上挂在APB2总线,对应的时钟使能位在RCC的APB2外设时钟使能寄存器里,也就是RCC_APB2ENR的第十四位。先把这个位置1,USART1才真正有了生命。同理,如果你用的是USART2或USART3,它们挂在APB1总线上,就要去RCC_APB1ENR里找对应的位,千万别搞混总线。这是新手最容易犯的错误之一,经验不足的人常常在APB2的寄存器里找USART2的使能位,找半天找不到,最后怀疑芯片坏了。

配置完外设时钟,还得确认IO口的时钟。USART1的默认引脚TX是PA9,RX是PA10,这俩引脚挂在GPIOA上,GPIOA的时钟也在RCC_APB2ENR里,和USART1的使能位挨着。很多人设置了GPIO的复用功能却忘了开GPIO的时钟,同样是不出数据。我的习惯是先把所有要用到的外设时钟一股脑开了,再逐个配置功能,顺序清晰,排查的时候也方便。

3.2 波特率计算:为什么是0x271

波特率这个参数看起来是个纯数学问题,但它的计算过程决定了你的串口能不能被对端设备正确识别。我用实际例子给你演算一遍,保证你以后遇到任何主频都能自己算出来。

STM32F103的USART1挂在APB2上,APB2的最高频率是72MHz。我们想要一个115200的标准波特率,代入公式:USARTDIV = 72,000,000 / (16 × 115,200) = 39.0625。注意,这里算出来的39.0625不是直接写进BRR的值,要分两部分处理。整数部分39转成十六进制就是0x27,小数部分0.0625要乘以16,得到1,这个1就是低4位的值。所以BRR寄存器的值就是0x27 << 4 | 0x01,即0x271。

算出来是0x271,十进制就是625。你可以用计算器验证一下:625乘以115200等于72,000,000,正好是APB2的时钟频率。这个数字对上了,就说明换算关系没错。

这里有一个细节很多人不理解:为什么小数部分要乘以16再写入低4位?因为BRR的低4位表示的是小数部分,而小数部分是USARTDIV的16进制小数表示法,每一位代表1/16。所以0.0625这个十进制小数,乘以16之后得到1,写在低4位里,表示的就是1/16。如果用8倍过采样模式,那就是乘以8塞到低3位,但那个场景用得少,前期不碰也罢。

提示:如果算出来的小数部分不是恰好能整除,别怕,芯片允许一定的波特率误差。一般误差控制在2%以内通信都很稳定。真正要注意的是别把整数和小数部分搞反了,那误差直接奔着几十个百分百去,绝对出乱码。

3.3 串口发送和接收的握手顺序

寄存器配置好了,接下来就是让数据跑起来。我先说发送。发送的流程其实就是在TXE标志和DR寄存器之间来回切换:检查TXE是否为1,为1就往DR写入一个字节。芯片自己会把DR里的数据转成串行位流发出去,这个过程不需要你操心,你只需要在TXE再次变1的时候写下一个字节。

写一个最简单的发送函数就是这样:

void UART_SendByte(uint8_t ch) { while (!(USART1->SR & (1 << 7))); // 等待TXE为1,即发送数据寄存器空 USART1->DR = ch; // 写入数据寄存器 }

第一行那个while循环就是死等TXE标志位的操作。USART1->SR读出状态寄存器的值,(1 << 7)是第七位的位掩码,也就是TXE所在的位。如果读出来位7是0,说明发送数据寄存器还是满的,就一直循环等;变为1了,说明可以写,循环结束,执行写DR的操作。

接收的轮询模式和发送相反,它的核心是RXNE标志位:RXNE为1说明DR里有一个新收到的字节,你把它读出来就是完成一次接收。

uint8_t UART_ReceiveByte(void) { while (!(USART1->SR & (1 << 5))); // 等待RXNE为1,即收到新数据 return (uint8_t)(USART1->DR); // 读取数据寄存器 }

这里特别注意:读SR和读DR的先后顺序是有讲究的。代码里先读SR拿到RXNE的状态,然后读DR,芯片会自动把RXNE清零。如果你先读DR再读SR,RXNE可能不会被正确清除,下一次接收就会出问题。这也是很多"串口收一次就卡死"案例的根源之一。

完整的初始化函数把前面的配置整合起来,看起来是这样的:

void UART_Init(void) { RCC->APB2ENR |= (1 << 2); // 打开GPIOA的时钟 RCC->APB2ENR |= (1 << 14); // 打开USART1的时钟 // 配置PA9为TX(复用推挽输出),PA10为RX(复用浮空输入或上拉输入) GPIOA->CRH &= ~(0xF << 4); // 先清零PA9的配置位 GPIOA->CRH |= (0xB << 4); // 1011:复用推挽输出,50MHz GPIOA->CRH &= ~(0xF << 8); // 再清零PA10的配置位 GPIOA->CRH |= (0x4 << 8); // 0100:浮空输入 USART1->BRR = 0x271; // 波特率设为115200 USART1->CR1 = (1 << 13) | (1 << 3) | (1 << 2); // UE=1, TE=1, RE=1 USART1->CR2 = 0; // 1位停止位 }

GPIO配置这里用到了CRH寄存器,因为PA9和PA10属于高16位引脚。PA9对应的位段是从第4位开始的4个位,PA10是从第8位开始的4个位。复用推挽模式写0xB,浮空输入写0x4,这两个值是从GPIO的CNF和MODE两位组合出来的,属于GPIO章节的知识点,第四章就用这个结论,深入解释留在GPIO那一章的回顾里。

CR1寄存器赋值那行用了三个位的组合:第13位是UE,第3位是TE,第2位是RE。这个配置完成后,串口立刻进入可以收发的工作状态。到这步,一个轮询模式的串口就已经能跑了。

4. 让CPU喘口气:改造为中断接收

4.1 轮询模式的痛点和中断配置的三步走

上面的轮询接收代码功能没问题,但有个致命弱点:CPU大部分时间都空转在while循环里,什么事也干不了。在实际项目中,CPU还要去跑传感器采集、跑显示刷新、跑运算逻辑,不可能专门蹲在串口面前等数据。这时候就要引入中断机制:串口收到一个字节,硬件主动通知CPU"来取货",CPU放下手头的活先把数据收走,然后继续干原来的事。

配置串口中断接收总共分三步。第一步在CR1寄存器里打开RXNEIE位,让接收事件可以触发中断请求。第二步去NVIC里使能对应的中断通道,USART1的中断通道号是37,写在NVIC_ISER寄存器里。第三步写中断服务函数,在函数里判断中断源并处理数据。

GCC环境下中断服务函数的写法是用void USART1_IRQHandler(void)这个名字,这是芯片启动文件里已经定义好的弱符号,你实现这个名字的函数,中断触发时就会自动跳进来。如果你的工程用的是标准外设库或者HAL库,这个函数名可能已经被包装了,要注意区别。

4.2 中断服务函数的正确写法

进入中断服务函数之后,首先要判断是不是我们关心的接收事件。判断的方法还是查SR寄存器的RXNE位。如果RXNE为1,说明这次中断确实是"收到新数据"引起的,那就赶紧读DR,把数据取走。这里同样要高度重视先读SR再读DR的顺序,否则会产生"接收中断永远触发、数据却一直是同一个"的诡异现象。

void USART1_IRQHandler(void) { if (USART1->SR & (1 << 5)) // RXNE为1,确认是接收中断 { uint8_t data = USART1->DR; // 读取数据,同时硬件自动清RXNE // 这里对data做处理,比如存到缓冲区 } }

这个框架看起来简单,但实际项目里往往会在中断里做更多的事情。比如你要实现一个不定长数据的接收,就需要在中断里判断帧头和帧尾;比如数据量大的时候,你还得处理缓冲区的溢出问题。这些工程化的细节,在真正理解中断机制之后都是水到渠成的事。

有一个我在项目里常用的处理手法:中断里只做最轻量的工作,把收到的字节放进一个环形缓冲区,主循环里再统一对缓冲区里的数据做协议解析。这样做的好处是中断服务函数非常短,不会因为在里面跑复杂逻辑而影响其他中断的响应,也不会因为处理太慢导致串口FIFO溢出丢数据。环形缓冲区的实现网上有很多种,原理不复杂,但它是串口在实际项目中能不能稳定运行的关键设计。

5. 实战中踩过的坑与排查方法

5.1 乱码的锅多半在波特率

如果你烧完程序发现串口助手打印出来的全是乱码,90%的可能是波特率配置不一致。这不是玄学,是硬件上的"双方速率不匹配"。你用115200发,上位机用9600收,收到的当然是一堆意义不明的字节。

排查方法很简单:先确认串口助手右下角的波特率是不是和你代码里配置的一致,然后核对BRR的配置值。以F103的72MHz主频为例,115200对应BRR为0x271,9600对应BRR为0x1D4C,这两者差了快八倍,搞混了必乱码。

还有一个隐蔽的情况:主频不是72MHz。有些板子用外部晶振,有些用内部RC振荡器,如果时钟系统没有正确配置,APB2总线频率不是72MHz,你按0x271去配115200,实际跑出来的波特率就会偏离标准值。遇到这种情况,先检查RCC里的时钟配置,确认系统主频,再用第一节的公式反推BRR。

5.2 串口收一次就卡死,先查标志位

这个现象的典型表现是:第一次发数据能收到,第二次发就完全没有反应了。这种问题在轮询和中断模式下都可能出现,根因九成是RXNE标志位没有被正确清除。

RXNE这个标志的清除机制有一个隐含条件:你必须先读SR寄存器,再读DR寄存器。芯片会在你执行完第二次读操作的时候自动把RXNE清零。如果你的代码里读DR之前没有先访问SR,或者顺序反了,RXNE就一直保持为1,程序会认为"一直有新数据",自然不会再等下一次接收。

用汇编级的思维去理解这个问题就很好记:读SR之后再读DR,这是一个完整的"消费"动作,芯片要靠这个时序来感知"数据已被取走"。你破坏了时序,它就不知道你已经拿走了数据,于是傻乎乎地一直举着"有货"的牌子。这个问题排查起来很容易,加上一句(void)USART1->SR;的虚假读取就能解决,但想明白为什么这么加,比加这一句本身重要得多。

5.3 串口调试的排查速查表

我把这些年调试串口遇到的问题整理成了一张表格,方便你在现场快速定位。这张表我打印出来贴在工位上很久,后来项目用熟了,一眼就能看出问题在哪。

现象可能原因排查方向
完全无输出USART时钟没使能检查RCC_APB2ENR的bit14
完全无输出TX引脚配置错误检查GPIOA的CRH配置
完全无输出TE位没置1检查CR1的bit3
乱码波特率不匹配核对BRR和两端配置
乱码数据位/停止位不一致核对CR1的M位和CR2的STOP位
收一个字节就卡死RXNE未清除检查SR/DR读取顺序
偶发丢数据中断处理太慢精简中断函数,引入环形缓冲区
上位机收到重复数据上位机配置了DTR/DSR信号关闭串口助手的流控选项

最后一条关于DTR/DSR的坑很少被提及但很常见。串口助手里的"DTR"和"RTS"选项如果勾选了,有些USB转串口芯片会额外驱动一些控制线,导致MCU的某个引脚电平异常,进而触发复位或者干扰通信。解决办法很简单:调试阶段把流控全部关掉。

如果你用的是实际硬件而非开发板,还有一个必须提的检查项:共地。串口通信双方必须共用一个地参考,也就是GND要连在一起。我见过太多人三根线只接了两根,TX和RX接上了,GND没接,结果就是数据时好时坏、完全没有规律。这种问题查起来最头疼,因为它的表现和软件Bug一模一样。

6. 调试串口常用的三板斧工具

// 硬核且常用的调试手段 —— 用串口打印芯片内部状态 void DebugPrint(const char *str) { while (*str) { UART_SendByte((uint8_t)(*str++)); } }

这段代码虽然简单,但在调试阶段的价值远被低估。程序跑到哪个分支了,变量值是多少,芯片内部状态如何,全都可以通过这种方式在串口助手里看到。它不需要仿真器,不需要额外的调试软件,一条USB转串口线就能完成全部工作。我在项目里调试时序问题的时候,经常在关键位置插入这种打印,帮了不少忙。

另一个实用技巧是用逻辑分析仪抓波形。逻辑分析仪的RX通道接在MCU的TX引脚上,启动抓取之后你就能直接看到一帧数据的电气波形,包括起始位、数据位、停止位。通过波形你不仅能看到波特率是否准确,还能看出停止位配置、数据长度配置有没有问题。这些都是纯软件层面看不出来的东西,波形一出来,整个通信链路的物理层健康状态一目了然。

对于手里没有逻辑分析仪的朋友,还有一个备选方案:把串口波特率故意配置成不同值,看数据变化的规律。比如你以115200发送字符串"Hello",用57600去接收,收到的会是一串看似随机但其实有规律的比特模式。通过对比不同波特率下的接收数据,可以反推出发送方的实际波特率。这个土办法当年在野外调试帮我解决了一个晶振频偏引起的通信问题。

7. 这章的结尾留一个作业

串口的寄存器就这么些,轮询和中断两种模式也都跑通了。如果你照着前面的代码自己敲了一遍,你应该已经对USART的工作流程有了完整的理解。这时候不要急着往下翻,我建议你做一个实验:把程序改成回环模式,串口收到什么就发什么,用USB转串口线把TX和RX短接,再用串口助手手动发送数据,看看会不会原样弹回来。

这个小实验可以同时验证发送、接收、中断三个环节的正确性,而且它本身就是一个串口调试工具的原型。我个人的体会是,寄存器编程的学习没有捷径,但也没有想象中那么难,最难的是迈出"不看库函数、直接操作寄存器"的第一步。这一章所有内容跑通之后,你会发现其他外设的寄存器手册读起来都顺眼了很多。下一章讲定时器的时候,你会庆幸这章打的基础够扎实,因为定时器在某些层面比串口要抽象得多。

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

Apollo-11项目实战:从结构解析到二次开发的完整指南

1. 从“Apollo-11”这个名字说起&#xff1a;它到底指什么第一次看到“Apollo-11”这个标题&#xff0c;很多人脑子里蹦出来的第一反应是上世纪的登月计划。但在技术圈里&#xff0c;这个名字被反复借用&#xff0c;通常指向的是一类代码仓库、开源项目或工程代号——用“阿波罗…

作者头像 李华
网站建设 2026/10/14 16:55:33

喜剧之王最新流出,4k无常分享~

「喜剧之王」 链接&#xff1a;https://pan.baidu.com/s/1ljIwOr0pClvZZxzUPY1hFA?pwd6666电视剧版保留了原作“小人物追梦”内核&#xff0c;融入乡村商战与乡村振兴背景&#xff0c;上演笑中有泪的“乡村商战”。故事舞台从片场移至虚构的“沙浦村”&#xff0c;讲述一群“人…

作者头像 李华
网站建设 2026/10/14 16:55:33

ESP32舵机控制全攻略:从硬件选型到PID闭环实战

1. 项目概述&#xff1a;为什么舵机控制是ESP32落地最值得深挖的“第一课”你手头有一块ESP32开发板&#xff0c;刚点亮LED、连上Wi-Fi、发了几条MQTT消息&#xff0c;兴奋劲儿还没过——但很快就会发现&#xff1a;光会“说话”&#xff08;通信&#xff09;和“亮灯”&#x…

作者头像 李华
网站建设 2026/10/14 16:55:16

Android加载动画实践:逐字跳跃效果从TextView到自定义View的完整实现

当年第一次在某同城App里看到那个"加载中"的逐字跳跃效果时&#xff0c;我第一反应是&#xff1a;这玩意儿做个动画不就完事了吗&#xff1f;后来真到自己项目里要复刻&#xff0c;才发现没那么简单——三个字一个接一个往上跳&#xff0c;节奏、插值、循环周期只要差…

作者头像 李华
网站建设 2026/10/14 16:54:07

iOS事件传递与响应链底层原理深度解析

1. 这不是一道“背题”&#xff0c;而是一张iOS事件处理系统的解剖图“UI事件传递&响应”这六个字&#xff0c;出现在iOS面试题里&#xff0c;表面看是考你能不能复述hitTest、nextResponder这些名词&#xff0c;但实际是在测试你有没有亲手拆过UIKit的底层逻辑——就像修车…

作者头像 李华