串口基础与轮询模式:USART协议详解、波特率计算、轮询发送/接收实现、阻塞式通信的痛点分析
各位同学,咱们今天聊聊串口通信的基础。说实话,串口这东西,看起来简单,但很多坑都藏在细节里。我做了这么多年嵌入式,见过太多人在波特率配置上翻车,或者在轮询模式下把CPU活活卡死。咱们今天就把这些底层的门道彻底讲透。
USART协议:到底在传什么?
USART的全称是Universal Synchronous/Asynchronous Receiver/Transmitter。说白了,就是一种串行通信协议。什么叫串行?就是数据一位一位地传,不像并口那样一次传8位。你想想看,串口只需要两根线(TX和RX),就能实现全双工通信,这在硬件设计上非常节省IO资源。
USART的帧结构是这样的:
- 起始位:1位,逻辑低电平,告诉接收端“我要开始发数据了”
- 数据位:通常8位或9位,LSB(最低有效位)先发
- 校验位:可选,奇校验或偶校验
- 停止位:1位、1.5位或2位,逻辑高电平
关键点:串口通信的双方必须约定完全相同的帧格式——数据位、校验位、停止位、波特率,缺一不可。我曾经在调试一个工业设备时,发现两边波特率差了0.5%,结果每100个字节就错1个,排查了整整两天。
这里我画了一张USART帧结构的示意图,帮你直观理解数据是怎么在线上流动的:
波特率计算:别让时钟误差吃掉你的数据
波特率,就是每秒传输的符号数。对于USART来说,1个符号就是1位,所以波特率就等于比特率。但这里有个关键问题:波特率是怎么从系统时钟算出来的?
STM32的USART波特率计算公式如下:
Tx/Rx 波特率 = f_CK / (16 × USARTDIV)
其中:
- f_CK:USART外设时钟频率
- USARTDIV:一个16位的分频系数,存放在BRR寄存器中
- 实际USARTDIV = DIV_Mantissa + DIV_Fraction / 16
举个例子,假设f_CK = 72 MHz,我们想要115200 bps:
USARTDIV = 72,000,000 / (16 × 115200) = 39.0625
整数部分:39 (0x27)
小数部分:0.0625 × 16 = 1 (0x1)
BRR寄存器值 = (39 << 4) | 1 = 0x0271
我的经验:实际配置时,我习惯先算理论值,再用逻辑分析仪抓实际波形验证。因为有些STM32型号的APB时钟树比较复杂,USART挂在不同总线上,时钟源可能不一样。我踩过最大的坑就是——USART1挂在APB2上(72MHz),USART2挂在APB1上(36MHz),结果用同一个BRR值,一个能跑一个乱码。
常见的波特率误差容忍范围是±2%。如果误差超过这个范围,接收端就可能采样到错误的位。下面这张表列出了常用波特率在不同时钟下的误差情况:
| 目标波特率 | f_CK=72MHz | 误差 | f_CK=36MHz | 误差 |
|---|---|---|---|---|
| 9600 | 468.75 | 0.00% | 234.375 | 0.00% |
| 19200 | 234.375 | 0.00% | 117.1875 | 0.00% |
| 115200 | 39.0625 | 0.00% | 19.53125 | 0.16% |
| 256000 | 17.578125 | 0.16% | 8.7890625 | 0.16% |
| 921600 | 4.8828125 | 0.16% | 2.44140625 | 0.16% |
注意:当USARTDIV的小数部分不是0.0625的整数倍时,就会产生量化误差。比如921600 bps在72MHz下,USARTDIV=4.8828125,小数部分0.8828125×16=14.125,只能取14,实际分频系数变成4.875,实际波特率≈923076,误差约0.16%。这个误差在可接受范围内,但如果你的系统时钟本身就不准(比如用了内部RC振荡器),误差会叠加,后果很严重。
轮询发送/接收实现:最直接的方式
轮询模式,说白了就是CPU死等。发送一个字节,等发送完成标志位;接收一个字节,等接收非空标志位。代码写起来很直观:
// USART轮询发送一个字节
void USART_SendByte(USART_TypeDef* USARTx, uint8_t data)
{
// 等待发送数据寄存器为空
while (!(USARTx->SR & USART_SR_TXE));
// 写入数据寄存器
USARTx->DR = data;
// 等待发送完成(可选,用于RS485等需要确认发送完毕的场景)
// while (!(USARTx->SR & USART_SR_TC));
}
// USART轮询接收一个字节
uint8_t USART_ReceiveByte(USART_TypeDef* USARTx)
{
// 等待接收数据寄存器非空
while (!(USARTx->SR & USART_SR_RXNE));
// 读取数据寄存器
return (uint8_t)(USARTx->DR & 0xFF);
}
// 发送字符串
void USART_SendString(USART_TypeDef* USARTx, const char* str)
{
while (*str)
{
USART_SendByte(USARTx, *str++);
}
}
这段代码看起来简单吧?但实际项目中,我从来不会直接这么用。为什么?因为while (!(USARTx->SR & USART_SR_TXE));这一行,就把CPU死死卡住了。如果对方没接、线路断了、或者对方处理速度慢,你的程序就永远停在这里。
阻塞式通信的痛点分析:为什么轮询模式让人头疼
咱们来掰扯掰扯轮询模式的几个致命问题:
- CPU利用率极低:发送1个字节在115200波特率下大约需要87μs,这87μs里CPU除了等什么也干不了。如果你要发1KB数据,CPU就被占用了近90ms。
- 无法处理多任务:在轮询接收时,如果对方长时间不发数据,你的程序就卡在
while (!(USARTx->SR & USART_SR_RXNE));。这时候任何中断都响应不了(除非你开了中断嵌套,但轮询本身就不该和中断混用)。 - 数据丢失风险:如果接收数据的速度比CPU处理速度快,而你又没有及时读取DR寄存器,新的数据会覆盖旧数据,造成丢帧。我在一个数据采集项目中就吃过这个亏——传感器以1MHz的速率往外吐数据,我用轮询去读,结果读到的全是乱码。
- 实时性无法保证:轮询模式下,CPU的响应时间完全取决于主循环的调度。如果主循环里还有其他耗时的操作,串口数据的响应延迟就会变得不可预测。
一句话总结:轮询模式只适合两种场景——一是系统极其简单,没有任何其他任务;二是调试阶段,临时验证硬件是否正常。但凡你的系统里还有别的活要干,就别用轮询。