UART驱动HAL封装:从轮询到中断的抽象之路
UART这个外设,说简单也简单,说复杂也复杂。我做了这么多年嵌入式,几乎每个项目都离不开它——调试打印、与传感器通信、甚至作为主控之间的数据桥梁。但真正让我意识到UART驱动需要好好封装的,是一次惨痛的教训。
那是一个物联网网关项目,用了三款不同的MCU。每换一个芯片,UART驱动就得重写一遍。更糟的是,应用层的协议栈代码和硬件操作混在一起,改一个波特率都要翻半天代码。嗯,从那以后,我开始认真思考:怎么把UART驱动做得既通用又高效?
UART驱动的两种工作模式
先说说UART最基本的两种模式:轮询和中断。你想想看,这两种模式本质上就是CPU怎么知道数据来了。
| 特性 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU占用 | 持续占用,不能做其他事 | 只在数据到达时响应 |
| 实时性 | 取决于轮询频率 | 立即响应 |
| 代码复杂度 | 简单直接 | 需要处理临界区 |
| 适用场景 | 调试、低速率、简单系统 | 多任务、高速率、复杂系统 |
| 功耗 | 高(CPU一直跑) | 低(可进入休眠) |
我个人习惯是:调试阶段先用轮询,稳定后切中断。但不管哪种模式,HAL层都要提供统一的接口。
HAL层抽象:让上层代码不感知硬件
核心思想很简单——把UART的操作抽象成几个标准接口。上层调用者不需要知道底层是STM32还是GD32,甚至不需要知道是轮询还是中断。
HAL层UART接口设计原则:
- 初始化:传入配置结构体,返回句柄
- 发送:阻塞或非阻塞,统一返回值
- 接收:轮询或中断,统一回调机制
- 控制:波特率、校验位、流控等
来看一个我常用的接口定义:
/* uart_hal.h - 硬件抽象层UART接口 */
typedef struct {
uint32_t baudrate;
uint8_t data_bits; /* 5, 6, 7, 8 */
uint8_t stop_bits; /* 1, 2 */
uint8_t parity; /* 0=none, 1=odd, 2=even */
uint8_t flow_ctrl; /* 0=none, 1=hw */
uint8_t mode; /* 0=poll, 1=interrupt */
void (*rx_callback)(uint8_t data);
} uart_config_t;
typedef void* uart_handle_t;
uart_handle_t uart_init(uart_config_t *cfg);
int32_t uart_send(uart_handle_t handle,
const uint8_t *data, uint32_t len);
int32_t uart_recv(uart_handle_t handle,
uint8_t *data, uint32_t len);
void uart_deinit(uart_handle_t handle);
注意看,这里用 void* 作为句柄。为什么?因为不同MCU的UART外设寄存器结构完全不同,用不透明指针可以隐藏底层实现。我在项目中遇到过有人直接把寄存器地址暴露给上层,结果换芯片时改得想哭。
环形缓冲区:中断驱动的基石
中断模式有个核心问题:中断服务程序要快进快出,不能在里面做复杂处理。那数据怎么办?存起来。用什么存?环形缓冲区。
说白了,环形缓冲区就是一个固定大小的数组,加上读指针和写指针。数据写进去,读出来,首尾相连。
我的经验:环形缓冲区的大小要仔细斟酌。太小容易溢出丢数据,太大浪费内存。一般我会根据波特率算一下:115200bps下,每字节约87μs,如果主循环100ms轮询一次,缓冲区至少能存1150字节。实际取2倍余量,2KB左右比较稳妥。
来看一个我常用的环形缓冲区实现:
/* ringbuf.h */
typedef struct {
uint8_t *buffer;
uint32_t size;
volatile uint32_t head; /* 写指针 */
volatile uint32_t tail; /* 读指针 */
} ringbuf_t;
/* 初始化 */
void ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size) {
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
/* 写入一个字节 */
int32_t ringbuf_put(ringbuf_t *rb, uint8_t data) {
uint32_t next = (rb->head + 1) % rb->size;
if (next == rb->tail) {
return -1; /* 缓冲区满 */
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
/* 读取一个字节 */
int32_t ringbuf_get(ringbuf_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return -1; /* 缓冲区空 */
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return 0;
}
注意:这里 head 和 tail 用了 volatile。为什么?因为中断和主循环会同时访问它们。不加 volatile,编译器可能优化掉读取操作,导致数据不同步。我曾经在这个坑里爬了整整两天。
中断服务程序的正确写法
有了环形缓冲区,中断服务程序就简单了:收到数据,塞进缓冲区,然后退出。主循环再从缓冲区取数据做处理。
/* UART中断服务程序(以STM32为例) */
void USART1_IRQHandler(void) {
uint8_t data;
if (LL_USART_IsActiveFlag_RXNE(USART1)) {
data = LL_USART_ReceiveData8(USART1);
ringbuf_put(&rx_ringbuf, data); /* 快速存入缓冲区 */
}
/* 发送中断同理 */
if (LL_USART_IsActiveFlag_TXE(USART1) && tx_pending) {
if (ringbuf_get(&tx_ringbuf, &data) == 0) {
LL_USART_TransmitData8(USART1, data);
} else {
LL_USART_DisableIT_TXE(USART1); /* 没有数据了,关中断 */
tx_pending = 0;
}
}
}
你看,中断里只做两件事:读数据、存缓冲区。绝对不要在这里做协议解析、打印日志、甚至调用 printf。我见过有人在中断里调 printf,结果递归调用把自己搞死了。
跨平台UART驱动框架实战
好了,理论说完了,咱们来点实际的。怎么设计一个能跑在不同MCU上的UART驱动框架?
我的做法是分层:
- HAL层:定义统一接口,提供环形缓冲区
- MCU适配层:实现具体的寄存器操作
- 应用层:只调用HAL接口,不关心底层
下面这张图展示了整个框架的结构:
这个框架的好处很明显:
- 应用代码写一次,换芯片只需重写适配层
- 环形缓冲区解耦了中断和主循环
- 统一接口让单元测试变得容易
实际项目中的避坑指南
最后分享几个我踩过的坑:
坑1:中断优先级 - 我曾经把UART中断优先级设得太高,结果高优先级中断里调用了环形缓冲区操作,而主循环也在操作同一个缓冲区。解决方案:关中断保护临界区,或者用无锁环形缓冲区(单生产者单消费者场景)。
坑2:缓冲区溢出 - 115200波特率下,如果主循环被高优先级任务阻塞太久,缓冲区会满。我后来加了一个溢出计数器,调试时特别有用。
我的建议:在HAL层加一个调试接口 uart_get_stats(),返回溢出次数、发送完成次数等统计信息。线上出问题时,这些数据能救命。
好了,UART驱动的HAL封装就讲到这里。记住一个原则:让上层代码像调用标准库一样使用UART,底层细节全部封装在HAL层里。这样,不管换什么芯片,你的应用代码都不用动。