4. UART驱动抽象:UART HAL接口设计
UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂在于不同芯片的UART寄存器差异巨大。我早期做项目时,就吃过这个亏——换了个MCU,UART驱动几乎重写了一遍。后来我才意识到,HAL层的核心价值,就是把这种差异挡在门外。
4.1 UART HAL接口设计原则
设计UART的HAL接口,我个人习惯遵循三个原则:
- 最小化:接口数量越少越好,能合并的绝不拆分
- 无状态:每个接口只依赖传入的参数,不依赖内部全局变量
- 可组合:收发、配置、流控可以独立使用,也能组合工作
你想想看,如果每个芯片的UART驱动都长不一样,上层应用怎么移植?所以HAL接口必须抽象到“收发、配置、流控”这三个维度上。
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次,代码又长又容易漏——说白了,就是设计没想清楚。
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缓冲区要么是全局的,要么是动态分配的,要么用信号量等待传输完成。
4.6 UART HAL知识体系
下面这张图,把UART HAL层的核心逻辑串起来了。你可以看到,收发、配置、流控是三个独立维度,轮询、中断、DMA是三种传输模式。HAL层的作用就是把这些组合起来,对外提供统一的接口。
从这张图你可以看到,HAL层处于中间位置。上面是应用层,下面是芯片寄存器层。HAL层的价值就是:应用层永远只跟HAL接口打交道,换芯片只需要换HAL层的实现。