2. UART基础与轮询驱动:从协议帧到STM32实战
UART,说白了就是嵌入式世界里最古老也最通用的串行通信方式之一。我刚开始做嵌入式那会儿,调试手段匮乏,全靠UART往PC上打印日志。直到现在,我手头每个项目板子上至少留一个UART口——你永远不知道什么时候需要它来救命。
这一章我们先把UART的协议帧格式掰开揉碎,然后讲波特率怎么算,最后用STM32的轮询模式把收发跑通。嗯,都是基本功,但基本功不扎实,后面DMA和中断模式就容易翻车。
2.1 UART协议帧格式
UART是异步串行通信,没有时钟线。收发双方靠约定的波特率和对帧格式的共识来同步。一个完整的UART帧长这样:
UART帧结构(从左到右发送顺序):
起始位(1bit) → 数据位(5~9bit) → 校验位(0/1bit) → 停止位(1/1.5/2bit)
我画了一张帧结构图,你一看就明白:
2.2 各字段详解
起始位
起始位固定为低电平(逻辑0),持续一个bit时间。接收方检测到TX线从高变低,就知道一帧开始了。为什么一定要有起始位?因为UART没有时钟线,接收方必须靠这个电平跳变来同步自己的采样时钟。
数据位
数据位可以是5、6、7、8或9位。最常见的是8位数据位,正好对应一个字节。发送顺序是LSB(最低有效位)在前,MSB(最高有效位)在后。你想想看,如果发0x55(二进制01010101),线上先看到的是最低位的1,然后0、1、0、1、0、1、0。
我的习惯:除非有特殊需求(比如7位ASCII),一律用8位数据位。兼容性最好,代码也最简洁。
校验位
校验位用于简单的错误检测,可选奇校验或偶校验。奇校验要求包括校验位在内的所有1的个数为奇数;偶校验则为偶数。举个例子:
| 数据 (0x55) | 1的个数 | 奇校验位 | 偶校验位 |
|---|---|---|---|
| 01010101 | 4 (偶数) | 1 (使总数为奇数) | 0 (保持偶数) |
我曾经踩过的坑:有一次调试一个老设备,对方要求奇校验,我配置成了偶校验。结果数据能发出去,但接收方一直报错。查了整整半天才发现是校验位配反了。所以,校验位配置一定要跟对方确认清楚,别想当然。
停止位
停止位固定为高电平(逻辑1),长度可以是1、1.5或2个bit时间。停止位的作用是给接收方留出处理时间,同时保证帧间有明确的高电平间隔。大多数场景用1位停止位就够了,如果通信环境噪声大,可以选2位停止位增加容错。
2.3 波特率计算
波特率就是每秒传输的符号数,单位是bps(bits per second)。对于UART来说,一个符号就是一个bit。所以波特率直接决定了每个bit的持续时间:
bit时间 = 1 / 波特率
例如:115200 bps → 每个bit持续约8.68微秒
STM32的UART波特率由外设时钟和波特率寄存器(USART_BRR)共同决定。公式如下:
波特率 = f_CK / (16 × USART_DIV)
其中:
f_CK = UART外设时钟频率(如APB2时钟)
USART_DIV = USART_BRR寄存器的值(整数部分+小数部分)
举个例子,如果f_CK = 72 MHz,目标波特率115200:
USART_DIV = 72,000,000 / (16 × 115200) = 39.0625
整数部分 = 39 (0x27)
小数部分 = 0.0625 × 16 = 1 (0x1)
USART_BRR = (39 << 4) | 1 = 0x271
实际项目中,我们很少手动算这个。STM32的HAL库和CubeMX会自动帮你算好。但理解原理很重要——万一哪天你用的芯片没有自动计算工具呢?
2.4 STM32 UART轮询模式实现
轮询模式是最简单的UART收发方式。CPU不断检查状态寄存器,看数据是否发送完成或是否有数据到达。说白了就是“死等”。
初始化配置
我用STM32F103系列举例,使用USART1,波特率115200,8N1(8数据位、无校验、1停止位)。
void UART_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
USART_InitTypeDef USART_InitStructure;
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
// 2. 配置GPIO:TX(PA9)推挽复用输出,RX(PA10)浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 3. 配置UART参数
USART_InitStructure.USART_BaudRate = 115200;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART1, &USART_InitStructure);
// 4. 使能UART
USART_Cmd(USART1, ENABLE);
}
轮询发送
发送一个字节:检查发送数据寄存器空标志(TXE),空则写入数据。
void UART_SendByte(uint8_t data)
{
// 等待发送数据寄存器为空
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
// 写入数据
USART_SendData(USART1, data);
}
void UART_SendString(char *str)
{
while (*str)
{
UART_SendByte((uint8_t)(*str++));
}
}
轮询接收
接收一个字节:检查接收数据寄存器非空标志(RXNE),有数据则读取。
uint8_t UART_ReceiveByte(void)
{
// 等待接收到数据
while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);
// 返回接收到的数据
return USART_ReceiveData(USART1);
}
主循环示例
一个简单的回环测试:收到什么就发什么。
int main(void)
{
uint8_t rx_data;
UART_Init();
UART_SendString("UART Polling Mode Ready!\r\n");
while (1)
{
rx_data = UART_ReceiveByte();
UART_SendByte(rx_data);
}
}
我个人的建议:轮询模式只适合在裸机或简单任务中使用。如果主循环里还有其他任务要处理,轮询接收会阻塞CPU。这时候就该考虑中断模式或DMA了——后面章节我会详细讲。
2.5 轮询模式的优缺点
| 优点 | 缺点 |
|---|---|
| 代码简单,逻辑直观 | CPU被占用,无法处理其他任务 |
| 没有中断上下文切换开销 | 接收可能丢数据(如果数据来得太快) |
| 调试方便,容易定位问题 | 不适合高波特率或大数据量场景 |
| 资源占用少(不需要中断向量) | 实时性差,无法响应紧急事件 |
我曾经翻过车:在一个项目里用轮询模式跑921600波特率,结果CPU几乎100%时间都在等数据,其他任务全部卡死。后来改成DMA+中断,CPU占用率直接降到5%以下。所以,轮询模式适合“偶尔发一两个字节”的场景,高频通信千万别用。
2.6 本章小结
UART的帧格式其实不复杂:起始位、数据位、校验位、停止位,就这么几个字段。波特率决定了通信速度,但也要考虑双方时钟误差——一般允许±2%的偏差,超过这个范围就可能丢数据。
轮询模式是UART驱动的基础实现,代码简单,适合学习和调试。但实际产品中,我很少用纯轮询。嗯,先把轮询跑通,后面学中断和DMA时你才能对比出它们的优势。