动手写寄存器,到了第四章,很多人卡在“查手册看得懂,写代码就懵”这个坎上。前面我们把时钟树、GPIO的推挽开漏都折腾过一遍了,这一章干脆来点实在的,把串口USART从寄存器层面彻底讲明白。为什么选串口?因为它是调试利器、上下位机通信的底座,也是中断、状态机、协议解析这些进阶技能的最佳练兵场。
这一章没有官方的“指定内容”,我按自己的学习路径来安排:以STM32F103这类常见型号为例子,从寄存器功能拆解到完整收发代码,再到中断接收和踩坑实录,一步步带着你从“对着数据手册发呆”变成“能独立搞定115200波特率通信”。无论你是刚扔掉HAL库的入门者,还是在做裸机通信的老手,都建议从头过一遍,很多细节是平时没人跟你说透的。
1. 串口模块的寄存器全景图
1.1 串口在芯片里的角色和地位
串口(USART)在单片机里就像人的嘴和耳朵,负责和外部世界说话。它既能打印调试信息,也能和设备通信,更是一堆传感器、蓝牙模块、WiFi模块的标准接口。从寄存器编程的角度看,串口模块是理解外设编程的教科书级例子:它有控制寄存器、状态寄存器、数据寄存器,处理时钟使能、波特率配置、中断管理,几乎涵盖了单片机外设编程的方方面面。
先给个整体认知:STM32F103的USART1挂载在APB2总线上,最高时钟72MHz;USART2和USART3挂在APB1上,最高36MHz。这个总线挂载关系直接影响波特率计算,很多人波特率不对就是没搞清楚时钟源。此外USART1的引脚通常是PA9(TX)和PA10(RX),复用功能开启方式也和纯GPIO不同,需要额外配置。
1.2 USART相关的核心寄存器一览
这部分建议对照芯片参考手册的USART章节一起看。我挑工作中最常用的几个寄存器,用大白话解释它们的职责:
| 寄存器 | 主要用途 | 关键位/字段 |
|---|---|---|
| SR | 状态寄存器 | TXE发送空、TC发送完成、RXNE接收非空、ORE过载错误 |
| DR | 数据寄存器 | 直接读写收发数据,发送写、接收读 |
| BRR | 波特率寄存器 | 分频值,决定通信速率 |
| CR1 | 控制寄存器1 | UE使能、M字长、TE发送使能、RE接收使能、RXNEIE接收中断使能 |
| CR2 | 控制寄存器2 | STOP停止位位数、CLKEN时钟使能 |
| CR3 | 控制寄存器3 | EIE错误中断使能、RTSE、CTSE硬件流控等 |
理解寄存器不能靠背。我的经验是先把它们分成三类:状态类就是看标志位的“仪表盘”,控制类是调模式的“开关面板”,数据类是传递信息的“信箱”。写代码的过程就是查状态、设控制、读写数据,这样思路一下就清晰了。
2. 时钟、引脚、波特率:配置串口前的三座大山
2.1 先让串口模块通电
任何外设工作前都得先使能时钟。很多人上来就配波特率,结果模块没上电,死活不通。如果是USART1,要操作RCC的APB2外设时钟使能寄存器,找到USART1EN这一位,置1;如果是USART2/3,则在APB1对应的位置操作。这一步错了,后面做再多都是白费。
// 以USART1为例,挂在APB2总线上 RCC->APB2ENR |= RCC_APB2ENR_USART1EN;2.2 引脚复用不是简单的“设为输出”
USART1默认映射到PA9、PA10,但这两脚不是普通GPIO,是复用功能引脚。配置要点是:PA9作为TX要复用推挽输出,PA10作为RX要复用浮空输入或带上拉输入。在STM32F103上,通过GPIO配置寄存器CRH设置端口模式,要选AFIO模式,不能选纯输出模式。
配置示例如下:
// PA9 = USART1_TX, PA10 = USART1_RX GPIOA->CRH &= ~(0xF << 4); // 清空PA9配置 GPIOA->CRH |= (0xB << 4); // 0b1011 = 复用功能推挽输出,50MHz GPIOA->CRH &= ~(0xF << 8); // 清空PA10配置 GPIOA->CRH |= (0x4 << 8); // 0b0100 = 复用功能浮空输入有些型号有AFR寄存器,但F103这一代还是CRH/CRL的方式,别弄混。还有个小坑:如果你同时用到多个复用外设,比如USART和USB都挂在PA11/PA12附近,需要用AFIO的重映射功能做引脚绕道,这个在第四章先不提,知道有这回事就行。
2.3 波特率计算,手把手教你敲
波特率寄存器BRR存的是分频值。核心公式:普遍情况下,USARTDIV = 外设时钟 / (16 × 波特率),然后把USARTDIV拆成整数部分和小数部分,分别填进BRR的高16位和低4位。但很多新手没搞懂小数部分怎么填,我具体算一遍:
假设USART1时钟为72MHz,想跑115200:
USARTDIV = 72000000 / (16 × 115200) = 39.0625
整数部分是39,也就是0x27;小数部分是0.0625,这0.0625要乘以16得到1,所以低4位就是1。BRR最终值为:
USART1->BRR = (39 << 4) | 1; // 0x271注意,这里小数部分不是直接把十进制的0625填进去,而是乘以16取整。串口传输的极性、停止位格式里都有细抠的地方,波特率偏差允许一般在正负2%左右,所以这步计算一定要细致。
再举一个波特率9600的例子:USARTDIV = 72000000 / (16 × 9600) = 468.75,整数468,小数0.75×16=12(十六进制0xC),BRR = (468 << 4) | 0xC。为什么算出来是整数十进制的468,而不是0x1D4?BRR的高16位就是直接放这个整数的十六进制值,低4位放小数。468的十六进制是0x1D4,所以组合后是0x1D4C。
2.4 开启串口的最简默认状态
波特率配好,再配CR1。我习惯先设置字长8位、无校验、1位停止位、使能发送和接收,具体位如下:
USART1->CR1 &= ~USART_CR1_M; // 8位数据,清零M位 USART1->CR1 |= USART_CR1_TE; // 使能发送 USART1->CR1 |= USART_CR1_RE; // 使能接收 USART1->CR1 |= USART_CR1_UE; // USART总使能 USART1->CR2 &= ~USART_CR2_STOP; // 1位停止位注意顺序:务必先配完其他位,最后把UE置1,否则可能出现边配置边工作的意外状态。
3. 查询模式的发送与接收:先能跑通,再谈优化
3.1 发送一个字节,到底在等什么
发送数据时,我们要往DR寄存器写数据,但DR只有一个,硬件机制是:你写入DR后,数据会先挪到移位寄存器里一点点往外送,此时状态寄存器SR的TXE位会变1,告诉你“我又可以接收下一个数据了”。操作顺序是:先读SR,检查TXE=1,然后再写DR。如果顺序反了,或者漏了等标志,数据会丢。
// 发送单个字符 void uart_send_char(char ch) { while ((USART1->SR & USART_SR_TXE) == 0); // 等待发送寄存器空 USART1->DR = ch; }这里有个容易忽略的点:TXE表示发送数据寄存器已空,但数据可能还在移位寄存器里没发完。如果需要“确保这一帧完全结束”,得等TC(发送完成)标志,而不是TXE。批量发送时,一帧接一帧无所谓,等TXE即可;但如果你关闭串口或进入低功耗,一定要先等TC。
3.2 发送字符串:别纠结效率,先把逻辑写对
再封装一层字符串发送:
void uart_send_string(char *str) { while (*str) { uart_send_char(*str++); } }这种逐字符等待的方式简洁,但有一个缺点:每发一个字符都要死等TXE标志,CPU被占用。小数据量无所谓,大数据或实时性高的场景要改成中断或DMA,第四章后面会讲到中断接收的替代方案。
3.3 接收一个字节:注意RXNE和ORE两个标志
接收时,每收到一个字节,硬件会把数据放进DR寄存器,并置位SR的RXNE。你要做的就是等RXNE变1,然后读DR取数据。但是新手最常中招的是ORE(过载错误):如果前面一字节还没被读走,新的一字节又到了,硬件直接把新数据丢掉并溢出置位。这就是为什么接收死等看起来没问题,实际跑起来却经常丢数据。
简单接收:
char uart_receive_char(void) { while ((USART1->SR & USART_SR_RXNE) == 0); // 等待收到数据 char ch = USART1->DR; // 读取数据,读DR会清RXNE return ch; }读DR这个动作其实会自动清除RXNE,所以如果你读了两次DR或先读SR再乱读,状态就飘了。还有,如果溢出发生了,哪怕RXNE还在,也要先读SR再读DR,才能把ORE清掉,否则它会一直卡着。
3.4 跑一个最简“回环”测试
把发送和接收组合起来,做一个串口助手能直接看到效果的回环测试:收到一个字节,马上原样返回。这样做的好处是验证收发通路是否齐活,也能暴露波特率、引脚配置的问题:
while (1) { char c = uart_receive_char(); uart_send_char(c); }接上USB转串口模块,用任意串口调试助手发送“A”,应该能看到“A”回显。如果没回显或者乱码,别急着改代码,先查硬件连接和波特率设置,这部分在第五章会专门列排查清单。
4. 中断接收:真正可用的串口程序
4.1 为什么查询模式不够用
查询模式在单片机比较闲的时候没问题,但在实际项目里,你不可能让CPU一直死等一个串口字节。假设你的主循环要跑电机PID、要刷显示屏、要处理按键,某一帧数据到了,主循环忙不过来,错过了RXNE,数据就丢了,或者ORE直接置位。解决方案是让串口“主动叫你”——用中断。
4.2 使能中断并向NVIC登记
开启中断分两步:第一步在USART的CR1寄存器里把RXNEIE位置1,让“收到数据”能触发中断请求;第二步在NVIC里使能对应的中断通道。F103的USART1中断号是37,但直接操作寄存器比较复杂,我们采用标准外设库或CMSIS头文件里现成的NVIC结构体写法:
USART1->CR1 |= USART_CR1_RXNEIE; // 接收数据非空则触发中断 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);如果不想用标准库,可以手动操作NVIC寄存器,但对初学者不友好。更关键的是,中断服务函数里要判断中断来源,然后读数据:
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t rx_data = USART1->DR; // 读DR会自动清RXNE // 把数据放进接收缓冲区 if (rx_index < RX_BUFFER_SIZE) { rx_buffer[rx_index++] = rx_data; } } if (USART1->SR & USART_SR_ORE) { // 读SR再读DR,清除过载错误 volatile uint8_t tmp = USART1->DR; } }4.3 一个迷你环形缓冲区
上面直接用数组当缓冲区很傻瓜,一旦接收数据多了,后面的数据会覆盖前面的数据。更稳妥的做法是环形缓冲区:写指针负责往缓冲区塞,读指针负责往外取,两个指针各自独立,一旦写指针追上读指针就说明缓冲区满了。实现不复杂,但对新手来说是个很好的跨越。
#define RX_BUF_SIZE 128 static volatile uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_write = 0; static volatile uint16_t rx_read = 0; void uart_rx_isr(uint8_t data) { uint16_t next = (rx_write + 1) % RX_BUF_SIZE; if (next != rx_read) { // 未满 rx_buf[rx_write] = data; rx_write = next; } } int uart_get_byte(uint8_t *data) { if (rx_read == rx_write) return 0; // 空 *data = rx_buf[rx_read]; rx_read = (rx_read + 1) % RX_BUF_SIZE; return 1; }这种实现在中断里只用赋值和取模,速度足够快。主循环或某个任务去调用uart_get_byte就能把收到的数据抠出来,不会阻塞中断,也不会丢数据。不过取模操作在F103这种Cortex-M3上还好,如果换成更小的内核DMA或用位与运算替代会更快,这是后话。
4.4 带状态机的串口协议解析
有缓冲区之后,就可以在应用层做协议解析了。最简单的做法是约定帧格式:帧头0xAA、帧尾0x55、中间是数据字节。主循环里从缓冲区取字节,用状态机逐字节判断当前状态,比如STATE_WAIT_HEAD、STATE_RECEIVE_DATA、STATE_WAIT_TAIL。状态机的好处是不需要一次性凑齐整帧,来一个字节处理一个字节,特别适合串口这种流式数据。
typedef enum { STATE_WAIT_HEAD, STATE_RECEIVE_DATA, STATE_WAIT_TAIL } FrameState; FrameState state = STATE_WAIT_HEAD; uint8_t process_frame(uint8_t byte) { static uint8_t data[8]; static uint8_t len = 0; switch (state) { case STATE_WAIT_HEAD: if (byte == 0xAA) { state = STATE_RECEIVE_DATA; len = 0; } break; case STATE_RECEIVE_DATA: data[len++] = byte; if (len >= sizeof(data)) state = STATE_WAIT_TAIL; break; case STATE_WAIT_TAIL: if (byte == 0x55) { // 一帧完整,处理data数组 state = STATE_WAIT_HEAD; return 1; } state = STATE_WAIT_HEAD; // 帧尾不对,重新等帧头 break; } return 0; }5. 高级用法和扩展思路
5.1 用DMA收发释放CPU
如果串口数据量很大,比如往TF卡写日志或从SD卡回传文件,中断模式也会频繁打扰CPU。此时可以把发送、接收数据搬移的任务交给DMA。寄存器层面就是配合DMA控制器的通道配置,以及USART的CR3里的DMAT和DMAR位。不展开逐条配置,但思路很清晰:你只要告诉DMA“目标地址是USART的DR寄存器,数据长度是N字节,方向是从内存搬到外设”,剩下的搬运工作DMA自己完成。
很多人担心DMA难学,其实DMA本身的寄存器也是类似套路:使能外设时钟、配置通道、配置源地址和目的地址、配置数据长度、启动传输。一旦你理解了外设寄存器的套路,DMA只是同一套方法论在不同模块上的应用。
5.2 9位数据模式和奇偶校验
有些协议,比如Modbus RTU,用8位数据位加偶校验,等价于9位模式。寄存器层面的操作是CR1里的M位置1,使能PCE位。此时DR寄存器里的数据是9位有效,但F103的DR寄存器写入和读取时要格外小心第9位的处理,具体是用CR1的PS位表示校验位,或者是利用DR的bit8存放。这属于进阶内容,我建议先把8N1通信跑利索了再玩它。
6. 串口调试避坑血泪史
6.1 乱码的常见原因
乱码是最常见的现象,我第一次调串口就遇到。排查顺序要沿着信号链路走:先看波特率是否匹配,再看时钟配置是否弄错——比如外部晶振8MHz还是内部HSI 8MHz,有没有开PLL倍频到72MHz,这一步错了波特率全偏;然后检查串口助手和你的程序是不是8位数据、1位停止位、无校验;最后检查TX和RX有没有接反。串口模块上TX对TX,那肯定没数据,但很多人就是会犯这个低级错误。
6.2 只能发送不能接收
能发送说明波特率、使能配置都没问题,问题多半在接收侧。优先检查引脚方向是否配成输入、是否开复用、CR1里的RE位有没有置1。还有,PA10如果你开了上拉,但外部设备是推挽输出,一般没问题;可你要是把PA10配置成推挽输出模式,那接收引脚直接被内部驱动拉死了,什么都收不到,这个坑我犯过。
6.3 为什么程序跑飞或死循环
如果发送字符串的代码块死循环,卡死在等待TXE,而且听不到任何输出,先别怀疑串口硬件,用调试器看RCC->APB2ENR里USART1EN有没有真正被置上,再看GPIO的AFIO复用有没有开。另一个隐藏杀手是串口中断服务函数里没清标志,导中断不断进入,表现为主程序跑不动。
6.4 快速定位技巧:GPIO电平法
有个不用串口助手的土办法:把发送引脚当数字IO拉起或拉下,如果你的USB转串口模块灯有反应,说明线路连接没问题。但如果程序不死循环,只是没波形,再用逻辑分析仪或者示波器去看USART引脚有没有波形。没有示波器时,也可以用杜邦线短接TX和RX做自发自收,能收到自己的数据就说明串口模块回环通路OK,问题出在对端设备上。
6.5 别忘记参考电压域
F103的USART1属于APB2域,还要注意到有些开发板只有3.3V的IO电平,如果你的USB转串口模块是5V的TTL电平,板子上的RX/TX可能扛不住5V高压,长期使用会烧引脚。选3.3V对应的转接模块,或者加电平转换芯片。
最后我再分享一点心得:寄存器写串口,最大的价值不是“不用HAL库很酷”,而是你在配置过程中建立了对时钟、引脚、寄存器、中断、状态标志的完整认知。这套认知迁移到SPI、I2C、DMA上都通用。我后来看别人代码里的HAL串口卡死问题,一两分钟就能猜出大概是哪个标志没查,靠的就是当年一点点翻寄存器练出来的手感。
下一章我打算讲讲定时器。定时器比串口更抽象,里面有预分频器、自动重装载、PWM输出、输入捕获这些玩意儿,但只要你把串口这套“查手册、理结构、分步骤配置”的方法带过去,会发现一切也不过如此。先动手把这一章的串口例程跑通,有问题随时在调试中多看看SR寄存器的值,很多疑惑会自己解开。