4. UART抽象层设计:封装UART的初始化、发送、接收与中断处理

UART 这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于不同 MCU 的寄存器配置差异巨大。我早期做项目时,经常要在 STM32、NXP、GD32 之间移植代码,每次都要重写一遍 UART 驱动,那感觉真是酸爽。

后来我悟出一个道理:与其每次重写,不如封装一层抽象。把 UART 的共性操作提炼出来,形成统一的 API。这样上层应用只管调用,底层驱动只管实现,中间用结构体指针连接。这就是我们今天要讲的 UART 抽象层设计。

4.1 抽象层要解决什么问题?

说白了,就是三个痛点:

  • 接口不统一:A 芯片用 USART1,B 芯片用 LPUART,初始化参数完全不同
  • 中断处理混乱:有的芯片用单字节中断,有的用 FIFO 中断,有的还带 DMA
  • 代码重复:每个项目都要写一遍波特率计算、校验位配置、中断使能

抽象层的目的,就是让上层代码只关心“发什么数据、收什么数据”,不关心“怎么配置寄存器”。

核心思想:定义一套通用的 UART 操作接口,底层驱动通过结构体函数指针挂接,上层应用通过句柄调用。

4.2 抽象层接口设计

我个人习惯把 UART 抽象层分成三层:

  1. 配置层:波特率、数据位、停止位、校验位、流控
  2. 数据层:单字节发送/接收、缓冲区发送/接收
  3. 事件层:中断回调、发送完成回调、接收超时回调

下面是我常用的接口定义:

/* uart_abstract.h */
#ifndef __UART_ABSTRACT_H__
#define __UART_ABSTRACT_H__

#include <stdint.h>

/* 波特率枚举 */
typedef enum {
    UART_BAUD_9600   = 9600,
    UART_BAUD_19200  = 19200,
    UART_BAUD_115200 = 115200,
    UART_BAUD_921600 = 921600
} uart_baud_t;

/* 数据位枚举 */
typedef enum {
    UART_DATA_5BIT = 5,
    UART_DATA_6BIT = 6,
    UART_DATA_7BIT = 7,
    UART_DATA_8BIT = 8
} uart_data_bits_t;

/* 停止位枚举 */
typedef enum {
    UART_STOP_1BIT = 1,
    UART_STOP_2BIT = 2
} uart_stop_bits_t;

/* 校验位枚举 */
typedef enum {
    UART_PARITY_NONE = 0,
    UART_PARITY_EVEN = 1,
    UART_PARITY_ODD  = 2
} uart_parity_t;

/* 配置结构体 */
typedef struct {
    uint32_t       baud_rate;
    uart_data_bits_t data_bits;
    uart_stop_bits_t stop_bits;
    uart_parity_t    parity;
    uint8_t          use_hw_flowctrl;  /* 0: 禁用, 1: 启用 */
} uart_config_t;

/* 事件回调类型 */
typedef void (*uart_rx_cb_t)(uint8_t data);
typedef void (*uart_tx_done_cb_t)(void);
typedef void (*uart_error_cb_t)(uint32_t error_flags);

/* 抽象接口结构体 */
typedef struct {
    int32_t (*init)(void *handle, uart_config_t *cfg);
    int32_t (*deinit)(void *handle);
    int32_t (*send_byte)(void *handle, uint8_t data);
    int32_t (*send_buffer)(void *handle, uint8_t *buf, uint32_t len);
    int32_t (*receive_byte)(void *handle, uint8_t *data);
    int32_t (*receive_buffer)(void *handle, uint8_t *buf, uint32_t len);
    void    (*enable_rx_interrupt)(void *handle, uint8_t enable);
    void    (*register_rx_callback)(void *handle, uart_rx_cb_t cb);
    void    (*register_tx_done_callback)(void *handle, uart_tx_done_cb_t cb);
    void    (*register_error_callback)(void *handle, uart_error_cb_t cb);
} uart_ops_t;

/* UART 设备结构体 */
typedef struct {
    void        *hw_handle;   /* 底层硬件句柄 */
    uart_ops_t  *ops;         /* 操作函数指针 */
    uart_config_t config;     /* 当前配置 */
    uint8_t      is_initialized;
} uart_device_t;

/* 通用 API */
int32_t uart_init(uart_device_t *dev, uart_config_t *cfg);
int32_t uart_send(uart_device_t *dev, uint8_t *buf, uint32_t len);
int32_t uart_receive(uart_device_t *dev, uint8_t *buf, uint32_t len);
void    uart_irq_handler(uart_device_t *dev);

#endif /* __UART_ABSTRACT_H__ */

嗯,这里要注意:hw_handle 是个 void * 指针,它指向具体的硬件寄存器基地址或驱动对象。这样设计的好处是,一个 uart_device_t 可以同时管理多个 UART 实例。

4.3 底层驱动实现示例

以 STM32 为例,我们来实现这个抽象接口。我曾经在一个项目中同时用了 STM32F4 和 GD32F3,两个芯片的 UART 寄存器布局几乎一样,但库函数完全不同。有了抽象层,我只需要写两份底层实现,上层代码完全不用改。

/* uart_stm32f4.c */
#include "uart_abstract.h"
#include "stm32f4xx_hal.h"

static UART_HandleTypeDef huart1;

static int32_t stm32_uart_init(void *handle, uart_config_t *cfg)
{
    UART_HandleTypeDef *huart = (UART_HandleTypeDef *)handle;
    
    huart->Instance = USART1;
    huart->Init.BaudRate = cfg->baud_rate;
    huart->Init.WordLength = (cfg->data_bits == UART_DATA_8BIT) ? 
                              UART_WORDLENGTH_8B : UART_WORDLENGTH_9B;
    huart->Init.StopBits = (cfg->stop_bits == UART_STOP_1BIT) ? 
                            UART_STOPBITS_1 : UART_STOPBITS_2;
    huart->Init.Parity = (cfg->parity == UART_PARITY_NONE) ? 
                          UART_PARITY_NONE : 
                          (cfg->parity == UART_PARITY_EVEN) ? 
                          UART_PARITY_EVEN : UART_PARITY_ODD;
    huart->Init.HwFlowCtl = cfg->use_hw_flowctrl ? 
                             UART_HWCONTROL_RTS_CTS : UART_HWCONTROL_NONE;
    
    return (HAL_UART_Init(huart) == HAL_OK) ? 0 : -1;
}

static int32_t stm32_uart_send_byte(void *handle, uint8_t data)
{
    UART_HandleTypeDef *huart = (UART_HandleTypeDef *)handle;
    return (HAL_UART_Transmit(huart, &data, 1, 100) == HAL_OK) ? 0 : -1;
}

/* 其他函数实现类似,此处省略 */

static uart_ops_t stm32_uart_ops = {
    .init = stm32_uart_init,
    .deinit = stm32_uart_deinit,
    .send_byte = stm32_uart_send_byte,
    .send_buffer = stm32_uart_send_buffer,
    .receive_byte = stm32_uart_receive_byte,
    .receive_buffer = stm32_uart_receive_buffer,
    .enable_rx_interrupt = stm32_uart_enable_rx_irq,
    .register_rx_callback = stm32_uart_reg_rx_cb,
    .register_tx_done_callback = stm32_uart_reg_tx_done_cb,
    .register_error_callback = stm32_uart_reg_err_cb,
};

uart_device_t uart1_dev = {
    .hw_handle = &huart1,
    .ops = &stm32_uart_ops,
    .is_initialized = 0
};

4.4 中断处理统一入口

中断处理是 UART 抽象层最容易被忽视的地方。你想想看,不同芯片的中断向量表不一样,中断标志位也不一样。但中断处理逻辑其实是一样的:收到数据 -> 调用回调 -> 清除标志。

我建议的做法是:在抽象层提供一个统一的中断处理入口,底层驱动在中断服务函数中调用这个入口。

/* 统一中断处理函数 */
void uart_irq_handler(uart_device_t *dev)
{
    uint8_t rx_data;
    uint32_t error_flags = 0;
    
    /* 检查是否有接收中断 */
    if (dev->ops->receive_byte(dev->hw_handle, &rx_data) == 0) {
        /* 调用注册的回调函数 */
        /* 这里假设回调函数指针存储在设备结构体中 */
        if (dev->rx_callback) {
            dev->rx_callback(rx_data);
        }
    }
    
    /* 检查是否有错误中断 */
    /* ... 错误处理逻辑 ... */
}

/* STM32 中断服务函数 */
void USART1_IRQHandler(void)
{
    uart_irq_handler(&uart1_dev);
}
小技巧:可以在 uart_device_t 中增加一个 rx_buffer 和 rx_index,实现环形缓冲区。这样中断接收的数据先存入缓冲区,上层应用通过 uart_receive 读取,避免数据丢失。

4.5 抽象层结构总览

下面这张图展示了 UART 抽象层的整体架构,从上层应用到硬件寄存器,每一层各司其职:

UART 抽象层架构图 应用层(Application) 抽象接口层(uart_ops_t) init | send | receive | enable_irq | register_callback 设备管理层(uart_device_t) hw_handle | ops | config | rx_callback | tx_done_callback 底层驱动层(Platform Specific) stm32_uart.c | gd32_uart.c | nxp_uart.c | ... 硬件寄存器层(HAL/LL) 上层应用只与抽象接口层交互,底层驱动更换时无需修改应用代码

4.6 使用示例

来看看实际怎么用。假设我们要初始化 UART1,波特率 115200,8 位数据,1 位停止位,无校验:

/* main.c */
#include "uart_abstract.h"

/* 声明外部设备对象 */
extern uart_device_t uart1_dev;

/* 接收回调函数 */
void uart1_rx_callback(uint8_t data)
{
    /* 收到数据后回显 */
    uart_send(&uart1_dev, &data, 1);
}

int main(void)
{
    uart_config_t cfg = {
        .baud_rate = UART_BAUD_115200,
        .data_bits = UART_DATA_8BIT,
        .stop_bits = UART_STOP_1BIT,
        .parity    = UART_PARITY_NONE,
        .use_hw_flowctrl = 0
    };
    
    /* 初始化 UART */
    if (uart_init(&uart1_dev, &cfg) != 0) {
        /* 初始化失败处理 */
        while(1);
    }
    
    /* 注册接收回调 */
    uart1_dev.ops->register_rx_callback(uart1_dev.hw_handle, uart1_rx_callback);
    
    /* 使能接收中断 */
    uart1_dev.ops->enable_rx_interrupt(uart1_dev.hw_handle, 1);
    
    /* 发送测试数据 */
    uint8_t test_msg[] = "Hello UART!";
    uart_send(&uart1_dev, test_msg, sizeof(test_msg));
    
    while(1) {
        /* 主循环可以干其他事 */
    }
}
避坑指南:我曾经在一个项目中忘记在中断服务函数里调用 uart_irq_handler,结果回调函数一直没触发。排查了半天才发现是中断入口没挂接。记住:抽象层只提供处理逻辑,中断向量表的挂接必须由底层驱动完成。

4.7 不同平台实现对比

下表总结了三个常见平台在实现 UART 抽象层时的差异点:

特性 STM32 (HAL) GD32 (Firmware) NXP LPC (SDK)
初始化函数 HAL_UART_Init usart_serial_init USART_Init
发送函数 HAL_UART_Transmit usart_data_transmit USART_SendData
接收函数 HAL_UART_Receive usart_data_receive USART_ReceiveData
中断使能 HAL_UART_Receive_IT usart_interrupt_enable NVIC_EnableIRQ
波特率计算 自动计算 需手动配置分频 自动计算
FIFO 支持 部分系列有 部分系列有 大部分有

你看,虽然底层函数名千差万别,但抽象层接口可以完全统一。这就是封装的价值。

4.8 扩展思考:环形缓冲区

如果只是简单的中断接收,数据量大了很容易丢包。我建议在抽象层内部集成一个环形缓冲区。这样中断处理函数只管往缓冲区写数据,上层应用通过 uart_receive 从缓冲区读数据,两者互不阻塞。

环形缓冲区的实现不复杂,但要注意临界区保护。中断里写数据时,主循环在读数据,可能会冲突。我的做法是:在写缓冲区前关中断,写完后开中断。或者用无锁环形缓冲区,利用原子操作。

经验之谈:环形缓冲区的大小要根据波特率和数据量来定。115200 波特率下,每秒约 11.5KB 数据,缓冲区至少 256 字节起步。如果数据量大,建议用 1KB 或更大。

好了,UART 抽象层的设计思路就讲到这里。核心就是:定义接口、分离实现、统一管理。下一层是 SPI 抽象层,思路类似,但 SPI 有主从模式、片选信号、时钟极性等更多细节。不过那是后面的事了。