☰
STM32串口USART寄存器操作白话详解:从波特率到中断收发
2026/10/14 16:48:01 网站建设 项目流程

打开手册之前,先聊聊串口这件事

很多朋友是从《STM32寄存器白话手册》前四章一路看下来的,前面的内容我们把GPIO、时钟树、中断控制器掰开揉碎讲了一遍,这第五章终于要碰嵌入式开发里最常用、也最能产出成就感的外设之一——USART。如果你刚翻开这一章,我先告诉你它讲的是什么:用寄存器操作的方式,把STM32的串口彻底玩明白,从最底层的波特率发生器,到收发字节的完整时序,再到中断接收的正确姿势,全部用白话讲清楚。

这章适合谁看呢?已经能独立点灯、会配时钟、会用外部中断的初学者,以及被标准库和HAL库封装搞到"知其然不知其所以然"的进阶开发者。如果你还在纠结为什么波特率寄存器填那个数字、为什么发送要先读状态寄存器再写数据寄存器,那这章就是冲着你来的。

很多人一提起寄存器操作,第一反应是"能跑就行,管它底层是什么"。但说句实在话,用寄存器操作串口,是我见过最快的捅破通信原理窗户纸的方式。你可想而知,当你亲手把BRR寄存器里的值用算盘拨出来、让数据飞上串口线的时候,你对"通信到底是怎么回事"的理解深度,跟那些只会调库的人完全不在一个维度上。

这章的内容量不小,我尽量按"先懂原理、再记结构、后能实操、最终会排查"的顺序来组织,每一段都争取讲出手册里没有明说、但实际调板子时非要命的事情。

1. 写在配置寄存器之前:串口通信的底层逻辑

很多手册的写法是直接甩给你寄存器表格,告诉你这位是数据寄存器、那位是控制寄存器,然后让你照着填。但我觉得在碰寄存器之前,得先把串口通信的物理逻辑打通,不然后面调参时你连"为什么丢数据"都猜不出来路。

1.1 一根线怎么传二进制:异步串行的核心思想

串口(UART/USART)的全称是通用异步收发器,注意"异步"两个字。它不像SPI那样有一根专门的时钟线来同步收发双方,而是靠通信双方各自约定一个相同的波特率(每秒传输的比特数),在一个字符的起始位和停止位之间,自己用自己的时钟去采样线路上的电平。

用白话打个比方:串口通信就像两个人隔着几十米喊话,互相看不见对方,只能靠约定好的语速(波特率)来传递信息。每个人喊出的话由"开始喽"(起始位)、中间的内容(数据位)、最后一声"我说完了"(停止位)构成,说话的人靠嗓门大小区分0和1,听话的人靠约定好的语速来判断每一个字从哪里开始、到哪里结束。

那么回归到STM32的内部结构,USART模块的核心工作就是三件事:把你程序里那个16位的字节,按约定的格式变成一帧电信号发出去;把线路上收到的电信号,按同一套规则还原成一个字节存进寄存器里;以及在这个过程中处理各种标志位,告诉你"上一个字节发完了""新收到一个字节了""缓冲区满了"等等状态信息。

理解了这一层,再去查寄存器的名字和含义,你会发现每一个寄存器都对应通信过程中的一个角色:有人负责定语速,有人负责装数据,有人负责发布状态消息,有人负责定制说话礼仪(配置位长、检验位、停止位)。从就这角度看,寄存器其实没那么可怕。

1.2 数据帧长什么样:起始位、数据位与停止位

串口的一个完整数据帧,按最常用的8-N-1配置来说,包含这么几个部分:一个起始位(低电平)、8个数据位(从LSB开始顺序发送)、一个可选的奇偶校验位、一个或两个停止位(高电平)。

这里我想特别强调数据位的发送顺序。很多人第一次用逻辑分析仪抓串口波形,看到线上的波形会觉得跟自己想象中"先发高位再发低位"完全不同,原因就是串口是从LSB开始发送的,即最右边的"比特0"最先出现在线路上。如果你用示波器或逻辑分析仪去解析,需要按照位序倒着读才是你的真实数据,这一点在调试时特别容易踩坑。

另外一个容易混淆的概念是"波特率"和"比特率"。在只有数据位没有多余编码的简单场景下两者数值相等,但严格的波特率指的是"每秒电平跳变的次数",也就是每秒传输的码元数。在常规8-N-1配置里,一个码元就是一比特,所以数值一样。但只要你以后接触调制解调之类的场景,就会发现波特率跟比特率根本不是一个概念,别在博文笔记里写混了。

2. USART寄存器逐位拆解:白话解读和关键坑点

STM32的USART寄存器组,在不同系列里基本架构相同,常见寄存器大致有这些:状态寄存器SR、数据寄存器DR、波特率寄存器BRR、控制寄存器CR1、CR2、CR3,以及新版芯片里的各种扩展寄存器。我按使用频率和逻辑顺序一个一个讲。

2.1 SR状态寄存器:先读后写的最重要铁律

SR寄存器(Status Register)里最核心的几个标志位是:TXE(发送数据寄存器空)、RXNE(接收数据寄存器非空)、TC(发送完成)、ORE(过载错误)、NE(噪声错误)、FE(帧错误)、PE(奇偶错误)。

先说TXE。很多初学者把TXE当成"数据已经发送完毕"的信号,这是经典误区。TXE置1表示的是"数据寄存器DR空了,你可以把下一个字节塞进来了",而数据真正从移位寄存器发完、引脚上出现停止位的时候,置位的是TC标志。通俗理解就是:TXE是"垃圾桶空了可以扔下一个垃圾",TC是"垃圾已经在垃圾场烧完了"。大多数场景我们从轮询方式发送时,只要等TXE即可,因为只要数据进了DR,硬件就会自己把它搬运到移位寄存器,你无需担心后面的流程。但如果有些时候你需要精确控制引脚时序,比如切换RS485收发方向,那就要等待TC为1,确保停止位发送彻底结束,才能去切换GPIO方向。

再讲RXNE:接收数据寄存器非空,也就是"收到了一个新字节,快来取值"。这个标志会在你读取DR寄存器之后由硬件自动清零,也可以在软件里手动清零,但实际工程里我建议用"读DR自动清"的方式,因为手动清零的时序搞不对容易误清掉刚进来的数据。

说到SR寄存器,有个重要警告——在旧版芯片上操作SR寄存器相关位时,不要用"读-改-写"的方式去操纵状态标志,因为这些标志位很多是"写入0无效、硬件置位、读后清零"的特性,盲目用位带操作或先读后写的方式改它,很可能把硬件置位的标志误清了,导致程序行为怪异。处理SR的正确姿势是:读它了解状态,靠读取DR或按手册要求写特定位来清除标志,别没事就对整个SR做赋值操作。

2.2 DR数据寄存器:入口和出口都是它

DR寄存器有点意思,它是双面的:你往它里面写数据,数据会走TX移位寄存器从TX引脚送出去;你从它里面读数据,读到的其实是RX移位寄存器从RX引脚收到的内容。注意这两个方向对应的物理实体是分开的,只是共用同一个寄存器地址。

DR寄存器在8位数据模式下,只有低8位有效;如果配置了9位数据模式,则低9位有效。写数据时,多余高位忽略;读数据时,多余高位的值要么是0要么是不确定值,取决于具体芯片实现。

我之前见过有人产生困惑:为什么往DR寄存器写0x1FF,接收端却收到0xFF?就是因为他没理解9位模式下才用低9位,8位模式下高第9位相当于不存在,你写上去了硬件也不会理你。

2.3 BRR波特率寄存器:直接填还是用算式算,结果一样

BRR(Baud Rate Register)寄存器用于产生波特率。它的核心公式是:BRR = 外设时钟频率 / 波特率(当DIV16系数为0时,即过采样16倍)。注意这里的"外设时钟频率",指的不是系统主频,而是挂在USART上的那个总线时钟。比如说你在STM32F1系列里,USART1挂在APB2上,USART2挂在APB1上,如果你把APB1的分频器配置得跟APB2不一样,那两个串口要配成同样的波特率,BRR寄存器里装的值可以完全不同。

这里隐藏着那个"库里帮你干了但你可能不知道"的事:标准库里的USART_BaudRate配置函数,内部就是拿这个公式计算的。但是很多人没注意芯片手册里的一句话:BRR寄存器的写入,必须在USART处于禁用状态时进行。也就是说,你不能在USART开着的情况下改波特率,否则结果未定义。有些实战场景比如"运行中切换波特率"确实会踩这个坑,正常做法是先关USART,改完BRR,再重新打开。

BRR寄存器里还有个细节,当DIV_Mantissa和DIV_Fraction配置组合出来,有些波特率并不能被精确实现。例如外设时钟72MHz、波特率9600,运算结果是7500,整数部分很好办,小数部分0.000意味着分数部分是0,刚好整除;可如果波特率是115200,算出来625,而如果换一个时钟频率比如48MHz,48000000/115200=416.6667,小数部分取整成16进制的值后,实际波特率跟理想值就有误差了。这个误差只要在允许范围内(通常±2%左右就能保持可靠通信),就没有问题,你可以在关键的通信链路上提前算算这个误差,验证一下稳定性。

2.4 CR1/CR2/CR3控制寄存器:把你的配置意图说给硬件听

三个控制寄存器各自的分工,简单概括就是:

  • CR1管的是"基础开关":USART使能(UE)、发送使能(TE)、接收使能(RE)、中断开关,以及8/9位数据格式选择、奇偶校验使能、静默模式等。
  • CR2管的是"帧格式和时钟极性":停止位位数、时钟极性/相位(同步模式)、LIN模式等。
  • CR3管的是"扩展功能":DMA使能、红外模式、硬件流控(CTS/RTS)等。

动手配置时建议按"先静后动"的顺序:先在串口禁用状态下把CR1、CR2、CR3里你需要的功能位全部配置好,然后再开启UE。因为使能瞬间硬件就会开始按当前配置驱动引脚、检测线路,如果你先使能了再补配参数,启动时刻可能已经产生一次错误的帧传输或误判,虽然不一定次次出错,但属于你没有必要去碰的雷区。

3. 动手配置USART:一次标准的寄存器初始化流程

这一节我们把上面的内容落地,手把手走一遍用寄存器完成USART初始化的完整过程。就以常见的STM32F103系列,USART1在APB2总线上,时钟频率配成72MHz为例。

3.1 第一步:开启外设时钟和配置引脚复用

任何外设之所以"不工作",十有八九是时钟没开,串口也不例外。配置USART1对应的时钟位,在RCC的APB2外设时钟使能寄存器里,找到USART1EN这个位把它置1。这一步做完,USART模块才真正上电。

接着配置GPIO引脚。USART1的默认引脚是PA9(TX)、PA10(RX),要把这两个引脚配置为复用推挽输出,但这里的复用功能选择还需要额外一步:在AFIO寄存器或GPIO的复用配置寄存器里,告诉引脚"你现在的复用功能是USART1"。别小看这步,很多"为什么串口没波形"的排查,最后都落在这类复用配置缺失上。在F1系列里,AFIO的功能通过复用重映射寄存器设定,不需要额外操作的情况是默认映射,但如果你的板子把串口引脚重映射到了PB6/PB7,那就要操作重映射寄存器了。

3.2 第二步:确定帧格式和计算BRR

假设我们需要8-N-1(8个数据位、无校验、1个停止位),就在CR1里把M位设置为0(8位数据模式)、PCE位设置为0(校验关闭),在CR2里把STOP位设置成00(1个停止位)。

接下来算波特率。假定外设时钟72MHz,我们需要115200bps。运算过程:72000000 / 115200 = 625。如果按奇数分频用公式,BRR寄存器直接填625进USART_BRR,硬件会自动拆成整数部分和小数部分,不需要我们手工分离。这就省事多了——但前提是你选的时钟频率和波特率能被整除;如果不能整除,就按我之前说的手动算小数部分的16进制值,把误差控制在允许范围。

提示:生产环境中,我习惯把波特率计算用脚本封个小工具,输入时钟和波特率直接输出BRR值,包括误差百分比,省得到时候在调试器里按计算器按半天。算完看一眼误差,如果超过2%,我会优先考虑调整外设时钟分频方案,而不是硬着头皮用。

3.3 第三步:填写控制寄存器,打开收发使能

在禁能状态下,按前面的配置写入CR1和CR2。然后把CR1里的TE位和RE位置1,即发送和接收都使能;最后把UE位置1,串口进入工作状态。

有一点要强调:虽然我们把TE和RE放在UE之前或之后都见过有人这么做,但稳妥的顺序是先在UE=0时配置好TE和RE,再统一UE=1。因为TE/RE仅仅控制模块内部的收发路径,而UE是整个模块的总闸,先开总闸再开路径,模块上电瞬间的内部状态不是完全可控的。反过来先设好路径再开总闸,行为干净利落。

3.4 第四步:验证初始化是否成功

配置完,如果你有示波器或逻辑分析仪,先别急着发数据,直接在TX引脚上手动拉一下电平试试?不对,这种方式不太规范。更靠谱的验证方式是:向DR写入一个0x55(二进制01010101),然后触发一次发送,再用逻辑分析仪抓波形。0x55是交替的01模式,波形看起来像方波,容易肉眼校验波特率正确与否。测出来的一个完整位的时间,用示波器光标量一下,算出波特率对比一下预估值,基本就能验证整体配置有没有问题。

4. 收发机制与中断处理:驱动串口的核心代码

配置完成之后,串口就能用起来。但应用层怎么通过轮询或中断方式收发数据,这里面的讲究比想象中多得多。

4.1 轮询发送:为什么等待TXE而不是等待其他

最简单的发送流程如下:确认TXE为1(上一个字节已经被取走)、把要发的字节写入DR、重复以上步骤。因为一旦写入DR,硬件会立刻把数据搬运到移位寄存器,TXE随即再次置1,所以连续发送其实就是循环"检查—写入"。

这里有个性能相关的细节:如果你在等待TXE的循环里不加任何超时保护,而且对端设备因为故障完全不回应或者你的发送引脚被外部拉死,程序就会一直卡死在这个循环里。在裸机开发里,这往往表现为"程序莫名其妙跑到某处就停了"。所以轮询发送必须配上超时机制,哪怕就是简单数一会儿时间片,也会让你的代码健壮很多。

4.2 轮询接收与溢出错误的连锁反应

接收侧的标准流程:等待RXNE为1、读取DR拿到数据、继续等。

但如果程序处理慢,硬件又连着收到了好几帧数据,就会出现溢出错误ORE。一旦ORE置1,RXNE可能被一同置位,但数据可能已经丢了一帧。更麻烦的是,收到错误标志后如果不做处理,之后的接收可能一直异常。正确处理方式就是:一旦检测到ORE,马上读取SR再读取DR,把这组读操作做完,标志就被清掉,接收逻辑就能回到正常轨道。

4.3 中断接收:为什么推荐优先用中断而不是轮询

如果程序还有别的事要做,显然不能一直轮询等串口。把RXNE中断打开,让串口收到字节时自动触发中断处理程序,这是最基本也最推荐的做法。初始化流程就是:在CR1里把RXNEIE位置1,然后在NVIC里使能串口中断通道,在中断服务函数里先判断是不是RXNE引起的,是的话就读取DR把数据存进你设计的接收缓冲区。

这里要注意,中断服务函数里读DR的动作,本身就会自动清除RXNE标志,所以不需要额外手动清零标志位。如果你手动操作了SR里的RXNE位试图清零,反而在有些芯片上会导致不确定行为。

4.4 发送中断和环形缓冲区:实作里才遇得到的效率问题

发送数据其实没必要像接收那样非用中断不可,因为发送通常都是程序主动的行为——你想发,发就是了,轮询TXE并不会太多占用系统时间。但如果你的串口是配合无线模块或者需要大吞吐量,那可以用"空闲时开启发送中断,通过中断里填数据到DR"的方式,配合一个发送环形缓冲区,把大批量数据的排队传输交给中断去做。

我实话说,环形缓冲区的代码不复杂,但很多人第一次写,边界条件总是改不对,尤其是"缓冲区满""缓冲区空"的判断、读写指针追尾的问题。如果你也准备写环形缓冲区,建议优先使用"预留一槽"方案——即缓冲区中始终空一个元素的位置不用,用(写指针+1) % 缓冲区大小 == 读指针来判断满,用读指针 == 写指针来判断空。这样判断逻辑天然避免"满和空同态"的坑。类似的小技巧在串口收发场景里能帮你少掉很多头发。

5. 一个可直接抄作业的示例:回显程序与中断收发队列

光说不练假把式。下面给出一个裸机、寄存器级别的串口回显Demo,功能很简单:串口收到什么就原样发回什么。但我会用上中断接收、轮询发送和一个简化环形缓冲区,让你看到实际工程里一个能用的小型收发架构是怎么搭起来的数据。

5.1 工程结构说明

代码假设你已经完成了RCC和GPIO的配置(前几章内容),这里重点展示USART相关部分。接收用中断方式,发送用轮询方式,接收到的数据先存入一个环形队列,主循环从队列里取出字节再通过轮询发送回去。

5.2 核心代码与逐段解释

#define RX_BUF_SIZE 64 static uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head = 0; // 写指针 static volatile uint16_t rx_tail = 0; // 读指针 // 串口1初始化,时钟72MHz,波特率115200,8N1 void USART1_RegInit(void) { // 1. 开启时钟(略,假设RCC配置完成) // 2. GPIO: PA9 TX, PA10 RX 复用推挽输出(略,假设已配置) // 3. 帧格式 USART1->CR1 &= ~(1 << 12); // M=0,8位数据模式 USART1->CR1 &= ~(1 << 10); // PCE=0,禁用奇偶校验 USART1->CR2 &= ~(3 << 12); // STOP=00,1位停止位 // 4. 波特率 USART1->BRR = 72000000 / 115200; // 625 // 5. 使能接收中断,关发送中断 USART1->CR1 |= (1 << 5); // RXNEIE=1 // 6. 打开串口模块、发送、接收 USART1->CR1 |= (1 << 3); // TE=1 USART1->CR1 |= (1 << 2); // RE=1 USART1->CR1 |= (1 << 13); // UE=1 // 7. NVIC配置(略) } // 中断服务函数骨架 void USART1_IRQHandler(void) { if (USART1->SR & (1 << 5)) // RXNE { uint8_t byte = (uint8_t)(USART1->DR & 0xFF); uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) // 缓冲区未满 { rx_buf[rx_head] = byte; rx_head = next; } // 如果满了,丢弃该字节,或置一个溢出标志 } } // 主循环发送一个字节 void USART1_SendByte(uint8_t byte) { // 等待TXE,加一个超时计数,防止卡死 uint32_t timeout = 0xFFFFF; while (!(USART1->SR & (1 << 7)) && timeout--); // TXE USART1->DR = byte; } // 主循环从队列取数据并回显 void User_LoopTask(void) { while (rx_head != rx_tail) { uint8_t ch = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; USART1_SendByte(ch); } }

这段代码里有一个细节值得留意:读DR之前先检查了RXNE,但实际上中断里能确认进入函数是因为RXNE,所以条件判断存在是合理的保险。读DR的动作本身会清RXNE,这个读操作请务必执行完整——不要只取USART1->DR高字节之类零碎操作,因为在某些系列上不完整的读可能引发残留标志问题。

另外,如果你把发送函数拿到中断里调用,那就要注意优先级嵌套和重入问题。这个例子里发送只在主循环调用,中断服务函数只接收、不发送,结构简单,不会出现自己打断自己的问题。

5.3 把程序烧进去:验证和测试

用USB转串口模块连接PA9和PA10,注意交叉连接:板上TX接模块RX,板上RX接模块TX,还要共地。用串口助手发送一串字符,能看到原样返回就说明收发通路全通了。此时你可以顺手做一个波特率压力测试,分别用9600、57600、115200、500000几个常用档位跑一遍,看看有没有字节丢失或乱码。

乱码往往不是代码逻辑问题,而是波特率配置和上位机设置不一致,或者晶振频率和库函数里预定义的频率不一致导致的。每次遇到乱码,先不要怀疑别的,先确认两边的波特率是不是都对应到同一个数值,再拿示波器看波形验证,大部分问题当场就解决了。

6. 实战中必踩的坑:从开发现场总结的排查清单

串口这部分内容虽然基础,却是大部分Bug的高发区。我把这几年现场调试和带新人时遇到过的高频问题整理成一个速查表,希望你在调板子时能少绕几个弯。

现象可能原因排查方向
完全无发送波形串口时钟没开;引脚复用没配置;UE位没置位查RCC外设时钟寄存器;查GPIO复用功能;查CR1的UE位
发送乱码波特率不一致;BRR计算错误;时钟频率配置与预期不符用示波器量位宽换算波特率;核对PLL和总线分频配置
能发不能收RE位没设;GPIO模式不对(RX引脚应为浮空输入或复用输入);对端TX未接好查CR1的RE位;查GPIO输入模式;确认接线共地
接收偶尔丢字中断处理太慢;没及时读DR导致ORE溢出中断服务函数精简;检查ORE标志处理逻辑
发送卡死等待TXE未加超时;发送引脚被外部短路拉死在发送while循环里加超时保护;检查外部硬件
连发多字节错乱TE和TC概念混淆;发送节奏控制不对;DR还没空就写入新数据区分TXE和TC;确认每次等TXE后再写入

除此之外,我再补一个较少人提但很常见的坑:芯片不同系列的USART寄存器地址可能不同,有些芯片还有UART和USART的区分(带不带同步时钟)。如果你从F1迁移到F0或者G0系列,直接复制寄存器代码大概率会翻车。不同系列里,寄存器布局有差异、附加功能位也各不相同,迁移时务必以目标芯片的手册为准,逐个寄存器核对。

在社区里写这一章时,我最后的体会

写这章的时候,我自己也把早年间用STM32做的一批老代码翻出来重新过了一遍。最大的感受是:寄存器编程这事儿,真正难的不是记住每一个位叫什么名字,而是把"硬件行为模型"在脑子里建立起来。你知道TXE是"可以投递下一个字节",就自然知道发送流程该等谁;你知道DR的读和写各自通向哪条路,就永远不会在收发逻辑上犯糊涂。

结合我自己的开发习惯,如果你手上有调试器,建议在配置完USART后,先把USART1->CR1、CR2、BRR这些寄存器的值在Watch窗口里看一遍,对照手册逐位确认,这会比盲目改代码高效得多。如果你没有调试器,把代码里几个关键寄存器打印出来,也能帮你快速定位问题出在配置阶段还是通信阶段。

第五章到这里把USART从物理层到寄存器到中断收发架构都过了一遍。下一章如果继续深入,我会考虑拿DMA把串口收发再次升级,实现在高波特率下不自旋、不阻塞的高吞吐框架;那些内容也是从"能用"走向"好用"必经的一步。不过今天这章内容,足够你应付大部分实战场景了,先把这个玩熟再说。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询