4. UART驱动封装(轮询模式):设计UART句柄结构体,实现阻塞式发送与接收,并加入超时机制

UART驱动,可以说是嵌入式开发中最基础也最常用的外设接口了。很多初学者上来就调库函数,发一个字节等一个字节,觉得挺简单。但真正到了项目里,你会发现——没有超时机制的阻塞式收发,就是个定时炸弹。

我个人习惯,不管多简单的驱动,第一件事就是先把句柄结构体定义好。结构体就是驱动的“身份证”,后面所有操作都围着它转。

4.1 UART句柄结构体设计

句柄结构体,说白了就是把UART相关的所有资源打包在一起。包括硬件外设指针、引脚配置、收发缓冲区、状态标志,还有我们今天要重点讲的超时参数。

我见过不少工程师把波特率、数据位这些参数散落在各个函数里,改个配置得翻遍整个文件。你想想看,这要是项目大了,维护起来得多痛苦?

下面是我在项目中沉淀下来的一套UART句柄定义:

/* UART工作模式枚举 */
typedef enum {
    UART_MODE_TX = 0x01,        /* 仅发送 */
    UART_MODE_RX = 0x02,        /* 仅接收 */
    UART_MODE_TX_RX = 0x03      /* 收发全双工 */
} UART_Mode_t;

/* UART状态枚举 */
typedef enum {
    UART_OK = 0x00,             /* 操作成功 */
    UART_BUSY = 0x01,           /* 忙,正在收发 */
    UART_TIMEOUT = 0x02,        /* 超时 */
    UART_ERROR = 0x03           /* 参数或硬件错误 */
} UART_Status_t;

/* UART句柄结构体 */
typedef struct {
    USART_TypeDef *Instance;    /* USART外设基地址 */
    GPIO_TypeDef *TxPort;       /* TX引脚端口 */
    uint16_t      TxPin;        /* TX引脚号 */
    GPIO_TypeDef *RxPort;       /* RX引脚端口 */
    uint16_t      RxPin;        /* RX引脚号 */

    uint32_t      BaudRate;     /* 波特率 */
    uint32_t      WordLength;   /* 数据位 */
    uint32_t      StopBits;     /* 停止位 */
    uint32_t      Parity;       /* 校验位 */
    UART_Mode_t   Mode;         /* 工作模式 */

    uint32_t      TxTimeout;    /* 发送超时(ms) */
    uint32_t      RxTimeout;    /* 接收超时(ms) */

    volatile uint8_t  State;    /* 驱动状态 */
    void (*ErrorCallback)(void *handle); /* 错误回调 */
} UART_Handle_t;

这里有几个设计要点,我重点说一下:

  • Instance指针:直接指向USART1/2/3等外设,方便切换目标串口
  • TxTimeout/RxTimeout:收发超时分开配置,因为发送和接收的实时性要求往往不同
  • State标志:用volatile修饰,因为中断或轮询中会修改它
  • ErrorCallback:错误回调函数指针,方便上层做异常处理
我的习惯:结构体成员按“硬件资源 → 通信参数 → 控制参数 → 状态回调”的顺序排列。这样读代码时,从上到下就是“用什么硬件 → 怎么配参数 → 怎么控制 → 出错了怎么办”,逻辑非常清晰。

4.2 阻塞式发送实现

阻塞式发送,就是调用发送函数后,CPU一直在等,直到数据发完或者超时。说白了就是“你不发完,我就不干别的”。

实现思路很简单:把数据一个字节一个字节往DR寄存器里写,每写一个等TC标志位置位。但这里有个坑——如果硬件出问题了,或者对端不响应,你就永远等下去了。所以超时机制必须加。

UART_Status_t UART_Transmit(UART_Handle_t *handle, 
                            const uint8_t *data, 
                            uint16_t size) {
    uint32_t tick_start;
    uint16_t i;

    /* 参数检查 */
    if (handle == NULL || data == NULL || size == 0) {
        return UART_ERROR;
    }

    /* 检查状态,防止重入 */
    if (handle->State & UART_STATE_TX_BUSY) {
        return UART_BUSY;
    }

    handle->State |= UART_STATE_TX_BUSY;
    tick_start = HAL_GetTick();

    for (i = 0; i < size; i++) {
        /* 等待发送数据寄存器空 */
        while (!(handle->Instance->SR & USART_SR_TXE)) {
            /* 超时检查 */
            if ((HAL_GetTick() - tick_start) > handle->TxTimeout) {
                handle->State &= ~UART_STATE_TX_BUSY;
                return UART_TIMEOUT;
            }
        }

        /* 写入数据 */
        handle->Instance->DR = data[i];

        /* 等待发送完成 */
        while (!(handle->Instance->SR & USART_SR_TC)) {
            if ((HAL_GetTick() - tick_start) > handle->TxTimeout) {
                handle->State &= ~UART_STATE_TX_BUSY;
                return UART_TIMEOUT;
            }
        }
    }

    handle->State &= ~UART_STATE_TX_BUSY;
    return UART_OK;
}

这段代码里,我用了两层while循环:第一层等TXE(发送寄存器空),第二层等TC(发送完成)。为什么要等两个标志?

嗯,这里要注意:TXE只表示数据从DR移到了移位寄存器,但移位寄存器还没发完。如果不等TC就直接发下一个字节,在高速率下可能会造成数据覆盖。我曾在115200波特率下遇到过这个问题,排查了半天才发现是TC没等够。

4.3 阻塞式接收实现

接收和发送是对称的,但有个区别:发送是我们主动推数据,接收是等数据来。所以接收的超时处理更关键——你不知道对端什么时候发数据,但你不能无限等下去。

UART_Status_t UART_Receive(UART_Handle_t *handle, 
                           uint8_t *buffer, 
                           uint16_t size) {
    uint32_t tick_start;
    uint16_t i;

    if (handle == NULL || buffer == NULL || size == 0) {
        return UART_ERROR;
    }

    if (handle->State & UART_STATE_RX_BUSY) {
        return UART_BUSY;
    }

    handle->State |= UART_STATE_RX_BUSY;
    tick_start = HAL_GetTick();

    for (i = 0; i < size; i++) {
        /* 等待接收数据寄存器非空 */
        while (!(handle->Instance->SR & USART_SR_RXNE)) {
            /* 超时检查 */
            if ((HAL_GetTick() - tick_start) > handle->RxTimeout) {
                handle->State &= ~UART_STATE_RX_BUSY;
                return UART_TIMEOUT;
            }
        }

        /* 读取数据 */
        buffer[i] = handle->Instance->DR;
    }

    handle->State &= ~UART_STATE_RX_BUSY;
    return UART_OK;
}
我曾经踩过的坑:接收超时时间设得太短,导致正常的数据帧被截断。后来我总结了一个经验:接收超时至少要比一个完整数据帧的传输时间长。比如115200波特率下传10个字节,大约需要0.9ms,那超时至少设5ms以上,留出余量。

4.4 超时机制设计详解

超时机制,说白了就是给阻塞操作加一个“闹钟”。到点了还没完成,就放弃等待,返回超时错误。

实现方式有很多种,我推荐用系统滴答定时器(SysTick)来做时间基准。STM32的HAL库默认就是1ms中断一次,用HAL_GetTick()就能拿到当前毫秒数。

超时判断的核心逻辑:

/* 记录开始时间 */
uint32_t start = HAL_GetTick();

/* 在循环中检查 */
while (条件不满足) {
    if ((HAL_GetTick() - start) > timeout_ms) {
        return UART_TIMEOUT;
    }
}

这里有个细节:用减法而不是加法来判断超时。因为HAL_GetTick()是32位无符号数,运行50多天后会溢出回0。如果用start + timeout < HAL_GetTick(),溢出时就会出问题。而减法在C语言无符号数运算中是安全的,即使溢出也能正确计算时间差。

超时参数 推荐值 说明
TxTimeout 100ms 发送1KB数据通常不超过10ms,100ms足够
RxTimeout 根据协议定 Modbus通常3.5字符时间,自定义协议建议50-200ms
最小超时 ≥ 2ms SysTick精度1ms,太短容易误判

4.5 完整驱动封装示例

把上面这些组合起来,就是一个完整的UART轮询驱动。初始化、发送、接收、错误处理,全在句柄里搞定。

/* 初始化UART */
UART_Status_t UART_Init(UART_Handle_t *handle) {
    /* 配置GPIO为复用功能 */
    GPIO_InitTypeDef gpio = {0};
    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    gpio.Pull = GPIO_PULLUP;

    gpio.Pin = handle->TxPin;
    HAL_GPIO_Init(handle->TxPort, &gpio);

    gpio.Pin = handle->RxPin;
    gpio.Mode = GPIO_MODE_AF_INPUT;
    HAL_GPIO_Init(handle->RxPort, &gpio);

    /* 配置USART */
    handle->Instance->CR1 = 0;
    handle->Instance->CR2 = 0;
    handle->Instance->CR3 = 0;

    /* 设置波特率、数据位、停止位、校验位 */
    /* ... 具体配置代码略,与标准库一致 */

    /* 使能USART */
    handle->Instance->CR1 |= USART_CR1_UE;

    handle->State = UART_STATE_READY;
    return UART_OK;
}

/* 使用示例 */
void UART_Example(void) {
    UART_Handle_t uart1 = {
        .Instance = USART1,
        .TxPort = GPIOA, .TxPin = GPIO_PIN_9,
        .RxPort = GPIOA, .RxPin = GPIO_PIN_10,
        .BaudRate = 115200,
        .WordLength = UART_WORDLENGTH_8B,
        .StopBits = UART_STOPBITS_1,
        .Parity = UART_PARITY_NONE,
        .Mode = UART_MODE_TX_RX,
        .TxTimeout = 100,
        .RxTimeout = 50,
        .ErrorCallback = NULL
    };

    uint8_t tx_buf[] = "Hello UART!";
    uint8_t rx_buf[32];

    UART_Init(&uart1);
    UART_Transmit(&uart1, tx_buf, sizeof(tx_buf));
    UART_Receive(&uart1, rx_buf, 10);
}
核心要点:
  • 句柄结构体是驱动的“中枢神经”,所有资源都通过它管理
  • 阻塞式收发必须加超时,否则硬件异常时系统会死锁
  • 超时判断用减法,避免32位溢出问题
  • 发送要等TC标志,不能只等TXE
  • 接收超时时间要根据数据帧长度和波特率合理设置

这套封装思路,我在多个项目里验证过。从简单的串口打印,到工业现场的Modbus协议,再到和4G模块的AT指令交互,都跑得很稳。你想想看,一个结构体搞定所有配置,一个函数搞定收发,出错了还有超时兜底——这才是工程级的代码。

UART轮询驱动核心架构 UART_Handle_t • Instance (外设指针) • TxPort / TxPin • RxPort / RxPin • BaudRate / WordLength • StopBits / Parity / Mode • TxTimeout / RxTimeout • State / ErrorCallback UART_Init() ① GPIO复用功能配置 ② USART参数配置 ③ 使能外设 ④ 状态置为READY UART_Transmit() ① 参数/状态检查 ② 等待TXE + 超时判断 ③ 写入DR寄存器 ④ 等待TC + 超时判断 UART_Receive() ① 参数/状态检查 ② 等待RXNE + 超时判断 ③ 读取DR到缓冲区 超时机制 • 基于SysTick(1ms) • HAL_GetTick()时间戳 • 减法判断防溢出 • 超时返回UART_TIMEOUT • 释放BUSY状态 设计要点 • 收发超时分开配置 • 发送必须等TC标志 • 接收超时留余量

这张图把整个驱动的架构理清楚了。句柄结构体是中心,初始化、发送、接收三个模块都依赖它,而超时机制贯穿收发全过程。你写驱动时,就按这个结构来组织代码,不会乱。