UART驱动HAL封装:从轮询到中断的抽象之路

UART这个外设,说简单也简单,说复杂也复杂。我做了这么多年嵌入式,几乎每个项目都离不开它——调试打印、与传感器通信、甚至作为主控之间的数据桥梁。但真正让我意识到UART驱动需要好好封装的,是一次惨痛的教训。

那是一个物联网网关项目,用了三款不同的MCU。每换一个芯片,UART驱动就得重写一遍。更糟的是,应用层的协议栈代码和硬件操作混在一起,改一个波特率都要翻半天代码。嗯,从那以后,我开始认真思考:怎么把UART驱动做得既通用又高效?

UART驱动的两种工作模式

先说说UART最基本的两种模式:轮询和中断。你想想看,这两种模式本质上就是CPU怎么知道数据来了。

特性 轮询模式 中断模式
CPU占用 持续占用,不能做其他事 只在数据到达时响应
实时性 取决于轮询频率 立即响应
代码复杂度 简单直接 需要处理临界区
适用场景 调试、低速率、简单系统 多任务、高速率、复杂系统
功耗 高(CPU一直跑) 低(可进入休眠)

我个人习惯是:调试阶段先用轮询,稳定后切中断。但不管哪种模式,HAL层都要提供统一的接口。

HAL层抽象:让上层代码不感知硬件

核心思想很简单——把UART的操作抽象成几个标准接口。上层调用者不需要知道底层是STM32还是GD32,甚至不需要知道是轮询还是中断。

HAL层UART接口设计原则:

  • 初始化:传入配置结构体,返回句柄
  • 发送:阻塞或非阻塞,统一返回值
  • 接收:轮询或中断,统一回调机制
  • 控制:波特率、校验位、流控等

来看一个我常用的接口定义:

/* uart_hal.h - 硬件抽象层UART接口 */
typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;    /* 5, 6, 7, 8 */
    uint8_t  stop_bits;    /* 1, 2 */
    uint8_t  parity;       /* 0=none, 1=odd, 2=even */
    uint8_t  flow_ctrl;    /* 0=none, 1=hw */
    uint8_t  mode;         /* 0=poll, 1=interrupt */
    void   (*rx_callback)(uint8_t data);
} uart_config_t;

typedef void* uart_handle_t;

uart_handle_t uart_init(uart_config_t *cfg);
int32_t       uart_send(uart_handle_t handle, 
                        const uint8_t *data, uint32_t len);
int32_t       uart_recv(uart_handle_t handle, 
                        uint8_t *data, uint32_t len);
void          uart_deinit(uart_handle_t handle);

注意看,这里用 void* 作为句柄。为什么?因为不同MCU的UART外设寄存器结构完全不同,用不透明指针可以隐藏底层实现。我在项目中遇到过有人直接把寄存器地址暴露给上层,结果换芯片时改得想哭。

环形缓冲区:中断驱动的基石

中断模式有个核心问题:中断服务程序要快进快出,不能在里面做复杂处理。那数据怎么办?存起来。用什么存?环形缓冲区。

说白了,环形缓冲区就是一个固定大小的数组,加上读指针和写指针。数据写进去,读出来,首尾相连。

我的经验:环形缓冲区的大小要仔细斟酌。太小容易溢出丢数据,太大浪费内存。一般我会根据波特率算一下:115200bps下,每字节约87μs,如果主循环100ms轮询一次,缓冲区至少能存1150字节。实际取2倍余量,2KB左右比较稳妥。

来看一个我常用的环形缓冲区实现:

/* ringbuf.h */
typedef struct {
    uint8_t *buffer;
    uint32_t size;
    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;
}

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

/* 读取一个字节 */
int32_t 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;
    return 0;
}

注意:这里 head 和 tail 用了 volatile。为什么?因为中断和主循环会同时访问它们。不加 volatile,编译器可能优化掉读取操作,导致数据不同步。我曾经在这个坑里爬了整整两天。

中断服务程序的正确写法

有了环形缓冲区,中断服务程序就简单了:收到数据,塞进缓冲区,然后退出。主循环再从缓冲区取数据做处理。

/* UART中断服务程序(以STM32为例) */
void USART1_IRQHandler(void) {
    uint8_t data;
    
    if (LL_USART_IsActiveFlag_RXNE(USART1)) {
        data = LL_USART_ReceiveData8(USART1);
        ringbuf_put(&rx_ringbuf, data);  /* 快速存入缓冲区 */
    }
    
    /* 发送中断同理 */
    if (LL_USART_IsActiveFlag_TXE(USART1) && tx_pending) {
        if (ringbuf_get(&tx_ringbuf, &data) == 0) {
            LL_USART_TransmitData8(USART1, data);
        } else {
            LL_USART_DisableIT_TXE(USART1);  /* 没有数据了,关中断 */
            tx_pending = 0;
        }
    }
}

你看,中断里只做两件事:读数据、存缓冲区。绝对不要在这里做协议解析、打印日志、甚至调用 printf。我见过有人在中断里调 printf,结果递归调用把自己搞死了。

跨平台UART驱动框架实战

好了,理论说完了,咱们来点实际的。怎么设计一个能跑在不同MCU上的UART驱动框架?

我的做法是分层:

  1. HAL层:定义统一接口,提供环形缓冲区
  2. MCU适配层:实现具体的寄存器操作
  3. 应用层:只调用HAL接口,不关心底层

下面这张图展示了整个框架的结构:

跨平台UART驱动框架结构 应用层 协议栈 · 日志系统 · 用户任务 HAL接口层(统一API) uart_init · uart_send · uart_recv · uart_deinit 环形缓冲区(ringbuf) 中断与主循环的数据桥梁 MCU适配层(平台相关) 寄存器操作 · 中断配置 · 时钟使能 STM32适配 LL/HAL库封装 GD32适配 固件库封装 ESP32适配 ESP-IDF封装 应用层和HAL接口层完全跨平台复用,只需为每个MCU编写适配层代码

这个框架的好处很明显:

  • 应用代码写一次,换芯片只需重写适配层
  • 环形缓冲区解耦了中断和主循环
  • 统一接口让单元测试变得容易

实际项目中的避坑指南

最后分享几个我踩过的坑:

坑1:中断优先级 - 我曾经把UART中断优先级设得太高,结果高优先级中断里调用了环形缓冲区操作,而主循环也在操作同一个缓冲区。解决方案:关中断保护临界区,或者用无锁环形缓冲区(单生产者单消费者场景)。

坑2:缓冲区溢出 - 115200波特率下,如果主循环被高优先级任务阻塞太久,缓冲区会满。我后来加了一个溢出计数器,调试时特别有用。

我的建议:在HAL层加一个调试接口 uart_get_stats(),返回溢出次数、发送完成次数等统计信息。线上出问题时,这些数据能救命。

好了,UART驱动的HAL封装就讲到这里。记住一个原则:让上层代码像调用标准库一样使用UART,底层细节全部封装在HAL层里。这样,不管换什么芯片,你的应用代码都不用动。