如果你是从第一章老老实实看到这里的,应该已经习惯了把寄存器当成一块块可以直接拨动的开关,而不是只能照着库函数抄的黑盒。前几章驱过GPIO、配过外部中断、折腾过定时器,现在到第八章,正好接上通信外设的第一课:USART串口。STM32F103的USART是开发板上最常用的外设之一,它能把芯片内部的字节变成一根线上的电平变化,也能从线上把别人的电平重新读成字节。这一章用白话把串口涉及的寄存器、波特率、收发流程讲透,配一套寄存器版例程,适合已经把定时器玩明白、想开始跟外部设备对话的人。
串口能做什么?开发阶段接一个USB转TTL模块,往电脑上打日志;量产后跟蓝牙模组、GPS模组、显示屏或者另一颗单片机做低速通信;很多产品还把串口当作固件升级入口。可以说,SPI、I2C、CAN这些总线里,串口是上手门槛最低、却最不可或缺的一个。不少新手觉得“用库函数不就行了吗”,可真遇到乱码、丢字节、中断不触发的时候,不懂寄存器只能干瞪眼。这也是我把串口定为第八章的原因:它需要的背景知识很纯粹,却能把前面学的寄存器基本功全部串起来。
1. 这一章解决什么问题
1.1 串口是整个调试链路的地基
很多项目里,串口承担的角色不是“锦上添花”,而是“保命通道”。程序跑到哪一步、传感器读数异常、某个外设没有应答,都要靠一条串口线把信息送出来。我在实际项目里几乎默认所有板子都预留一组串口引脚,哪怕产品定型后不一定用到,调试阶段也必须有。这个习惯帮我省了太多拆壳、接线、反复烧录的时间。
串口通信在STM32F103上主要由USART外设完成,名字里的U代表Universal,S代表Synchronous/Asynchronous,A代表Asynchronous,简写里的A和S组合表示它既能异步也能同步,但绝大多数场景只用异步模式。异步意味着收发双方没有一根共享时钟线,全靠约定的波特率在时间轴上对齐每秒传输多少个bit。这也是它硬件上只需要TX、RX两根线的原因,比SPI、I2C省引脚,又比CAN简单直接。
1.2 白话版USART原理
把串口想成写信:空闲时TX线保持高电平,相当于“没事干”;要发一个字节时,先把线拉低一个bit的时间,这就是起始位,相当于喊一句“注意,我要开始写了”;接着依次发送8个数据位,从最低位开始,相当于信件正文;最后再把线拉高一bit时间,这是停止位,相当于“写完了,你可以喘口气”。接收方按同样的时间节奏,在每一位中间采样电压高低,就能还原出0和1。
STM32F103的USART在接收时通常用16倍过采样,也就是每个数据bit会被采样16次左右,硬件会倾向于取中间的采样值,降低误码率。这也是为什么通信双方波特率必须一致,差一点点也许一两帧没问题,连续传输几百个字节后,采样点会逐渐偏移到bit边缘甚至掉到隔壁bit里,最终表现为乱码。常用的8N1格式就是1个起始位、8个数据位、无校验、1个停止位,一帧总共10个bit。按9600波特率计算,每位约104微秒,发一个字节大约1.04毫秒,这个数字以后排查耗时问题会用到。
1.3 路径规划:芯片手册告诉我们的三条线索
配置任何外设之前,先查三件事:时钟、引脚、寄存器。USART1挂在APB2总线上,最高可以到72MHz;USART2和USART3挂在APB1总线上,最高36MHz。默认情况下USART1的收发引脚是PA9和PA10,不需要重映射就不用开AFIO时钟,但必须把GPIOA时钟打开。如果需要把USART1挪到PB6、PB7,那就得额外使能AFIO时钟并配置重映射寄存器,这一步最容易漏。
寄存器方面,USART1基地址是0x40013800,几个关键寄存器都在里面:SR状态寄存器、DR数据寄存器、BRR波特率寄存器、CR1/CR2/CR3控制寄存器。整体思路是先翻参考手册的功能框图,把外设看成一个带输入和输出的加工机器,再去操作它的开关和指示灯。库函数只是包装了这些过程,寄存器方式则要求你每一步都知道自己在设置什么,排查问题时能直接对着手册找原因。
2. 白话拆解四个关键寄存器
2.1 SR状态寄存器:不要再看错TXE和TC
SR寄存器是串口状态的“红绿灯”,很多新手一开始就在这里翻车。它里面有TXE和TC两个发送相关位,TXE是发送数据寄存器为空,表示你可以往DR写入下一个字节;TC是发送完成,表示当前字节已经从移位寄存器完全移出,TX引脚已经恢复空闲电平。区别在于,DR把数据收下后,硬件还要靠移位寄存器一位一位往外送,这个过程TXE是管不到的。
所以连续发送一串数据时,每发一个字节只等TXE是够用的,但最后一个字节发完后如果马上关串口、拉低引脚或者进低功耗,很可能帧没完整发出去。稳妥做法是循环里等TXE,结束后额外等一次TC,确保最后一个字节真的从引脚上出去了。接收方向要看RXNE,它表示接收数据寄存器非空,读到1就可以去DR取数据。还有一个ORE是溢出错误,硬件接收速度比软件取走数据快,会把旧数据覆盖掉,这时不能只读DR,得先读SR再读DR,让硬件知道你已经处理过错误状态。
2.2 BRR波特率寄存器:计算公式并不吓人
波特率寄存器BRR,核心就是写入一个分频值,让外设时钟经过分频后得到目标波特率。公式很简单:USARTDIV = 外设时钟 / (16 × 波特率)。以72MHz的USART1配9600波特率举例,USARTDIV = 72000000 / (16 × 9600) = 468.75。这个值拆成整数部分和小数部分,整数468写成十六进制是0x1D4,小数0.75要乘以16得到12,写成十六进制是0xC。BRR寄存器高12位存整数,低4位存小数,于是写入0x1D4C。
再看一个常见配置115200,USARTDIV = 72000000 / (16 × 115200) = 62.5,整数部分是62,对应0x3E,小数部分是8,写入的BRR就是0x3E8。由于小数部分只有4位,精度是1/16,计算出来的实际波特率与目标波特率误差通常远小于串口通信允许的容差,可以放心用。需要注意,如果改成8倍过采样,公式分母会从16变成8,但STM32F103默认是16倍过采样,先把标准情况玩明白再说。
2.3 CR1、CR2、CR3:控制寄存器的操作顺序
CR1是主控制寄存器,UE是总开关,写入1才让USART工作;TE和RE分别是发送使能和接收使能;RXNEIE是接收中断使能,置1后收到数据会触发USART1中断;M位控制数据字长,0表示8个数据位,1表示9个数据位,默认8N1模式不需要动它;PCE是校验使能,默认0。CR2里最重要的是STOP位,两位组合控制停止位数量,默认00是一位停止位,最常用。CR3平时可以保持全零,只有用到硬件流控、红外模式或单线半双工时才需要设置。
操作顺序也值得单独说。我的初始化习惯是先把CR1整个清零,相当于对外设做软复位,再写BRR波特率,再配CR2和CR3,最后才打开UE、TE、RE。这样做是为了避免在半配置状态下外设就开始工作,比如UE没开但TE已经打开,或者波特率寄存器还是复位值8MHz分频,万一此时线上恰有抖动,接收逻辑可能误判起始位,把错误数据收进来。先关后配再开,顺序看起来不起眼,却很能减少随机乱码问题。
2.4 DR数据寄存器:读和写共用一个地址
DR寄存器是串口的“窗口”,发送数据就向它写入,接收数据就从它读出,所以在硬件层面它其实分成了发送数据寄存器和接收数据寄存器,但软件访问地址是同一个。这跟普通可读可写的寄存器不一样,你会发现在DR里写一个值再读回来,读到的不是刚才写的内容,而是当前接收移位电路拿到的数据,甚至可能什么都没收到。别因此怀疑芯片坏了,这是设计如此。
使用DR之前必须确认方向没问题:发送前查SR的TXE是否置1,接收前等RXNE置1。在8N1模式下DR的低8位是有效数据,高9位基本用不到。有人习惯直接读DR然后与0xFF做位与,是为了把多余的高位滤掉,代码上更严谨。另一个容易被忽略的点是:读取DR本身会清除RXNE标志,但在某些错误标志存在的情况下,需要先读SR再读DR才能完整清除状态,所以中断处理函数里我习惯先判断SR里的RXNE位,然后再取DR,顺序不要反。
3. 搭一个寄存器版USART1工程
3.1 硬件准备和引脚分配
我手头用的是一块最普通的STM32F103最小系统板,USB转TTL模块也是常见的小板子,接线方式很固定:PA9接到模块的RXD,PA10接到模块的TXD,两边GND必须连在一起。注意是交叉接,如果PA9也接到TXD、PA10也接到RXD,那实际是自己发给自己,电脑端什么都收不到。接完先检查USB转TTL模块的电平标准,早期不少模块是5V电平,跟3.3V单片机通信有风险,更推荐选3.3V电平的模块,或者确认模块带电平转换电路。
串口调试时最容易忽略的是共地。模块和开发板各插各的USB口,电脑USB地线理论上也是通的,但有些劣质供电环境会有地电位差,表现出来就是偶尔乱码、掉字节。最好的习惯是模块的GND和板子的GND用杜邦线直接连起来,再去做后续排查。硬件连接对了,大部分乱码问题还没开始就少了一半。
3.2 GPIO与时钟的寄存器配置
用寄存器方式配置GPIO,先看USART1默认引脚PA9、PA10在GPIOA的CRH寄存器里,CRH控制8到15号引脚。PA9在CRH中占4位,PA10占紧接着的4位。发送脚PA9要配成复用推挽输出,方向50MHz,所以MODE位是11,CNF位是10,整个4位组合起来是1011,十六进制就是0xB。接收脚PA10配成输入,并且选上拉方式,MODE是00,CNF是10,4位组合起来是1000,十六进制就是0x8。为什么接收要上拉?因为串口空闲时是高电平,输入阻抗状态下如果线上悬空,容易受干扰误触发起始位,加上拉能让空闲电平稳定。
开启时钟时,需要给RCC_APB2ENR的IOPAEN位和USART1EN位都写1,这两个使能位都在同一个寄存器里,一条或运算就能同时打开。还要把GPIOA_ODR的第10位置1,让PA10的上拉电阻真正生效。这部分代码可以用一组宏定义来写,把寄存器地址提着走,后面配置波特率和控制寄存器时也不用东拉西扯。
3.3 USART初始化代码
初始化代码分两段,第一段做时钟和引脚,第二段做USART配置。时钟引脚配置完成后,先把CR1清零,然后写BRR为0x1D4C,对应72MHz时钟和9600波特率。接着CR2写0,表示1位停止位,CR3写0,先不管流控和红外。最后CR1置位UE、TE、RE,也就是第13位、第3位、第2位。这样串口就处于可收可发的状态。
#define RCC_APB2ENR (*(volatile unsigned int*)0x40021018) #define GPIOA_CRH (*(volatile unsigned int*)0x40010804) #define GPIOA_ODR (*(volatile unsigned int*)0x4001080C) #define USART1_SR (*(volatile unsigned int*)0x40013800) #define USART1_DR (*(volatile unsigned int*)0x40013804) #define USART1_BRR (*(volatile unsigned int*)0x40013808) #define USART1_CR1 (*(volatile unsigned int*)0x4001380C) #define USART1_CR2 (*(volatile unsigned int*)0x40013810) #define USART1_CR3 (*(volatile unsigned int*)0x40013814) void uart1_init(void) { RCC_APB2ENR |= (1 << 2) | (1 << 14); GPIOA_CRH &= ~(0xFF << 4); GPIOA_CRH |= (0xB << 4) | (0x8 << 8); GPIOA_ODR |= (1 << 10); USART1_CR1 = 0; USART1_BRR = 0x1D4C; USART1_CR2 = 0; USART1_CR3 = 0; USART1_CR1 |= (1 << 13) | (1 << 3) | (1 << 2); }发送函数看起来也不复杂。常见错误是在发送最后一段时只等TXE,结果停在循环里没等TC,主程序已经跑去做别的事,最后一帧在线上其实只发了一半。我会在单字节发送函数里等TC,简化调用逻辑;在字符串发送函数里,循环内等TXE,循环结束后等一次TC。
void uart1_send_byte(unsigned char ch) { while (!(USART1_SR & (1 << 7))); USART1_DR = ch; while (!(USART1_SR & (1 << 6))); } void uart1_send_string(const char *s) { while (*s) { while (!(USART1_SR & (1 << 7))); USART1_DR = *s++; } while (!(USART1_SR & (1 << 6))); }3.4 中断接收与环形缓冲
串口接收用轮询也可以,但项目里总要处理“数据随时可能来”的场景,中断是更通用的方案。接收中断的使能位在CR1的RXNEIE,第5位。另外不要忘记NVIC,USART1的中断号是37,对应NVIC的ISER[1]寄存器第5位。很多人配好了外设寄存器、中断标志也有了,但就是进不了中断函数,原因多半是没开NVIC。
中断函数里最忌讳处理太多逻辑,我习惯只做一件事:把读到的字节塞进环形缓冲区。环形缓冲区用两个下标,写索引head和读索引tail,入队前判断下一个位置是否等于tail,等于则说明缓冲区满了,这次数据宁可丢弃也不要覆盖未读数据。主循环通过uart1_get_char从队列取数据,再去做协议解析或业务逻辑。这样中断占用时间极短,再快的连续接收也不容易丢帧。
#define RX_BUF_SIZE 64 static volatile unsigned char rx_buf[RX_BUF_SIZE]; static volatile unsigned int rx_head = 0; static volatile unsigned int rx_tail = 0; void USART1_IRQHandler(void) { if (USART1_SR & (1 << 5)) { unsigned char ch = (unsigned char)(USART1_DR & 0xFF); unsigned int next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { rx_buf[rx_head] = ch; rx_head = next; } } } unsigned char uart1_get_char(unsigned char *ch) { if (rx_head == rx_tail) return 0; *ch = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; return 1; }初始化中断接收时,在uart1_init末尾补两行:使能CR1的第5位,然后使能NVIC。NVIC_ISER1地址是0xE000E104,给第5位置1。这里要用或运算而不是直接赋值,否则会清掉其他中断的使能状态,等后期外设多了可能引发诡异问题。
#define NVIC_ISER1 (*(volatile unsigned int*)0xE000E104) USART1_CR1 |= (1 << 5); NVIC_ISER1 |= (1 << 5);4. 调试实录:我踩过的坑与排查套路
4.1 乱码一定先看这三个点
乱码是最常见的串口问题,表现为电脑串口助手收到的字符不是预期内容,或者成了各种“口口”、花符号。先别急着改代码,按顺序查三处:波特率是否一致、系统时钟频率是否和你计算BRR时用的一致、USB转TTL模块和板子是否共地。
波特率不一致很好理解,我见过不少人在串口助手选9600,但代码里初始化的是115200,两边差一点点,偶尔能出来开头几个字符,后面全乱。系统时钟问题更隐蔽,有人用了外部晶振但代码还按内部8MHz配置,或者芯片内置HSE被旁路,实际频率和预期差很多,就算BRR写对了波特率也是错的。一条实用的排查路是:先只发一个0x55,也就是二进制01010101,用逻辑分析仪看TX引脚实际方波周期,反推出真实波特率,哪边不对一下就看出来了。
| 现象 | 可能原因 | 优先检查点 |
|---|---|---|
| 乱码 | 波特率不匹配 | 串口助手和代码里的波特率是否一致 |
| 乱码 | 系统时钟频率不对 | 实际主频是否等于BRR计算时使用的频率 |
| 乱码 | 地电位差 | USB转TTL模块与板子GND是否连接 |
| 乱码 | TX/RX接错 | 接成同向连接,数据只发不收 |
4.2 发送不出去和只能收的问题
发送不出去,先分清是硬件问题还是软件问题。软件层面,检查RCC_APB2ENR是否打开了USART1EN,检查CR1的TE位是否为1,检查GPIOA_CRH是否把PA9配成了复用推挽。硬件层面,用万用表量一下PA9空闲时是不是高电平,如果是0V,要么引脚方向配置成了输入,要么焊接短路,要么电源没供上。
只收不能发是另一个高频问题。思路缩小到TX链路:USB转TTL模块的RXD是否接到了PA9,注意模块上的RXD和单片机TX交叉,模块的TXD接到单片机RX。还有串口助手软件的发送区编码、回车换行设置等,都会影响看起来是否“法发”。我自己的排查顺序很固定,先在main里不停发送0x55,用逻辑分析仪看PA9有没有波形,如果没有,把串口初始化和GPIO寄存器逐个打印出来对照手册,基本能定位。如果波形正确但电脑收不到,再查模块线和串口助手设置。
4.3 中断触发不了的排查顺序
中断接收不来,通常不是单一原因,顺着链路查一遍基本都能解决。第一条检查NVIC是否使能了USART1中断,我见过代码里写了外设中断标志但NVIC完全没配,ISER寄存器全零。第二条检查CR1的RXNEIE是否置1,这个位控制着RXNE标志能否向内核发出中断请求。第三条确认RE位是1,接收功能本身都没打开,线上来多少数据也没用。
还有一类情况是中断函数确实进了,但出不来。原因多半是中断标志没有清除,RXNE标志需要通过读SR再读DR来清除,如果在中断里只做了别的操作,没有真正读DR,那这个标志会一直为1,产生无穷中断。我的中断代码里固定是“先检查SR、再读DR”,既能把数据取走,也能把标志清干净。如果项目里还有更高优先级的其他中断在频繁抢占,串口中断一直等不到调度,也要回头检查NVIC优先级分组和抢占优先级设置。
4.4 调试器暂停时可能会误导你的标志位
用调试器在线调试串口,会发现一个奇怪现象:程序停在断点处时,串口助手上还能收到一些数据,或者SR某些位看起来不太对劲。这是因为Cortex-M3内核暂停时,外设时钟通常还在继续跑,硬件该收的收、该发的发,只是没有软件去处理而已。所以你在断点看到的寄存器值,可能是“很久以前”的值,也可能是已经被硬件更新但软件还没反应过来的值,不能简单当成当前程序状态。
真正理解程序运行过程,我更推荐用逻辑分析仪或示波器看引脚波形,再结合串口助手的打印日志来判断。如果一定要在线调试,别在收发循环里单步跑,容易看到TXE、TC翻转得太快而误判。有个小技巧:在初始化完成后先让串口固定输出一串字符,电脑端能看到完整输出,再往后加调试断点,这样能把外设初始化和业务逻辑的问题隔离开。
5. 让串口代码更好用的几个习惯
5.1 把串口当成一个独立模块来设计
寄存器裸写不是终点,而是为了提高排查能力。等你能用自己的代码把串口跑通,下一步建议把uart1_init、uart1_send_byte、uart1_send_string、uart1_get_char封装成一个独立模块,对外只暴露几个干净的接口。这样上层应用完全不用关心寄存器细节,换到USART2或USART3时,只需要改模块内部的基地址、时钟位和引脚配置,业务代码一行都不用动。
模块内部还可以顺便定义波特率枚举、数据位、停止位这些参数,避免散落一堆魔法数字。实测下来,模块化设计对调试效率的提升很明显,尤其是后面要加DMA、低功耗串口唤醒时,只需要封闭变化点,而不是把所有调用串口的代码翻个底朝天。这个习惯比过早追求高级框架有用得多。
5.2 我的串口调试习惯与后续扩展
我在实际项目中固定下来的串口调试流程,可以给参考。第一步,贴上串口初始化绑定到系统时钟,把所有时钟配置先稳定下来;第二步,只发一个0x55测试波形,验证波特率和硬件链路;第三步,用环形缓冲接上中断接收,在电脑端发什么回显什么,确认双向通路;第四步,再往上叠业务协议,比如给数据包加帧头、校验和、超时重发。每走一步都用串口助手和数据对比验证,出现问题能立刻缩小范围。
后续扩展方向也不难,串口配DMA可以大幅度降低CPU负担,适合大批量接收数据;多串口同时使用可以各自维护独立环形缓冲;想实现一个简单的命令解析器,可以在主循环从接收缓冲区读字节并切割出完整帧。等把本章的寄存器配置真正吃透,再回头看参考手册的USART章节,你会发现原来那些密密麻麻的寄存器位都变得理所应当了。串口是整个嵌入式通信体系的入口,这里稳住了,后面学SPI、I2C都会顺很多。