第4章 UART驱动与串口调试(上):USART外设框图与寄存器详解、轮询模式发送与接收

UART,说白了就是串口通信。做嵌入式开发的人,没有不跟它打交道的。我刚开始学STM32那会儿,第一个调通的外设就是UART——能打印出"Hello World"的那一刻,感觉整个世界都亮了。

这一章我们不讲理论绕弯子,直接看USART的硬件框图、寄存器,然后手写轮询模式的发送和接收。你跟着我走一遍,后面调试任何外设都会轻松很多。

USART外设框图:先看明白硬件怎么跑

STM32的USART模块,内部结构其实不复杂。我习惯把它分成三块来看:

  • 发送通路:数据从CPU写入发送数据寄存器(TDR),经过移位寄存器,从TX引脚逐位发出
  • 接收通路:RX引脚接收到的数据,经过移位寄存器,存入接收数据寄存器(RDR),CPU去读
  • 控制与状态:波特率发生器、中断控制、各种状态标志位

你想想看,数据流其实就是一个"写寄存器→硬件自动移位→读寄存器"的过程。嗯,就这么简单。

核心要点:USART的发送和接收是独立的硬件通路,可以同时工作(全双工)。发送靠写TDR触发,接收靠硬件自动完成,你只需要去RDR里取数据。

USART 内部数据通路框图 CPU / 总线 TDR (发送数据寄存器) RDR (接收数据寄存器) 发送移位寄存器 接收移位寄存器 TX 引脚 RX 引脚 波特率发生器 (USARTDIV) CR1 / CR2 / CR3 控制 数据流:CPU写TDR → 移位寄存器逐位发送 → TX引脚输出 | RX引脚输入 → 移位寄存器 → CPU读RDR

USART核心寄存器:你只需要记住这几个

USART的寄存器很多,但实际开发中常用的就那么几个。我列个表,你对照着看:

寄存器 全称 作用 轮询模式必用
SR 状态寄存器 查看发送完成、接收就绪等状态 ✅ 轮询TXE/TC/RXNE位
DR 数据寄存器 写=发送数据,读=接收数据 ✅ 直接读写
BRR 波特率寄存器 设置通信速率 ✅ 初始化时配置
CR1 控制寄存器1 使能USART、使能发送/接收、配置数据位/奇偶校验 ✅ 初始化时配置
CR2 控制寄存器2 配置停止位位数 ✅ 初始化时配置
CR3 控制寄存器3 流控制、单线模式等 一般用默认值

我的习惯:SR寄存器里的TXE(发送数据寄存器空)和TC(发送完成)是两个容易混淆的标志位。TXE表示你可以往DR里写下一个字节了,TC表示最后一个字节已经从引脚上发完了。轮询发送时,我一般用TXE,效率更高。

轮询模式发送:BSP_UART_PutChar 实现

轮询模式,说白了就是CPU死等。发一个字节,等硬件准备好,再发下一个。虽然效率不高,但胜在简单可靠。

实现步骤就三步:

  1. 等待TXE标志位置1(表示TDR已空,可以写数据)
  2. 把要发送的字节写入DR寄存器
  3. (可选)等待TC标志位,确保数据完全发出

代码长这样:

/* 文件: bsp_uart.c */
#include "bsp_uart.h"

void BSP_UART_PutChar(USART_TypeDef* USARTx, uint8_t ch)
{
    /* 1. 等待发送数据寄存器为空 (TXE=1) */
    while (!(USARTx->SR & USART_SR_TXE));
    
    /* 2. 写入数据寄存器,硬件自动开始发送 */
    USARTx->DR = ch;
    
    /* 3. 可选:等待发送完成 (TC=1),确保数据从引脚完全发出 */
    /* 如果连续发送多个字节,可以省略这一步,用TXE就够了 */
    while (!(USARTx->SR & USART_SR_TC));
}

我曾经踩过的坑:在连续发送大量数据时,如果每发一个字节都等TC,性能会明显下降。TC是在最后一个停止位结束后才置位的,而TXE在数据转移到移位寄存器后就置位了。所以连续发送时,只等TXE就够了,最后一个字节再等TC。

轮询模式接收:BSP_UART_GetChar 实现

接收比发送更简单。你只需要等RXNE(接收数据寄存器非空)标志位,然后去读DR就行了。

步骤:

  1. 等待RXNE标志位置1(表示收到一个字节)
  2. 从DR寄存器读取数据
uint8_t BSP_UART_GetChar(USART_TypeDef* USARTx)
{
    /* 1. 等待接收数据寄存器非空 (RXNE=1) */
    while (!(USARTx->SR & USART_SR_RXNE));
    
    /* 2. 读取数据寄存器,硬件自动清除RXNE标志 */
    return (uint8_t)(USARTx->DR);
}

嗯,就这么简单。你可能会问:读DR的时候为什么要强制转成uint8_t?因为DR寄存器是16位的,但实际数据只占低8位(8位数据模式时),高8位是保留位或用于9位数据模式。

完整的初始化流程

有了发送和接收函数,还得先初始化USART才能用。初始化步骤我总结成一张表:

步骤 操作 寄存器/外设 说明
1 使能USART时钟 RCC->APB2ENR / APB1ENR USART1挂在APB2上,USART2/3挂在APB1上
2 配置GPIO为复用功能 GPIOx->CRH/CRL TX推挽复用输出,RX浮空输入
3 设置波特率 USARTx->BRR 根据时钟频率和波特率计算分频值
4 配置数据位、停止位、校验 USARTx->CR1, CR2 常用8N1:8数据位、无校验、1停止位
5 使能发送器和接收器 USARTx->CR1 置位TE和RE位
6 使能USART USARTx->CR1 置位UE位,USART开始工作

对应的初始化代码:

void BSP_UART_Init(USART_TypeDef* USARTx, uint32_t baudrate)
{
    uint32_t apb_clock;
    uint16_t usart_div;
    
    /* 步骤1:使能时钟(以USART1为例) */
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
    
    /* 步骤2:配置GPIO PA9=TX(推挽复用), PA10=RX(浮空输入) */
    GPIOA->CRH &= ~(0xFF << 4);   /* 清除CNF9,MODE9,CNF10,MODE10 */
    GPIOA->CRH |= 0x0B << 4;      /* PA9: 推挽复用输出50MHz */
    GPIOA->CRH |= 0x04 << 8;      /* PA10: 浮空输入 */
    
    /* 步骤3:设置波特率 */
    apb_clock = 72000000;  /* 假设系统时钟72MHz,USART1挂在APB2上 */
    usart_div = apb_clock / baudrate;
    USARTx->BRR = usart_div;
    
    /* 步骤4:配置8N1模式 */
    USARTx->CR1 &= ~USART_CR1_M;    /* 8位数据模式 */
    USARTx->CR1 &= ~USART_CR1_PCE;  /* 无校验 */
    USARTx->CR2 &= ~USART_CR2_STOP; /* 1位停止位 */
    
    /* 步骤5:使能发送器和接收器 */
    USARTx->CR1 |= USART_CR1_TE;
    USARTx->CR1 |= USART_CR1_RE;
    
    /* 步骤6:使能USART */
    USARTx->CR1 |= USART_CR1_UE;
}

关于波特率计算:STM32的BRR寄存器存放的是USARTDIV的16倍值。当OVER8=0(默认)时,公式是:USARTDIV = 时钟频率 / (16 × 波特率)。我一般直接用整数除法,误差在3%以内都能正常工作。如果你要求高精度,可以用浮点计算再四舍五入。

实战:串口打印"Hello World"

把上面的函数组合起来,写个main函数测试一下:

/* 文件: main.c */
#include "bsp_uart.h"

/* 简单的字符串发送函数 */
void BSP_UART_SendString(USART_TypeDef* USARTx, const char* str)
{
    while (*str)
    {
        BSP_UART_PutChar(USARTx, *str++);
    }
}

int main(void)
{
    uint8_t rx_data;
    
    /* 初始化USART1,波特率115200 */
    BSP_UART_Init(USART1, 115200);
    
    /* 发送欢迎信息 */
    BSP_UART_SendString(USART1, "Hello World!\r\n");
    BSP_UART_SendString(USART1, "UART轮询模式测试开始...\r\n");
    
    while (1)
    {
        /* 接收一个字节,然后原样回显 */
        rx_data = BSP_UART_GetChar(USART1);
        BSP_UART_PutChar(USART1, rx_data);
    }
}

把这段代码烧进板子,打开串口助手,设置波特率115200、8N1。你应该能看到"Hello World!",然后你发什么,它就回什么——这就是最简单的串口回环测试。

验证方法:如果串口助手收不到数据,先检查三件事:1) TX/RX引脚有没有接反?2) 波特率是不是一致?3) GPIO的复用功能配置对了没有?我遇到过最蠢的一次,是把PA9和PA10的配置写反了,TX配成了输入,RX配成了输出……折腾了半小时才发现。

轮询模式的优缺点

轮询模式虽然简单,但不是万能的。我列个对比,你心里有个数:

对比项 轮询模式 中断模式 DMA模式
CPU占用 高(死等) 低(触发时才响应) 极低(硬件搬运)
实现复杂度 低 中 高
实时性 差(阻塞) 好 好
适用场景 调试打印、简单通信 交互式通信 大数据量传输

我个人习惯是:调试阶段先用轮询模式把功能调通,然后再根据需求升级成中断或DMA。这样出问题了容易定位——如果轮询模式都不通,那肯定是硬件或初始化的问题,跟中断/DMA无关。

注意:轮询模式在接收时有个致命问题——如果对方一直不发数据,你的程序就会卡死在BSP_UART_GetChar里。所以在实际产品中,轮询接收很少用,一般都会加超时机制或者直接用中断。这个我们下一章会讲。

好了,这一章的内容就到这里。你跟着代码敲一遍,用串口助手验证一下,应该就能把USART的轮询模式吃透了。记住:外设驱动没有玄学,只有寄存器。框图看懂了,寄存器配对了,代码写对了,它就能跑起来。