4. 驱动层封装核心概念

UART这个外设,说简单也简单,说复杂也复杂。简单在于它就是个串行收发,复杂在于你要在工程里把它用好,尤其是处理不定长数据、高波特率、多任务并发时,坑一个接一个。

我个人习惯,做UART驱动封装时,核心就三件事:数据缓冲、收发机制、接口抽象。这三件事做好了,上层应用基本不用管底层是中断还是DMA,换芯片平台也只需要改底层几个函数。

核心原则:驱动层只负责数据搬运和通知,不负责业务解析。把UART当作一个“管道”,上层往里扔数据、往外取数据,中间怎么传的,上层不关心。

4.1 环形缓冲区——UART驱动的“蓄水池”

为什么一定要用环形缓冲区?我在项目中遇到过,早期用线性数组+指针偏移,数据一多就溢出,或者读指针追不上写指针,直接乱掉。环形缓冲区说白了就是一个固定大小的数组,配合头尾指针循环使用,解决了“生产者-消费者”模型下的数据竞争问题。

4.1.1 环形缓冲区核心结构

typedef struct {
    uint8_t *buffer;    // 数据存储区
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;  // 写指针(生产者)
    volatile uint32_t tail;  // 读指针(消费者)
} ring_buffer_t;

这里有个细节:size我建议设计成2的幂。为什么?因为可以用位运算代替取模,效率高很多。你想想看,中断里每收一个字节都要取模,高频场景下这个开销不可忽视。

小技巧:判断环形缓冲区是否满,用 ((head - tail) & (size - 1)) == 0;判断空,用 head == tail。注意这里head和tail都是无符号整数,减法要小心溢出,但2的幂配合位运算可以完美规避。

4.1.2 环形缓冲区操作接口

我一般会封装四个基础操作,不多不少,刚好够用:

函数 功能 使用场景
ring_buf_init 初始化缓冲区,清空指针 驱动初始化时调用
ring_buf_put 写入一个字节 中断/DMA回调中调用
ring_buf_get 读取一个字节 应用层轮询或任务中调用
ring_buf_available 获取可读数据长度 判断是否有新数据
// 写入一个字节,返回是否成功
bool ring_buf_put(ring_buffer_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) & (rb->size - 1);
    if (next == rb->tail) {
        return false;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return true;
}

// 读取一个字节,返回是否成功
bool ring_buf_get(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return false;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    return true;
}
注意:head和tail一定要加volatile修饰。我曾经在一个项目里忘了加,编译器优化后,中断里改了head,主循环里读到的还是旧值,数据直接乱掉。查了两天才找到原因,教训深刻。

4.2 中断接收——最常用的UART数据接收方式

中断接收是UART驱动的标配。但很多新手容易犯一个错:在中断里做太多事。记住,中断服务函数要快进快出,最好只做一件事——把数据塞进环形缓冲区。

4.2.1 中断接收的典型流程

  1. UART收到一个字节,触发RXNE中断
  2. 中断服务函数读取DR寄存器,拿到数据
  3. 调用ring_buf_put将数据存入环形缓冲区
  4. 清除中断标志位
  5. 退出中断,主循环或任务中处理数据
void USART1_IRQHandler(void) {
    if (LL_USART_IsActiveFlag_RXNE(USART1)) {
        uint8_t data = LL_USART_ReceiveData8(USART1);
        ring_buf_put(&uart1_rx_buf, data);
        // 注意:这里不要做协议解析,不要调用printf
    }
}

嗯,这里要注意:有些HAL库会在中断里帮你清标志位,但有些不会。我建议你养成习惯,手动清一下,避免因为库版本差异导致问题。

4.2.2 中断接收的抽象接口

为了让上层代码不依赖具体硬件,我通常会定义一组抽象接口:

// UART驱动抽象接口
typedef struct {
    void (*init)(uint32_t baudrate);
    void (*send_byte)(uint8_t data);
    void (*send_buffer)(uint8_t *data, uint32_t len);
    uint32_t (*available)(void);
    uint8_t (*read_byte)(void);
    void (*irq_handler)(void);
} uart_drv_t;

这样设计的好处是,上层应用只需要拿到这个结构体指针,调用read_byte和available就能收数据,底层是中断还是DMA,完全不用管。

4.3 DMA收发——高吞吐场景下的最优解

当波特率超过115200,或者你需要连续收发大量数据时,中断方式就不太够用了。每个字节都进中断,CPU根本忙不过来。这时候就该DMA上场了。

DMA说白了就是硬件级别的“数据搬运工”。你告诉它数据从哪来、到哪去、搬多少,它自己搞定,搬完了给你发个完成中断。

4.3.1 DMA接收的两种模式

模式 原理 适用场景
循环模式(Circular) DMA自动循环填充缓冲区,满了一半或满了触发中断 持续接收不定长数据,如GPS、雷达数据
单次模式(Normal) DMA接收指定长度后触发完成中断 定长数据包接收,如Modbus帧

我个人更推荐循环模式+空闲中断的组合。为什么?因为实际项目中,你很难预知数据什么时候结束。空闲中断可以在总线空闲时告诉你“数据收完了”,配合DMA循环缓冲区,既能保证不丢数据,又能及时处理。

4.3.2 DMA+空闲中断的典型实现

// 初始化DMA接收(循环模式)
void uart_dma_rx_init(void) {
    // 配置DMA:外设地址=UART_DR,内存地址=rx_buffer
    // 传输方向:外设到内存
    // 模式:循环
    // 数据长度:缓冲区大小
    LL_DMA_Config(DMA1, LL_DMA_CHANNEL_1, ...);
    LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1);
    
    // 使能UART空闲中断
    LL_USART_EnableIT_IDLE(USART1);
}

// 空闲中断处理
void USART1_IRQHandler(void) {
    if (LL_USART_IsActiveFlag_IDLE(USART1)) {
        LL_USART_ClearFlag_IDLE(USART1);
        
        // 计算当前已接收的数据长度
        uint32_t remaining = LL_DMA_GetDataLength(DMA1, LL_DMA_CHANNEL_1);
        uint32_t received = RX_BUF_SIZE - remaining;
        
        // 将DMA缓冲区中的数据“搬运”到环形缓冲区
        // 注意:这里要处理DMA缓冲区可能被覆盖的情况
        process_dma_rx_data(received);
    }
}
避坑指南:DMA循环模式下,如果你不及时把数据从DMA缓冲区搬走,新数据会覆盖旧数据。我曾经在一个项目中,DMA缓冲区设了256字节,但应用层处理太慢,导致数据被覆盖,解析出来全是乱码。解决方案是:在空闲中断里,把DMA缓冲区的数据全部搬到环形缓冲区,或者用双缓冲机制。

4.4 中断与DMA的抽象接口设计

好了,现在我们有中断方式和DMA方式两种接收方案。但上层应用不想关心这些细节。怎么办?抽象。

我设计了一个统一的UART驱动接口,底层可以自由切换中断或DMA:

// 统一的UART驱动接口
typedef struct {
    // 初始化
    void (*init)(uart_config_t *cfg);
    
    // 发送
    void (*send)(uint8_t *data, uint32_t len);
    
    // 接收(非阻塞)
    int32_t (*receive)(uint8_t *data, uint32_t timeout_ms);
    
    // 获取可读数据量
    uint32_t (*available)(void);
    
    // 注册接收回调
    void (*register_callback)(uart_rx_cb_t callback);
    
    // 底层中断处理(由中断向量表调用)
    void (*irq_handler)(void);
} uart_hal_t;

你看,这个接口里没有出现“中断”或“DMA”的字眼。底层实现时,你可以用中断+环形缓冲区,也可以用DMA+空闲中断,甚至可以用轮询。上层调用receive时,它只知道“我要读数据”,至于数据怎么来的,它不管。

我的习惯:在驱动初始化时,通过一个配置项来选择底层机制。比如cfg->rx_mode = UART_RX_MODE_IRQ或UART_RX_MODE_DMA。这样同一个接口,切换模式只需要改一个宏定义,非常方便。

4.5 本章知识体系

下面这张图总结了UART驱动封装的核心结构,你可以看到数据从硬件到应用层的完整流动路径:

UART驱动封装知识体系 UART驱动封装核心结构 硬件层(UART外设) 中断接收 逐字节触发 DMA接收 批量搬运+空闲中断 环形缓冲区 head: 写指针 tail: 读指针 size: 2的幂 位运算取模 抽象接口层(uart_hal_t) init() | send() | receive() available() | register_callback() irq_handler() 应用层(协议解析/业务逻辑) 设计要点 • 中断服务函数只做数据搬运,不做协议解析 • 环形缓冲区大小建议256字节以上,避免溢出 • 抽象接口屏蔽中断/DMA差异,上层无感切换

从这张图可以清楚看到:数据从硬件外设出发,经过中断或DMA两种路径,最终汇入环形缓冲区。抽象接口层把底层细节全部封装,应用层只需要调用几个标准函数就能完成收发。这就是驱动封装的核心思想——分层隔离,接口统一。

总结一下:UART驱动封装的关键在于“缓冲+抽象”。环形缓冲区解决数据暂存和竞争问题,抽象接口解决底层切换问题。这两个做好了,你的UART驱动就能在各种项目中复用,换芯片、换平台都不需要大改。