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层的整体架构,以及环形缓冲区在其中的位置:
4.6 设计要点总结
最后,我把UART HAL层设计的几个关键点列出来,方便你对照检查:
- 接口与实现分离:头文件只暴露API,内部结构体对外不可见
- 参数配置化:波特率、数据位等参数由上层传入,驱动不写死
- 环形缓冲区大小必须是2的幂:用位运算替代取模,提高效率
- 头尾指针用volatile修饰:防止编译器优化导致中断和主循环的竞争
- 中断中只做最少的操作:把数据塞进环形缓冲区就返回,复杂处理交给主循环
- 回调函数注册机制:让上层可以感知事件,但不强制上层必须处理
个人建议:刚开始做HAL层设计时,不要追求一步到位。先把阻塞模式跑通,再加中断,最后加DMA。环形缓冲区可以先固定大小(比如256字节),等系统稳定了再考虑动态配置。一步一步来,不容易翻车。