3. UART驱动与HAL封装:轮询模式、中断模式、环形缓冲区设计、波特率配置实战

UART,说白了就是串口。做嵌入式开发,这东西几乎天天见。调试打印、跟传感器通信、对接蓝牙模块,哪哪都离不开它。但很多新手写UART驱动,就是简单调个库函数,一旦遇到数据错乱、丢字节、波特率对不上,立马抓瞎。

我个人习惯,不管项目多小,UART驱动一定要做HAL封装。为什么?因为换芯片的时候你就知道了。我早期一个项目,从STM32F1换到GD32,底层寄存器全变,但上层协议栈一点没动——全靠HAL层挡了一刀。

3.1 轮询模式:简单但别滥用

轮询模式,就是CPU死等。发一个字节,等发送完成;读一个字节,等接收完成。代码写起来最直接,但效率最低。

// 轮询发送一个字节
void uart_poll_send(UART_HandleTypeDef *huart, uint8_t data)
{
    // 等待发送缓冲区空
    while (!(huart->SR & USART_SR_TXE));
    // 写入数据
    huart->DR = data;
    // 等待发送完成
    while (!(huart->SR & USART_SR_TC));
}

// 轮询接收一个字节
uint8_t uart_poll_recv(UART_HandleTypeDef *huart)
{
    // 等待接收到数据
    while (!(huart->SR & USART_SR_RXNE));
    return (uint8_t)(huart->DR);
}

这段代码,每个字节都要死等。你想想看,如果波特率是115200,一个字节大概87微秒,CPU就干等着。要是系统里还有别的任务要跑,这种写法直接拖垮实时性。

注意:轮询模式只适合在初始化阶段、调试阶段、或者极低吞吐量的场景。生产环境的主循环里,千万别用轮询收数据——丢包率会让你怀疑人生。

3.2 中断模式:让CPU喘口气

中断模式就好多了。CPU该干嘛干嘛,数据来了,硬件自动触发中断,你在中断服务函数里处理。我习惯把中断服务函数写得尽量短,只做数据搬运,不做协议解析。

// 中断服务函数(以STM32为例)
void USART1_IRQHandler(void)
{
    uint8_t data;
    
    // 检查接收中断标志
    if (USART1->SR & USART_SR_RXNE)
    {
        data = (uint8_t)(USART1->DR);
        // 把数据放入环形缓冲区
        ringbuf_put(&rx_ringbuf, data);
    }
    
    // 检查发送中断标志(如果需要)
    if (USART1->SR & USART_SR_TXE)
    {
        // 从发送缓冲区取数据发送
        if (ringbuf_get(&tx_ringbuf, &data) == 0)
        {
            USART1->DR = data;
        }
        else
        {
            // 没有数据要发送,关闭发送中断
            USART1->CR1 &= ~USART_CR1_TXEIE;
        }
    }
}

这里有个坑,我踩过好几次。中断里操作缓冲区,一定要考虑原子性。如果主循环也在读写同一个缓冲区,不加保护的话,数据竞争是必然的。最简单的做法:中断里只写,主循环里只读,或者反过来。

3.3 环形缓冲区设计:核心中的核心

环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:写指针和读指针。数据写满了就从头覆盖,读指针追着写指针走。

我项目中用过很多种实现,最稳定的是这种:

// 环形缓冲区结构体
typedef struct {
    uint8_t *buffer;    // 缓冲区内存
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
} RingBuf_t;

// 初始化
void ringbuf_init(RingBuf_t *rb, uint8_t *buf, uint32_t size)
{
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ringbuf_put(RingBuf_t *rb, uint8_t data)
{
    uint32_t next_head = (rb->head + 1) & (rb->size - 1);
    
    // 缓冲区满
    if (next_head == rb->tail)
        return -1;
    
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

// 读取一个字节
int ringbuf_get(RingBuf_t *rb, uint8_t *data)
{
    // 缓冲区空
    if (rb->head == rb->tail)
        return -1;
    
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    return 0;
}
关键设计点:
  • 缓冲区大小必须是2的幂,这样可以用位运算代替取模,效率高很多
  • head和tail用volatile修饰,防止编译器优化掉中断和主循环之间的共享变量
  • 判断满和空的条件:head == tail表示空,(head+1) & (size-1) == tail表示满
  • 实际可用空间是size-1,留一个空位区分满和空

我曾经在一个项目里,缓冲区大小设成了100,不是2的幂。结果取模运算在中断里多花了几个时钟周期,波特率高了之后就开始丢数据。改成128之后,问题立刻消失。嗯,这里要注意,嵌入式里,细节真的决定成败。

3.4 波特率配置实战

波特率配置,看起来就是套公式,但实际项目中翻车最多的地方就在这里。不同芯片的时钟树不一样,APB总线频率不同,分频系数不同,算出来的实际波特率和目标波特率有误差。

标准公式:

波特率 = 时钟频率 / (16 * USARTDIV)

// USARTDIV是分频系数,可以是小数
// 例如:时钟频率72MHz,目标波特率115200
// USARTDIV = 72000000 / (16 * 115200) = 39.0625
// 整数部分:39,小数部分:0.0625 * 16 = 1
// 所以:USART_BRR = (39 << 4) | 1 = 0x271
目标波特率 时钟频率 实际波特率 误差 是否可用
115200 72 MHz 115200 0.00% ✅ 完美
115200 84 MHz 115068 0.11% ✅ 可用
115200 100 MHz 114547 0.57% ⚠️ 临界
115200 120 MHz 113636 1.36% ❌ 不可用
经验值:波特率误差在±2%以内,大部分UART设备都能正常工作。但如果你用的是高速波特率(如921600),误差最好控制在±1%以内。我曾经因为时钟配置错了,APB2总线频率算少了一半,结果115200的波特率实际只有57600,跟PC对不上,排查了整整一个下午。

3.5 HAL封装:统一接口,屏蔽差异

HAL封装的核心思想:上层代码不关心底层是哪个芯片、哪个UART外设。你只需要定义一套统一的接口,然后针对不同平台实现底层。

// HAL层统一接口
typedef struct {
    int (*init)(uint32_t baudrate);
    int (*send)(uint8_t *data, uint32_t len);
    int (*recv)(uint8_t *data, uint32_t timeout_ms);
    void (*irq_handler)(void);
} UART_HAL_t;

// 具体平台实现(以STM32为例)
static int stm32_uart_init(uint32_t baudrate)
{
    // 配置GPIO、USART寄存器、NVIC中断
    // 设置波特率
    // 使能USART
    return 0;
}

static int stm32_uart_send(uint8_t *data, uint32_t len)
{
    // 把数据写入发送环形缓冲区
    // 使能发送中断
    return 0;
}

static int stm32_uart_recv(uint8_t *data, uint32_t timeout_ms)
{
    // 从接收环形缓冲区读取数据
    // 支持超时返回
    return 0;
}

// HAL层实例
const UART_HAL_t uart_hal_stm32 = {
    .init = stm32_uart_init,
    .send = stm32_uart_send,
    .recv = stm32_uart_recv,
    .irq_handler = stm32_uart_irq_handler,
};

这样做的好处很明显。上层协议栈(比如Modbus、AT指令解析器)只跟UART_HAL_t打交道。换芯片的时候,只需要重新实现这4个函数,上层代码一行都不用改。

HAL封装的核心原则:
  • 接口要足够抽象,不暴露任何寄存器细节
  • 支持同步和异步两种模式
  • 错误码统一,上层不用关心底层错误类型
  • 中断处理函数由HAL层管理,上层不直接操作中断

3.6 知识结构总览

下面这张图,把UART驱动从底层到上层的完整结构画出来了。你对照着看,思路会更清楚。

UART驱动与HAL封装知识结构 应用层(协议栈、调试、传感器) HAL封装层(统一接口) init() | send() | recv() | irq_handler() 驱动核心层 轮询模式 CPU死等,简单低效 中断模式 事件驱动,高效实时 环形缓冲区 数据中转,防丢包 硬件抽象层(寄存器操作) 波特率配置 | GPIO复用 | NVIC中断使能 物理层(UART外设 + 电平转换) 设计原则 上层不依赖底层寄存器

从这张图可以看得很清楚:应用层只跟HAL封装层打交道,HAL层调用驱动核心层的三种模式,驱动核心层再通过硬件抽象层操作寄存器。每一层各司其职,换芯片的时候,只需要改最下面两层。

我的建议:刚开始做HAL封装的时候,别想着一步到位。先实现轮询模式,保证基本功能通。然后加中断模式,最后上环形缓冲区。每加一层,都要做完整的回环测试——发什么数据,收回来必须一模一样。

嗯,UART这部分内容,看起来基础,但真正做扎实了,后面所有串行通信的中间件集成都会轻松很多。你想想看,Modbus、AT指令、GPS NMEA协议,底层都是UART。HAL层封装好了,上层就是纯粹的协议解析逻辑,跟硬件彻底解耦。