4. UART驱动抽象:UART HAL层接口设计

UART,说白了就是串口。做嵌入式的人,没人离得开它。调试打印、跟传感器通信、跟蓝牙模块对接,全得靠它。

但问题是——每家芯片的UART寄存器都不一样。STM32是一套,GD32是一套,NXP又是一套。你要是直接操作寄存器,换个芯片就得重写。这就是为什么我们需要HAL层。

4.1 UART HAL层接口设计原则

我个人习惯,先把接口定死,再考虑实现。接口就是契约,谁也别乱改。

UART HAL层至少要提供这四类操作:

  • 初始化:配置波特率、数据位、停止位、校验位
  • 发送:阻塞发送、非阻塞发送(中断/DMA)
  • 接收:阻塞接收、非阻塞接收(中断/DMA)
  • 中断回调:发送完成、接收完成、错误处理

我见过很多新手把初始化参数写死在驱动里,换一个波特率就要改代码。嗯,这里要注意——参数必须由上层传入,驱动只负责执行。

核心原则:HAL层只定义"做什么",不关心"怎么做"。底层怎么操作寄存器,那是MCU适配层的事。

4.2 接口定义:UART HAL层API

先看头文件。我习惯把结构体和函数声明分开,结构体放前面,函数放后面。

/* uart_hal.h */
#ifndef __UART_HAL_H__
#define __UART_HAL_H__

#include <stdint.h>

/* UART配置参数结构体 */
typedef struct {
    uint32_t baudrate;      /* 波特率:9600, 115200, 921600等 */
    uint8_t  data_bits;     /* 数据位:5, 6, 7, 8 */
    uint8_t  stop_bits;     /* 停止位:1, 2 */
    uint8_t  parity;        /* 校验位:0=无, 1=奇校验, 2=偶校验 */
    uint8_t  flow_ctrl;     /* 流控:0=无, 1=硬件流控 */
} uart_config_t;

/* UART句柄结构体(不透明指针) */
typedef struct uart_handle uart_handle_t;

/* 回调函数类型定义 */
typedef void (*uart_tx_cplt_cb_t)(uart_handle_t *handle);
typedef void (*uart_rx_cplt_cb_t)(uart_handle_t *handle, uint8_t data);
typedef void (*uart_error_cb_t)(uart_handle_t *handle, uint32_t error_code);

/* HAL层API */
uart_handle_t* uart_init(uint32_t uart_id, uart_config_t *config);
int32_t uart_deinit(uart_handle_t *handle);

int32_t uart_send_blocking(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t uart_send_nonblocking(uart_handle_t *handle, uint8_t *data, uint32_t len);

int32_t uart_receive_blocking(uart_handle_t *handle, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
int32_t uart_receive_nonblocking(uart_handle_t *handle, uint8_t *buf, uint32_t len);

/* 中断回调注册 */
void uart_register_tx_callback(uart_handle_t *handle, uart_tx_cplt_cb_t cb);
void uart_register_rx_callback(uart_handle_t *handle, uart_rx_cplt_cb_t cb);
void uart_register_error_callback(uart_handle_t *handle, uart_error_cb_t cb);

/* 中断服务函数(由底层调用) */
void uart_irq_handler(uart_handle_t *handle);

#endif /* __UART_HAL_H__ */

你看,这里用了不透明指针(uart_handle_t)。上层只知道这是个句柄,但看不到内部结构。这样底层实现随便改,上层代码不用动。

我的经验:不透明指针是HAL层解耦的利器。我在一个项目里把底层从STM32换成了GD32,上层应用代码一行没改——就是因为句柄内部结构对外不可见。

4.3 环形缓冲区设计与应用

为什么要用环形缓冲区?你想想看,UART接收数据是异步的。中断来了,你总不能每次都去处理吧?万一处理不过来,数据就丢了。

环形缓冲区就是解决这个问题的——生产者(中断)往里面写,消费者(主循环)从里面读。互不干扰。

4.3.1 环形缓冲区结构体

/* ring_buffer.h */
#ifndef __RING_BUFFER_H__
#define __RING_BUFFER_H__

#include <stdint.h>

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

/* API声明 */
void    ring_buffer_init(ring_buffer_t *rb, uint8_t *buf, uint32_t size);
int32_t ring_buffer_put(ring_buffer_t *rb, uint8_t data);
int32_t ring_buffer_get(ring_buffer_t *rb, uint8_t *data);
uint32_t ring_buffer_available(ring_buffer_t *rb);
uint32_t ring_buffer_free_space(ring_buffer_t *rb);
int32_t ring_buffer_is_empty(ring_buffer_t *rb);
int32_t ring_buffer_is_full(ring_buffer_t *rb);

#endif /* __RING_BUFFER_H__ */

4.3.2 环形缓冲区实现要点

实现环形缓冲区,有几个坑必须注意。我曾经在一个产品里吃过亏,数据莫名其妙就丢了,查了两天才发现是头尾指针竞争的问题。

问题 原因 解决方案
头尾指针竞争 中断和主循环同时操作head/tail head只由生产者写,tail只由消费者读;用volatile修饰
缓冲区大小不是2的幂 取模运算慢,且容易溢出 强制size为2的幂,用位运算替代取模
满/空判断混淆 head==tail时可能是空也可能是满 预留一个空位,或者用计数器
中断中调用put put里有关中断操作,导致中断嵌套 中断中只写数据,不做复杂判断

这里给出一个预留空位法的实现:

/* ring_buffer.c */
#include "ring_buffer.h"

void ring_buffer_init(ring_buffer_t *rb, uint8_t *buf, uint32_t size)
{
    rb->buffer = buf;
    rb->size   = size;
    rb->head   = 0;
    rb->tail   = 0;
}

int32_t ring_buffer_put(ring_buffer_t *rb, uint8_t data)
{
    uint32_t next_head = (rb->head + 1) & (rb->size - 1);
    
    /* 检查是否满:如果下一个head等于tail,说明满了 */
    if (next_head == rb->tail) {
        return -1;  /* 缓冲区满 */
    }
    
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

int32_t ring_buffer_get(ring_buffer_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;
}

uint32_t ring_buffer_available(ring_buffer_t *rb)
{
    return (rb->head - rb->tail) & (rb->size - 1);
}

uint32_t ring_buffer_free_space(ring_buffer_t *rb)
{
    return rb->size - 1 - ring_buffer_available(rb);
}

避坑指南:我曾经在一个项目中把环形缓冲区大小设为100(不是2的幂),然后用了取模运算。结果在高速接收时,取模运算消耗了大量CPU时间,导致中断响应延迟,数据溢出。后来改成128,用位运算,问题解决。记住:环形缓冲区大小必须是2的幂。

4.4 环形缓冲区与UART HAL层的结合

现在把环形缓冲区嵌入到UART句柄中:

/* uart_hal.c 内部结构体 */
struct uart_handle {
    uint32_t        uart_id;        /* UART编号 */
    uart_config_t   config;         /* 配置参数 */
    void           *reg_base;       /* 寄存器基地址(底层使用) */
    
    /* 环形缓冲区 */
    ring_buffer_t   rx_ring;        /* 接收环形缓冲区 */
    ring_buffer_t   tx_ring;        /* 发送环形缓冲区 */
    uint8_t         rx_buf[256];    /* 接收缓冲区内存 */
    uint8_t         tx_buf[256];    /* 发送缓冲区内存 */
    
    /* 回调函数 */
    uart_tx_cplt_cb_t   tx_callback;
    uart_rx_cplt_cb_t   rx_callback;
    uart_error_cb_t     error_callback;
};

中断服务函数里,只需要做一件事——把数据塞进环形缓冲区:

/* 中断服务函数(在底层中断中调用) */
void uart_irq_handler(uart_handle_t *handle)
{
    uint8_t data;
    
    /* 检查接收中断 */
    if (/* 接收中断标志位 */) {
        data = /* 读取数据寄存器 */;
        
        /* 放入环形缓冲区 */
        if (ring_buffer_put(&handle->rx_ring, data) == 0) {
            /* 成功,调用回调通知上层 */
            if (handle->rx_callback) {
                handle->rx_callback(handle, data);
            }
        } else {
            /* 缓冲区满,调用错误回调 */
            if (handle->error_callback) {
                handle->error_callback(handle, 0x01); /* 溢出错误 */
            }
        }
    }
    
    /* 检查发送中断 */
    if (/* 发送中断标志位 */) {
        uint8_t tx_data;
        if (ring_buffer_get(&handle->tx_ring, &tx_data) == 0) {
            /* 还有数据要发,继续发送 */
            /* 写入数据寄存器 */;
        } else {
            /* 发送完成,关闭发送中断 */
            /* 关闭发送中断标志 */;
            if (handle->tx_callback) {
                handle->tx_callback(handle);
            }
        }
    }
}

上层应用怎么用?很简单:

/* 应用层代码示例 */
void app_uart_init(void)
{
    uart_config_t config = {
        .baudrate  = 115200,
        .data_bits = 8,
        .stop_bits = 1,
        .parity    = 0,
        .flow_ctrl = 0
    };
    
    uart_handle_t *handle = uart_init(1, &config);
    
    /* 注册回调 */
    uart_register_rx_callback(handle, my_rx_callback);
    uart_register_error_callback(handle, my_error_callback);
}

void my_rx_callback(uart_handle_t *handle, uint8_t data)
{
    /* 收到一个字节,可以在这里做简单处理 */
    /* 或者直接让主循环去环形缓冲区取数据 */
}

void main_loop(void)
{
    uint8_t rx_data;
    
    while (1) {
        /* 从环形缓冲区取数据 */
        if (uart_receive_nonblocking(handle, &rx_data, 1) == 0) {
            /* 处理收到的数据 */
            process_data(rx_data);
        }
        
        /* 其他任务 */
        do_other_stuff();
    }
}

4.5 整体架构图

下面这张图展示了UART HAL层的整体架构,以及环形缓冲区在其中的位置:

UART HAL层架构与环形缓冲区数据流 应用层(Application) HAL层(uart_init / uart_send / uart_receive) 接收环形缓冲区(RX Ring) 发送环形缓冲区(TX Ring) 中断服务层(ISR:uart_irq_handler) 硬件层(UART外设寄存器) 数据流方向:硬件 → 中断 → 环形缓冲区 → HAL层 → 应用层 发送方向相反:应用层 → HAL层 → 环形缓冲区 → 中断 → 硬件

4.6 设计要点总结

最后,我把UART HAL层设计的几个关键点列出来,方便你对照检查:

  1. 接口与实现分离:头文件只暴露API,内部结构体对外不可见
  2. 参数配置化:波特率、数据位等参数由上层传入,驱动不写死
  3. 环形缓冲区大小必须是2的幂:用位运算替代取模,提高效率
  4. 头尾指针用volatile修饰:防止编译器优化导致中断和主循环的竞争
  5. 中断中只做最少的操作:把数据塞进环形缓冲区就返回,复杂处理交给主循环
  6. 回调函数注册机制:让上层可以感知事件,但不强制上层必须处理

个人建议:刚开始做HAL层设计时,不要追求一步到位。先把阻塞模式跑通,再加中断,最后加DMA。环形缓冲区可以先固定大小(比如256字节),等系统稳定了再考虑动态配置。一步一步来,不容易翻车。