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)

我画了一张帧结构图,你一看就明白:

UART 异步串行帧结构 空闲 高电平 起始 0 D0 LSB D1 D2 D3 D4 D5 D6 D7 MSB 校验位 可选 停止位 1/1.5/2bit 空闲 或下一帧 发送方向 → 说明: 空闲时TX线为高电平。起始位拉低一个bit时间,通知接收方“我要发数据了”。 数据位从LSB开始发送,停止位恢复高电平,保证帧间有明确的电平跳变。

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时你才能对比出它们的优势。