4. UART抽象层设计:封装UART的初始化、发送、接收与中断处理
UART 这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于不同 MCU 的寄存器配置差异巨大。我早期做项目时,经常要在 STM32、NXP、GD32 之间移植代码,每次都要重写一遍 UART 驱动,那感觉真是酸爽。
后来我悟出一个道理:与其每次重写,不如封装一层抽象。把 UART 的共性操作提炼出来,形成统一的 API。这样上层应用只管调用,底层驱动只管实现,中间用结构体指针连接。这就是我们今天要讲的 UART 抽象层设计。
4.1 抽象层要解决什么问题?
说白了,就是三个痛点:
- 接口不统一:A 芯片用 USART1,B 芯片用 LPUART,初始化参数完全不同
- 中断处理混乱:有的芯片用单字节中断,有的用 FIFO 中断,有的还带 DMA
- 代码重复:每个项目都要写一遍波特率计算、校验位配置、中断使能
抽象层的目的,就是让上层代码只关心“发什么数据、收什么数据”,不关心“怎么配置寄存器”。
4.2 抽象层接口设计
我个人习惯把 UART 抽象层分成三层:
- 配置层:波特率、数据位、停止位、校验位、流控
- 数据层:单字节发送/接收、缓冲区发送/接收
- 事件层:中断回调、发送完成回调、接收超时回调
下面是我常用的接口定义:
/* 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 抽象层的整体架构,从上层应用到硬件寄存器,每一层各司其职:
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 从缓冲区读数据,两者互不阻塞。
环形缓冲区的实现不复杂,但要注意临界区保护。中断里写数据时,主循环在读数据,可能会冲突。我的做法是:在写缓冲区前关中断,写完后开中断。或者用无锁环形缓冲区,利用原子操作。
好了,UART 抽象层的设计思路就讲到这里。核心就是:定义接口、分离实现、统一管理。下一层是 SPI 抽象层,思路类似,但 SPI 有主从模式、片选信号、时钟极性等更多细节。不过那是后面的事了。