4. UART驱动移植与实战(上):UART轮询模式

UART,说白了就是串口。做嵌入式开发的人,没有谁离得开它。我刚开始用STM32那会儿,调试全靠串口打印,那时候觉得这玩意儿太简单了——不就是发数据、收数据嘛。后来踩的坑多了,才发现事情没那么简单。

这一章,咱们就好好聊聊UART轮询模式。我会带着你从HAL库源码入手,看看HAL_UART_Transmit和HAL_UART_Receive到底干了什么,再分析一下阻塞式收发带来的延时问题。

4.1 轮询模式的核心逻辑

轮询模式,也叫阻塞模式。你调用一个发送函数,CPU就在那儿死等,直到数据发完才返回。接收也是一样,没收到数据就卡住不动。

我画了一张流程图,帮你理清整体思路:

UART轮询模式核心流程 调用HAL_UART_Transmit 检查句柄状态(huart->gState) 等待发送完成标志(TC/ TXE) 超时判断(Timeout) 返回HAL_OK/ HAL_TIMEOUT 轮询模式下,CPU全程参与数据搬运,无法执行其他任务

你看,整个流程就是一个线性执行链。CPU从调用函数开始,到返回结果为止,中间没有任何"分心"的机会。

4.2 HAL_UART_Transmit 源码分析

咱们直接看HAL库的源码。我挑几个关键点来说。

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart,
                                    uint8_t *pData,
                                    uint16_t Size,
                                    uint32_t Timeout)
{
    uint16_t *tmp;
    uint32_t tickstart = 0;

    // 1. 参数检查
    if (huart->gState == HAL_UART_STATE_READY) {
        // 2. 设置状态为BUSY
        huart->gState = HAL_UART_STATE_BUSY;
    } else {
        return HAL_BUSY;
    }

    // 3. 获取起始时间
    tickstart = HAL_GetTick();

    // 4. 逐字节发送
    while (huart->TxXferCount > 0) {
        // 等待TXE标志
        while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE) == RESET) {
            // 超时判断
            if ((Timeout == 0) ||
                ((HAL_GetTick() - tickstart) > Timeout)) {
                huart->gState = HAL_UART_STATE_READY;
                return HAL_TIMEOUT;
            }
        }
        // 写入数据寄存器
        huart->Instance->DR = (*pData++ & 0xFF);
        huart->TxXferCount--;
    }

    // 5. 等待发送完成
    while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET) {
        // 同样有超时判断
    }

    // 6. 恢复状态
    huart->gState = HAL_UART_STATE_READY;
    return HAL_OK;
}

这段代码,我拆成几个关键点来讲:

步骤 做了什么 注意点
状态检查 判断gState是否为READY 如果正在发送,直接返回HAL_BUSY
设置BUSY 防止其他任务干扰 这是临界区保护的关键
等待TXE 等待发送寄存器空 这里会死等,直到标志置位
写入DR 把数据塞进硬件寄存器 一次只发一个字节
等待TC 等待移位寄存器发送完成 最后一个字节必须等TC
恢复状态 释放UART资源 返回HAL_OK

关键区别:TXE vs TC

TXE(发送数据寄存器空)表示你可以往DR写下一个字节了。TC(发送完成)表示整个帧已经发完了。发送最后一个字节时,必须等TC,否则数据可能还没发出去就被覆盖了。

我在项目中遇到过一个问题:连续发送大量数据时,只等TXE不等TC,结果最后一帧数据总是丢失。查了半天才发现是这个问题。

4.3 HAL_UART_Receive 源码分析

接收函数和发送函数是对称的,但有个细节不一样。

HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart,
                                   uint8_t *pData,
                                   uint16_t Size,
                                   uint32_t Timeout)
{
    uint32_t tickstart = 0;

    if (huart->gState == HAL_UART_STATE_READY) {
        huart->gState = HAL_UART_STATE_BUSY_RX;
    } else {
        return HAL_BUSY;
    }

    tickstart = HAL_GetTick();

    while (huart->RxXferCount > 0) {
        // 等待RXNE标志
        while (__HAL_UART_GET_FLAG(huart, UART_FLAG_RXNE) == RESET) {
            if ((Timeout == 0) ||
                ((HAL_GetTick() - tickstart) > Timeout)) {
                huart->gState = HAL_UART_STATE_READY;
                return HAL_TIMEOUT;
            }
        }
        // 读取数据寄存器
        *pData++ = (uint8_t)(huart->Instance->DR & 0xFF);
        huart->RxXferCount--;
    }

    huart->gState = HAL_UART_STATE_READY;
    return HAL_OK;
}

接收函数的核心逻辑:

  • 等待RXNE(接收数据寄存器非空)标志
  • 从DR寄存器读一个字节
  • 重复直到收完指定字节数

注意:接收超时问题

如果对方发送的数据少于你期望的Size,接收函数就会一直卡在等待RXNE的循环里,直到超时。我曾经在一个项目中,因为通信协议没对齐,接收端等了500ms超时,导致整个系统响应变慢。后来我改成了不定长接收,才解决这个问题。

4.4 阻塞式发送与接收的延时问题

轮询模式最大的问题,就是延时。咱们来算一笔账。

假设你用的是115200波特率,8N1格式。那么:

  • 1个字节 = 10位(1起始 + 8数据 + 1停止)
  • 传输1个字节的时间 = 10 / 115200 ≈ 86.8μs
  • 传输100个字节 ≈ 8.68ms
  • 传输1024个字节 ≈ 88.9ms

这88.9ms里,CPU在干什么?

在死等。在空转。在浪费算力。

延时问题的本质

轮询模式下,CPU的利用率极低。发送1024字节,CPU真正干活的时间只有写入DR的那几个微秒,剩下的88.9ms全在等硬件。你想想看,如果你的系统还要处理其他任务,比如按键扫描、LED刷新、传感器采集,这88.9ms的阻塞就是灾难。

我整理了一个对比表,帮你直观感受一下:

数据量 115200波特率耗时 CPU有效工作时间 CPU浪费时间
1字节 86.8μs ≈0.5μs 86.3μs
100字节 8.68ms ≈50μs 8.63ms
1024字节 88.9ms ≈512μs 88.4ms

你看,CPU的有效利用率不到1%。说白了,轮询模式只适合两种场景:

  • 发送极少量的数据(比如几个字节的调试信息)
  • 系统没有其他任务需要处理(比如裸机跑一个简单的循环)

我的建议

如果你要在项目里用UART做数据通信,我个人建议直接上中断模式或DMA模式。轮询模式只适合做调试打印,或者发送一些不频繁的状态信息。我曾经在一个产品里用了轮询模式接收GPS数据,结果因为阻塞时间太长,导致主循环里的传感器采集周期被拉长,最后定位精度出了问题。从那以后,我再也不敢在正式产品里用轮询模式做数据收发了。

4.5 轮询模式的使用场景

虽然轮询模式有延时问题,但它也不是一无是处。我总结了几种适合用轮询模式的场景:

  1. 调试信息输出:printf打印日志,数据量小,偶尔用一下没问题。
  2. 系统初始化阶段:MCU刚启动时,外设还没初始化完,用轮询模式最可靠。
  3. 低优先级通信:比如每隔几秒发一次状态心跳包,阻塞几十微秒可以接受。
  4. 简单裸机程序:没有RTOS,没有复杂任务调度,轮询模式反而简单直接。

避坑指南

我曾经在一个项目中,用轮询模式接收不定长数据。对方发数据的时间间隔不固定,结果接收函数经常超时返回,导致数据丢失。后来我改成了中断接收+环形缓冲区,才彻底解决这个问题。所以,如果你要接收不定长数据,千万别用轮询模式。

4.6 本章小结

这一章,咱们把UART轮询模式的源码和延时问题都过了一遍。核心就三点:

  • 轮询模式是阻塞的,CPU在等待期间啥也干不了
  • 发送函数等TXE和TC两个标志,接收函数等RXNE标志
  • 延时问题严重,CPU利用率极低,只适合少量数据或调试场景