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

UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂在于不同芯片的UART寄存器差异巨大。我早期做项目时,就吃过这个亏——换了个MCU,UART驱动几乎重写了一遍。后来我才意识到,HAL层的核心价值,就是把这种差异挡在门外。

4.1 UART HAL接口设计原则

设计UART的HAL接口,我个人习惯遵循三个原则:

  • 最小化:接口数量越少越好,能合并的绝不拆分
  • 无状态:每个接口只依赖传入的参数,不依赖内部全局变量
  • 可组合:收发、配置、流控可以独立使用,也能组合工作

你想想看,如果每个芯片的UART驱动都长不一样,上层应用怎么移植?所以HAL接口必须抽象到“收发、配置、流控”这三个维度上。

核心思路:HAL层不关心UART有多少个FIFO、中断优先级怎么配,它只关心“发一个字节”、“收一个字节”、“配置波特率”、“打开流控”。

4.2 收发接口设计

收发是UART最基础的功能。我见过很多HAL把收发接口拆成十几个函数,其实没必要。核心就四个:

接口 功能 参数 返回值
uart_send_byte 发送单字节 句柄, 数据 状态码
uart_recv_byte 接收单字节 句柄, 数据指针 状态码
uart_send_buffer 发送多字节 句柄, 数据, 长度 已发送长度
uart_recv_buffer 接收多字节 句柄, 缓冲区, 长度 已接收长度

这里有个细节:uart_recv_byte 的参数是数据指针,而不是直接返回值。为什么?因为接收可能失败,用指针可以同时返回数据和状态。我在项目中遇到过有人用返回值直接当数据,结果超时返回了0,跟真实数据0混淆了——嗯,这是个坑。

// 伪代码示例:UART HAL收发接口
typedef struct {
    uint32_t base_addr;      // 寄存器基地址
    uint32_t baudrate;       // 当前波特率
    uint8_t  data_bits;      // 数据位
    uint8_t  stop_bits;      // 停止位
    uint8_t  parity;         // 校验位
    void     (*irq_handler)(void);  // 中断回调
} uart_handle_t;

int uart_send_byte(uart_handle_t *handle, uint8_t data) {
    // 等待发送寄存器空
    while (!(UART_REG(handle, SR) & TX_EMPTY));
    UART_REG(handle, DR) = data;
    return UART_OK;
}

int uart_recv_byte(uart_handle_t *handle, uint8_t *data) {
    // 等待接收寄存器非空
    if (!(UART_REG(handle, SR) & RX_NOT_EMPTY)) {
        return UART_TIMEOUT;
    }
    *data = UART_REG(handle, DR);
    return UART_OK;
}

4.3 配置接口设计

配置接口是HAL层最容易被“膨胀”的地方。每个芯片的UART配置项都不一样,有的支持7位数据,有的支持9位,有的校验方式还分奇偶、标记、空格……

我的做法是:用结构体统一配置参数,而不是拆成十几个set函数。

// 统一配置结构体
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;         // 校验:NONE, ODD, EVEN
    uint8_t  flow_ctrl;      // 流控:NONE, RTS_CTS, XON_XOFF
} uart_config_t;

int uart_configure(uart_handle_t *handle, uart_config_t *config);

这样上层只需要填充一个结构体,调用一个接口。底层驱动根据结构体内容去设置寄存器。我曾经见过一个项目,配置接口拆了12个函数,结果每次配置都要调6、7次,代码又长又容易漏——说白了,就是设计没想清楚。

小技巧:配置接口里可以做参数校验。比如波特率不支持921600时,返回错误码,而不是静默地设置成115200。这样上层能及时发现配置错误。

4.4 流控接口设计

流控分硬件流控和软件流控。硬件流控用RTS/CTS引脚,软件流控用XON/XOFF字符。HAL层需要把这两种都抽象出来。

我个人习惯把流控作为配置的一部分,而不是单独的函数。但有些场景需要动态开关流控,比如在通信过程中临时关闭流控发送紧急数据。所以额外提供两个接口:

int uart_flow_ctrl_enable(uart_handle_t *handle, uint8_t flow_type);
int uart_flow_ctrl_disable(uart_handle_t *handle, uint8_t flow_type);

flow_type 可以是 FLOW_HW 或 FLOW_SW。这样上层可以灵活控制。

这里要注意:硬件流控的引脚配置通常由GPIO模块管理,HAL层UART驱动不应该直接操作GPIO寄存器。正确的做法是:HAL层只配置UART内部的流控逻辑,引脚复用由板级初始化代码完成。我在项目中见过有人把GPIO配置写死在UART驱动里,换板子就崩——嗯,这是个典型的耦合错误。

4.5 DMA与中断模式封装

收发模式是UART性能的关键。中断模式适合小数据量、低频率的场景;DMA模式适合大数据量、高频率的场景。HAL层需要把这两种模式封装成统一的接口。

我的设计思路是:收发接口不变,通过初始化参数选择模式。

模式 适用场景 CPU占用 吞吐量 延迟
轮询 调试、低速率 高(阻塞) 低 可控
中断 命令响应、小数据 中(每字节中断) 中 中
DMA 大数据流、高速率 低(仅启动和完成) 高 较高

中断模式的封装,核心是环形缓冲区。接收中断把数据放入环形缓冲区,上层从缓冲区读取。这样不会丢数据,也不会阻塞中断服务函数。

// 中断模式接收示例
static uint8_t rx_ring_buf[256];
static uint16_t rx_head = 0, rx_tail = 0;

void uart_irq_handler(uart_handle_t *handle) {
    uint8_t data;
    if (UART_REG(handle, SR) & RX_NOT_EMPTY) {
        data = UART_REG(handle, DR);
        // 放入环形缓冲区
        uint16_t next = (rx_head + 1) % 256;
        if (next != rx_tail) {  // 缓冲区未满
            rx_ring_buf[rx_head] = data;
            rx_head = next;
        }
        // 如果满了,丢弃数据(或置溢出标志)
    }
}

int uart_recv_byte(uart_handle_t *handle, uint8_t *data) {
    if (rx_head == rx_tail) {
        return UART_NO_DATA;  // 缓冲区空
    }
    *data = rx_ring_buf[rx_tail];
    rx_tail = (rx_tail + 1) % 256;
    return UART_OK;
}

DMA模式的封装,核心是回调机制。DMA传输完成时触发中断,HAL层调用用户注册的回调函数。

// DMA模式收发接口
typedef void (*uart_dma_callback_t)(uart_handle_t *handle, uint32_t transferred);

int uart_send_dma(uart_handle_t *handle, uint8_t *data, uint32_t len, uart_dma_callback_t cb);
int uart_recv_dma(uart_handle_t *handle, uint8_t *buf, uint32_t len, uart_dma_callback_t cb);

这里有个关键点:DMA缓冲区必须保持有效直到传输完成。我见过有人传了局部变量给DMA,函数返回后栈空间被回收,DMA还在往那个地址写数据——结果就是内存踩踏,系统崩溃。所以DMA缓冲区要么是全局的,要么是动态分配的,要么用信号量等待传输完成。

避坑指南:我曾经在一个项目中,DMA接收用了栈上的缓冲区,调试时一切正常,发布后偶尔死机。查了三天才发现是DMA传输还没完成,缓冲区已经被覆盖了。从那以后,我规定DMA缓冲区必须由调用者保证生命周期,HAL层只做传输,不做生命周期管理。

4.6 UART HAL知识体系

下面这张图,把UART HAL层的核心逻辑串起来了。你可以看到,收发、配置、流控是三个独立维度,轮询、中断、DMA是三种传输模式。HAL层的作用就是把这些组合起来,对外提供统一的接口。

UART HAL层知识体系 UART HAL层知识体系 UART HAL 接口层 收发接口 配置接口 流控接口 单字节收发 多字节收发 波特率 数据格式 硬件流控 软件流控 轮询模式 中断模式 DMA模式 芯片寄存器操作层(不同芯片实现不同) HAL层屏蔽芯片差异,上层应用只需调用统一的收发、配置、流控接口,无需关心底层实现

从这张图你可以看到,HAL层处于中间位置。上面是应用层,下面是芯片寄存器层。HAL层的价值就是:应用层永远只跟HAL接口打交道,换芯片只需要换HAL层的实现。

经验之谈:设计UART HAL时,不要把中断服务函数暴露给上层。中断是芯片相关的,HAL层应该内部注册中断,通过回调或信号量通知上层。这样上层代码可以完全跟中断解耦,移植时只需要改HAL层的中断处理逻辑。