写这个系列的第五章之前,我其实犹豫了很久。串口太“基础”了,基础到网上随便一搜就是一大把教程,基础到很多人觉得它不值得单独开一章。但恰恰是这种“基础”外设,最容易让人在寄存器层面栽跟头。前面几章我们把GPIO、时钟和中断挨个摸了一遍,说到底都是在和配置型外设打交道——往寄存器里写一个值,引脚电平就变了,效果立等可见。串口不一样,它是你遇到的第一个真正有数据流动的外设:有发送、有接收、有状态位、有中断标志,还有一个充满小数陷阱的波特率计算。名字还是那些寄存器,玩法却完全不同。
这一章我想用“白话”的方式,把USART串口的寄存器从头到尾拆一遍,从四个核心寄存器讲起,再到最小发送程序、中断接收、波特率排查,最后总结一套可以迁移到SPI、I2C身上的方法论。如果你前面几章已经自己点过灯、按过按键,那这章会是你第一次感觉到“寄存器真的在干活”。
1. 为什么第五章要讲串口:它是一面照妖镜
1.1 这个系列走到现在,先翻翻账本
在动手配置串口之前,我建议你先回顾一下前几章你用寄存器做过的事情:点亮LED,往ODR写0或1;按键扫描,读IDR;外部中断,配EXTI的上升沿触发;NVIC,打开中断通道。发现没有,这些操作本质上都是“一次性配置”——你把参数写对,硬件就开始按你的意图工作,之后基本不用管它。
串口打破了这种舒适区。它以异步方式持续工作:你要发数据,得等硬件准备好;你要收数据,得随时准备响应;数据来得太快,还会报错。换句话说,你第一次需要“和硬件对话”。这种对话不是靠一个寄存器就能完成的,而是靠一组寄存器之间的配合。而把这种配合看清楚的最好方式,就是直接看寄存器,而不是躲在库函数后面。
1.2 串口能干什么,为何第一个通信外设选它
串口的应用场景不用我多介绍:调试打印是绝大多数嵌入式项目的生命线;各种GPS模块、蓝牙模块、WiFi模块、传感器模块,十有八九都是串口输出;AT指令交互更是老传统。可以说,不会串口,你连调试设备都费劲。
但我把它放在第五章还有一个更实际的理由:串口的协议足够简单。一根TX一根RX,没有时钟线,没有主从仲裁,没有ACK/NACK,电平按约定的波特率翻动。协议简单意味着你拿示波器或者逻辑分析仪看到的高低电平,能直接对应上寄存器行为。如果你连串口的寄存器都讲不清楚,那后面SPI的时钟极性、I2C的仲裁、CAN的位时序,基本就更不用想了。
1.3 贯穿全章的外设操作套路
我把串口调通之后再回头看,发现整个配置过程其实是一套可以复用的“外设操作套路”:
打开RCC时钟 → 配置GPIO复用 → 设置外设参数 → 使能外设总开关 → 查状态位 → 读写数据寄存器。
我习惯把这六步叫作“外设的呼吸节奏”。举一个生活化的例子:你要开水龙头,得先打开水管阀门(RCC时钟),确认水管口径(GPIO模式),然后才能拧水龙头开关(CR1里的UE位)。水龙头打开之后,水有没有流出来,你得盯着水池看——这就是状态寄存器。
串口这一章,就是让你第一次完整练习这套呼吸节奏。
2. 先别急着写代码:四个必须认识的USART寄存器
2.1 USART_SR 状态寄存器是外设的“脸色”
USART_SR可能是你调试串口时看得最多的寄存器,它的每个位都在告诉你硬件当前的脸色。这里面有四个位,建议你闭着眼睛都能背出来:
| 位 | 名称 | 含义 |
|---|---|---|
| 7 | TXE | 发送数据寄存器空,可以往DR里写新数据 |
| 6 | TC | 发送完成,当前字符已经全部送出 |
| 5 | RXNE | 接收数据寄存器非空,DR里有一个新收到的字节 |
| 3 | ORE | 过载错误,新数据到的时候RXNE还是1,旧数据被覆盖 |
查询方式收发,本质上就是反复“看脸色”:发送前盯着TXE,接收前盯着RXNE。出错的时候看ORE。别小看这几个位,很多奇怪的现象都是因为没看对状态位导致的,比如明明发了一长串字符串,最后一个字符没出来,因为你在等TXE而不是TC。
2.2 USART_DR 数据寄存器:同一个地址,两条路
DR是串口的数据关口。写DR,就是把要发送的字节交出去;读DR,就是把收到的字节取走。同一个地址,两个方向。
发送的时候,如果TXE是0,说明上一次写入DR的字节还没被硬件移走,这时候你再写DR,前面的数据会被挤掉。所以标准动作是:死等TXE置位,然后写DR。接收的时候,如果RXNE是1,说明DR里已经有新字节,你要是不读走,下一个字节到的时候硬件就只能把旧数据覆盖掉,然后置位ORE。
值得留意的是,RXNE标志的清除方式是“读SR,再读DR”,这是F1系列手册里明确写的操作顺序。你单独读DR不会清标志,单独读SR也不会。这个细节在写中断接收时特别重要。
2.3 USART_CR1 控制寄存器:总开关与各种使能位
CR1是串口的总控室,里面每一个“IE”结尾的位都对应一个中断使能,而UE、TE、RE则决定了串口死没死、能不能发、能不能收。
| 位 | 名称 | 作用 |
|---|---|---|
| 13 | UE | 串口总使能,相当于总电闸 |
| 12 | M | 字长选择,0表示8数据位,1表示9数据位 |
| 10 | PCE | 校验使能,0表示无校验 |
| 5 | RXNEIE | 接收数据非空中断使能 |
| 6 | TCIE | 发送完成中断使能 |
| 3 | TE | 发送使能 |
| 2 | RE | 接收使能 |
配置顺序是个经常被忽略的坑:一定要先设M、PCE这些参数,再写BRR波特率,最后才把UE置1。如果先把UE打开再改参数,外设可能用半配置状态跑起来,行为非常诡异。
2.4 USART_BRR 波特率寄存器:小数的斗智斗勇
波特率寄存器BRR存的是一个定点小数。以F1系列常用的Over16模式为例,计算公式是:
USARTDIV = PCLK / (16 × 波特率)
整数部分放进BRR的高位,小数部分乘以16以后再放进低位。举个例子,一块板子的PCLK2是8MHz,我想跑115200,算一下:
8000000 / (16 × 115200) ≈ 4.3403
整数部分是4,小数部分0.3403 × 16 ≈ 5.44,四舍五入取5。所以BRR = 0x45。
用同一个公式,再给你几个常用配置:
| PCLK | 目标波特率 | USARTDIV | BRR |
|---|---|---|---|
| 8 MHz | 115200 | 4.3403 | 0x45 |
| 72 MHz | 115200 | 39.0625 | 0x271 |
| 72 MHz | 9600 | 468.75 | 0x1D4C |
| 36 MHz | 9600 | 234.375 | 0xEA6 |
看到没,只有一部分时钟和波特率组合能整除,其他组合都会有误差。误差在2%以内通常问题不大,但如果差得离谱,你收到的就是乱码。
3. 最小发送程序:让TX引脚吐出第一个字节
3.1 动外设之前,先把“电”送到
在F1系列里,GPIOA和USART1都挂在APB2总线上,所以RCC的寄存器操作可以一口气完成:
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN;很多人第一次写寄存器程序失败,原因不是逻辑错了,而是忘了开外设时钟。你往一个没通电的寄存器里写值,就像对着黑灯的屋子喊话,写了也白写。所以我建议你每次写外设代码,第一件事就是把时钟使能这一行先写上。
3.2 引脚不是随便配的
串口1的默认引脚是PA9和PA10。PA9要当TX使用,必须配置成复用推挽输出;PA10当RX使用,配置成上拉输入。对应的GPIOA->CRH操作如下:
GPIOA->CRH &= ~(0xFFUL << 4); // 清掉PA9、PA10的配置 GPIOA->CRH |= (0xBUL << 4); // PA9 = 1011,复用推挽输出,50MHz GPIOA->CRH |= (0x4UL << 8); // PA10 = 0100,上拉输入 GPIOA->ODR |= (1UL << 10); // 让PA10的内部上拉生效为什么PA9是复用推挽而不是普通推挽?因为引脚的控制权已经从GPIO模块移交给了USART外设。普通推挽是你用ODR控制电平,复用推挽是外设内部驱动这个引脚的电平。
3.3 寄存器写入顺序为什么不能乱
USART的初始化顺序,我的个人习惯是:先清CR1,再写CR2和BRR,然后开UE,最后开TE和RE。这样每一步都建立在前面条件就绪的基础上,不容易出现中间态。
void uart1_init(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN; GPIOA->CRH &= ~(0xFFUL << 4); GPIOA->CRH |= (0xBUL << 4); GPIOA->CRH |= (0x4UL << 8); GPIOA->ODR |= (1UL << 10); USART1->BRR = 0x45; // 8MHz PCLK2, 115200-8N1 USART1->CR1 = 0; // 先彻底清零 USART1->CR1 |= (1UL << 13); // UE,总使能 USART1->CR1 |= (1UL << 3) | (1UL << 2); // TE | RE }发送一个字符和字符串的函数也顺手写出来:
void uart1_putc(char c) { while (!(USART1->SR & (1UL << 7))); // 等TXE USART1->DR = c; } void uart1_puts(const char *s) { while (*s) { uart1_putc(*s++); } }用这个程序,配合串口助手,选择115200、8N1,你就能看到第一串字符从板子里冒出来。
3.4 串口助手里看到什么算成功
如果你的串口助手收到了一串可读的ASCII字符,说明从RCC到GPIO再到USART这条链路已经全线打通。如果收到的是乱码,首要怀疑对象是波特率;如果完全没反应,检查时钟是否打开、引脚是否配置对。
我曾经给新手一个很实用的自测方法:把PA9和PA10用一根杜邦线短接,程序里调用uart1_puts发送一段固定字符串。如果串口助手能收到同样的字符串,说明板子的发送和接收链路都正常,问题只可能出在外部连线或者对方的接收设置上。这招在定位问题时特别省事。
4. 接收和中断:RXNE、ORE这两个坑必须亲自踩一次
4.1 查询接收,先读对状态位
最简单的接收就是查询方式:死等RXNE置位,然后去读DR。
if (USART1->SR & (1UL << 5)) { uint8_t ch = (uint8_t)USART1->DR; }这段代码看起来没问题,但你得记住一点:RXNE的清除依赖“读SR再读DR”这个动作顺序。如果你之前为了判断标志已经读过SR,然后又单独读了DR,标志不一定清得干净。正确做法是让标志判断和数据读取尽量在一次流程里完成。
4.2 打开中断后,一切都在NVIC层面
查询接收的最大问题,是主循环被一个等待数据的死循环卡住,没法干别的事。解决办法就是中断接收:硬件收到数据后,直接跳到中断服务函数。
要做的事情有两件。第一,在CR1里把RXNEIE位置1:
USART1->CR1 |= (1UL << 5);第二,在NVIC里打开串口1的中断通道:
NVIC_EnableIRQ(USART1_IRQn);中断服务函数长这样:
volatile uint8_t rx_byte = 0; volatile uint8_t rx_flag = 0; void USART1_IRQHandler(void) { if (USART1->SR & (1UL << 5)) { rx_byte = USART1->DR; rx_flag = 1; } }中断服务函数里的活要越少越好,千万别在里面写打印函数或者复杂处理。你把数据赶紧存下来,置个标志位,剩下的交给主循环慢慢处理,这是嵌入式开发里最基本的修养。
4.3 ORE 过载错误:数据来得比你快
ORE是串口新手最容易碰到的坑。当RXNE还是1,也就是上一个数据还没被读走的时候,新的数据到了,硬件就会把DR里的内容覆盖,同时置位ORE。
这个坑最典型的触发场景,就是中断服务函数写得太慢,或者主循环里关中断太久。你在中断里又是解析协议又是做运算,数据源源不断进来,结果就是不断触发ORE。
ORE的清除方式同样是“读SR再读DR”。所以我建议你在中断服务函数里对ORE做一次兜底处理:
void USART1_IRQHandler(void) { uint8_t sr = USART1->SR; if (sr & (1UL << 5)) { rx_byte = USART1->DR; rx_flag = 1; } else if (sr & (1UL << 3)) { (void)USART1->DR; // 清掉ORE,放弃旧数据 } }很多人清不掉ORE,是因为中断服务函数里读SR和读DR之间被其他代码插了一脚。记住:清标志和取数据的动作要尽量挨着,就像抢红包一样手速要快。
4.4 一个能跑的echo程序
把上面的东西拼起来,就是最经典的echo程序——收到什么就回什么:
int main(void) { uart1_init(); NVIC_EnableIRQ(USART1_IRQn); USART1->CR1 |= (1UL << 5); // RXNEIE while (1) { if (rx_flag) { rx_flag = 0; uart1_putc(rx_byte); } } }串口助手发一个字符,这边回一个字符。可别小看这个程序,它在调试时比任何printf都好用,因为你能直观看到“中断有没有触发”和“数据有没有正确串行传输”。
5. 移植到新板子时,波特率对不上?先查这四件事
5.1 外设时钟是从哪棵树上摘下来的
F1系列里,USART1挂在APB2,USART2和USART3挂在APB1。APB1和APB2的最高频率不一样,分频系数也经常不一样,如果你把同一份BRR代码从APB2的外设搬到APB1的外设,波特率必然出错。
举个例子,你把BRR=0x45这套配置从USART1搬到USART2,但USART2的时钟是36MHz,实际波特率会变成419kbps左右,串口助手怎么设都不可能对上。所以每次换外设,先查你在用哪个总线时钟。
5.2 OVER8 在不同系列上的默认值不一样
Over16和Over8是USART的两种过采样模式。Over16模式就是我前面给的公式,Over8模式下波特率计算会差一倍,而且小数部分的精度规则也不一样。
F1系列的OVER8位默认是0,一般没人去动它。但F4、F3等系列里,这句“默认”并不一定成立。如果你把F1的代码原封不动搬到其他系列,波特率全错,不要惊讶,先去看OVER8位到底是0还是1。这个坑特别隐蔽,因为它藏在CR1的第15位,很多人一开始根本不会注意到。
5.3 引脚重映射和AFIO
USART1的默认引脚是PA9和PA10,但它其实可以通过AFIO重映射到PB6和PB7。很多板子为了走线方便,用的就是重映射引脚。
重映射需要做两件事:打开AFIO时钟,然后在AFIO->MAPR里置位USART1_REMAP。漏了这一步,你对着PA9调半天也没用,因为板子上根本没引出这个引脚。
我见过最典型的情况:某块板子的串口丝印明明写着TX和RX,但主控引脚在内部被重映射到了别处,新手拿着默认配置去调,死活调不通。遇到这种问题,第一步永远是查原理图,看这个串口到底接到哪个引脚上。
5.4 一次串口乱码的完整排查记录
有一次朋友让我看一块板子,说串口打印出来全乱码。我拿到手做的第一件事,不是开Keil改代码,而是把串口助手的波特率从115200改成9600试了一下——乱码变成了一种“有规律但不认识”的字符。这说明问题大概率在波特率匹配上,而不是电气连接问题。
接着我拿逻辑分析仪夹在TXD引脚上,抓了一小段波形,数了一下单个bit的时间宽度,发现实际波特率大约在9580bps左右。这就奇怪了,目标明明是115200。查代码发现,BRR是按8MHz PCLK2算的,但启动代码里把PCLK2配置成了72MHz,于是实际波特率跑到了1Mbps以上,串口助手当然收不到正常字符。
把RCC分频那一段改成实际时钟之后,瞬间就通了。这次排查给我最大的教训,就是“算BRR之前,先确认PCLK到底是多少”。如果你连外设时钟都不确定,后面所有计算都是空中楼阁。
6. 学完这章,读其他外设寄存器的方法论
6.1 寄存器手册的正确阅读姿势
我经常看到有人抱着数据手册从头翻到尾,结果越翻越迷茫。其实你应该看的是“参考手册”里的外设章节,而不是“数据手册”。数据手册讲的是引脚、电气特性和封装,外设寄存器一定要去参考手册里找。
打开USART章节,我建议先看“功能描述”部分的框图和帧格式,不要一上来就翻寄存器表。框图能让你知道这个外设由哪几个模块组成,帧格式能让你理解数据位、停止位、校验位是怎么排布的。看懂这两样,再去翻寄存器表,你会发现自己能猜出每个寄存器大概管什么。
6.2 从USART迁移到SPI/I2C
串口搞明白之后,再看SPI和I2C会非常熟悉,因为它们都是同一套骨架:
| 功能 | USART | SPI | I2C |
|---|---|---|---|
| 波特率/速率 | BRR | BRR | CCR |
| 控制寄存器 | CR1 | CR1 | CR1 |
| 状态寄存器 | SR | SR | SR |
| 数据寄存器 | DR | DR | DR |
| 错误标志 | ORE | MODF | AF |
SPI多了时钟极性和相位的概念,I2C多了一个总线忙状态和ACK机制,但“查状态位再读写数据寄存器”的核心逻辑完全没变。你会发现在USART上练出来的那套操作节奏,换个外设照样能用,这也是我为什么坚持让你从寄存器入手学外设。
6.3 一点个人体会
很多人觉得现在HAL库都这么成熟了,还学寄存器是不是有点倒退。我的看法恰恰相反:寄存器不是让你在生产代码里疯狂手搓,而是让你在HAL行为异常时能往下钻一层,搞清楚它到底在干什么。串口这个外设,协议简单、寄存器直观、波形可测,是练习这项能力成本最低的地方。
最后再分享一个小技巧:调试串口寄存器代码时,准备一支逻辑分析仪,每次改一个寄存器配置就抓一次波形,把“寄存器值”和“物理波形”一一对应起来看。比如改一下BRR,看波形间隔有没有变化;改一下CR1的M位,看帧长度有没有变化。这样做上三五次,你对串口的理解深度,会远超那些只会调库的人。