嵌入式必学:USART串口寄存器从基础配置到乱码排查
写这个系列的第五章之前我其实犹豫了很久。串口太“基础”了基础到网上随便一搜就是一大把教程基础到很多人觉得它不值得单独开一章。但恰恰是这种“基础”外设最容易让人在寄存器层面栽跟头。前面几章我们把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可能是你调试串口时看得最多的寄存器它的每个位都在告诉你硬件当前的脸色。这里面有四个位建议你闭着眼睛都能背出来位名称含义7TXE发送数据寄存器空可以往DR里写新数据6TC发送完成当前字符已经全部送出5RXNE接收数据寄存器非空DR里有一个新收到的字节3ORE过载错误新数据到的时候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则决定了串口死没死、能不能发、能不能收。位名称作用13UE串口总使能相当于总电闸12M字长选择0表示8数据位1表示9数据位10PCE校验使能0表示无校验5RXNEIE接收数据非空中断使能6TCIE发送完成中断使能3TE发送使能2RE接收使能配置顺序是个经常被忽略的坑一定要先设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目标波特率USARTDIVBRR8 MHz1152004.34030x4572 MHz11520039.06250x27172 MHz9600468.750x1D4C36 MHz9600234.3750xEA6看到没只有一部分时钟和波特率组合能整除其他组合都会有误差。误差在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位置1USART1-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挂在APB2USART2和USART3挂在APB1。APB1和APB2的最高频率不一样分频系数也经常不一样如果你把同一份BRR代码从APB2的外设搬到APB1的外设波特率必然出错。举个例子你把BRR0x45这套配置从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 引脚重映射和AFIOUSART1的默认引脚是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会非常熟悉因为它们都是同一套骨架功能USARTSPII2C波特率/速率BRRBRRCCR控制寄存器CR1CR1CR1状态寄存器SRSRSR数据寄存器DRDRDR错误标志OREMODFAFSPI多了时钟极性和相位的概念I2C多了一个总线忙状态和ACK机制但“查状态位再读写数据寄存器”的核心逻辑完全没变。你会发现在USART上练出来的那套操作节奏换个外设照样能用这也是我为什么坚持让你从寄存器入手学外设。6.3 一点个人体会很多人觉得现在HAL库都这么成熟了还学寄存器是不是有点倒退。我的看法恰恰相反寄存器不是让你在生产代码里疯狂手搓而是让你在HAL行为异常时能往下钻一层搞清楚它到底在干什么。串口这个外设协议简单、寄存器直观、波形可测是练习这项能力成本最低的地方。最后再分享一个小技巧调试串口寄存器代码时准备一支逻辑分析仪每次改一个寄存器配置就抓一次波形把“寄存器值”和“物理波形”一一对应起来看。比如改一下BRR看波形间隔有没有变化改一下CR1的M位看帧长度有没有变化。这样做上三五次你对串口的理解深度会远超那些只会调库的人。