4. UART驱动封装:轮询模式与中断模式的HAL接口设计
UART,说白了就是嵌入式世界里最常用的“串口”。从调试打印到模块通信,几乎每个项目都离不开它。但不同芯片的UART寄存器、时钟配置、中断处理方式千差万别。我见过不少团队,换一次芯片就得重写一遍UART驱动,累不累?
这一章,我们就来聊聊怎么把UART的轮询模式和中断模式封装成统一的HAL接口。让你以后换平台时,上层应用代码几乎不用动。
uart_send() 和 uart_recv(),底层差异由HAL层屏蔽。
4.1 轮询模式 vs 中断模式:什么时候用哪个?
先别急着写代码。你得想清楚,你的项目到底需要哪种模式。我个人习惯是:调试阶段用轮询,产品阶段用中断。为什么?
| 特性 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU占用 | 高(一直等) | 低(有数据才处理) |
| 实时性 | 差(取决于轮询频率) | 好(硬件触发) |
| 代码复杂度 | 低 | 中(需要处理临界区) |
| 适用场景 | 调试、低速率、非实时 | 产品、高速率、实时通信 |
| 功耗 | 高(CPU无法休眠) | 低(可进入低功耗) |
你想想看,如果只是每秒打印一次日志,轮询完全够用。但如果是和传感器模块通信,数据随时可能来,那就必须用中断。我在一个项目中就吃过亏——用轮询读GPS模块,结果丢包率高达30%。后来改成中断,问题立刻解决。
4.2 HAL接口设计:先定“契约”
好的HAL接口,就像一份清晰的合同。上层应用和底层驱动都按这个合同办事。我建议先定义好数据结构,再定义函数接口。
4.2.1 数据结构定义
/* uart_hal_types.h */
typedef enum {
UART_MODE_POLLING = 0,
UART_MODE_INTERRUPT
} uart_mode_t;
typedef enum {
UART_BAUD_9600 = 9600,
UART_BAUD_19200 = 19200,
UART_BAUD_115200 = 115200
} uart_baud_t;
typedef enum {
UART_PARITY_NONE = 0,
UART_PARITY_EVEN,
UART_PARITY_ODD
} uart_parity_t;
typedef struct {
uint8_t port_id; /* 端口号,如0表示UART0 */
uart_mode_t mode; /* 轮询 or 中断 */
uart_baud_t baudrate; /* 波特率 */
uart_parity_t parity; /* 校验位 */
uint8_t data_bits; /* 数据位,通常为8 */
uint8_t stop_bits; /* 停止位,通常为1 */
void (*rx_callback)(uint8_t data); /* 中断接收回调,仅中断模式使用 */
} uart_config_t;
这里有个细节:rx_callback 只在中断模式下有效。轮询模式下,上层应用自己调用接收函数就行。我刚开始设计时,把回调函数放在配置结构体外面,结果接口变得很别扭。后来统一放进去,清爽多了。
4.2.2 核心函数接口
/* uart_hal.h */
/**
* @brief 初始化UART
* @param config 配置参数指针
* @return 0成功,-1失败
*/
int32_t uart_init(uart_config_t *config);
/**
* @brief 发送一个字节(轮询模式)
* @param data 要发送的数据
* @return 0成功,-1失败
*/
int32_t uart_send_byte(uint8_t data);
/**
* @brief 接收一个字节(轮询模式)
* @param data 存放接收数据的指针
* @param timeout_ms 超时时间,单位毫秒
* @return 0成功,-1超时
*/
int32_t uart_recv_byte(uint8_t *data, uint32_t timeout_ms);
/**
* @brief 发送缓冲区(中断模式)
* @param buf 数据缓冲区
* @param len 数据长度
* @return 实际发送的字节数
*/
int32_t uart_send_buf(const uint8_t *buf, uint32_t len);
/**
* @brief 获取接收缓冲区中未读数据长度(中断模式)
* @return 未读数据字节数
*/
uint32_t uart_available(void);
/**
* @brief 从接收缓冲区读取一个字节(中断模式)
* @param data 存放数据的指针
* @return 0成功,-1缓冲区为空
*/
int32_t uart_read_byte(uint8_t *data);
注意看,轮询模式的接口是 uart_send_byte 和 uart_recv_byte,而中断模式多了 uart_send_buf 和 uart_available。为什么这么设计?
轮询模式下,发送和接收都是阻塞的,一次一个字节最直接。中断模式下,我们通常用环形缓冲区(ring buffer)来缓存数据,所以需要 uart_available 来查询还有多少数据没读。
4.3 轮询模式实现:简单直接
轮询模式的实现,说白了就是死等。发送时等发送寄存器空,接收时等接收寄存器非空。以STM32为例:
/* uart_hal_polling.c */
int32_t uart_send_byte(uint8_t data)
{
/* 等待发送数据寄存器空 */
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = data;
return 0;
}
int32_t uart_recv_byte(uint8_t *data, uint32_t timeout_ms)
{
uint32_t tick_start = get_tick();
while (!(USART1->SR & USART_SR_RXNE)) {
if (get_tick() - tick_start > timeout_ms) {
return -1; /* 超时 */
}
}
*data = (uint8_t)(USART1->DR);
return 0;
}
嗯,这里要注意:get_tick() 函数需要依赖一个系统滴答定时器,通常是1ms中断一次。如果没有这个基础,轮询超时就很难实现。我在一个裸机项目里就踩过这个坑——没有系统时钟,只能用for循环做粗略延时,结果不同编译器优化等级下,超时时间完全不准。
4.4 中断模式实现:环形缓冲区是关键
中断模式的核心,是硬件中断服务函数(ISR)和环形缓冲区的配合。ISR负责把数据从硬件寄存器搬到缓冲区,上层应用再从缓冲区读取。
/* uart_hal_interrupt.c */
#define RX_BUF_SIZE 256
static uint8_t rx_buf[RX_BUF_SIZE];
static volatile uint16_t rx_head = 0;
static volatile uint16_t rx_tail = 0;
/* 中断服务函数(由芯片的中断向量表调用) */
void USART1_IRQHandler(void)
{
uint8_t data;
uint16_t next;
if (USART1->SR & USART_SR_RXNE) {
data = (uint8_t)(USART1->DR);
next = (rx_head + 1) % RX_BUF_SIZE;
if (next != rx_tail) { /* 缓冲区未满 */
rx_buf[rx_head] = data;
rx_head = next;
}
/* 如果缓冲区满了,直接丢弃数据 */
}
}
int32_t uart_read_byte(uint8_t *data)
{
if (rx_head == rx_tail) {
return -1; /* 缓冲区为空 */
}
*data = rx_buf[rx_tail];
rx_tail = (rx_tail + 1) % RX_BUF_SIZE;
return 0;
}
uint32_t uart_available(void)
{
/* 注意:这里需要处理溢出情况 */
return (uint32_t)((rx_head - rx_tail) % RX_BUF_SIZE);
}
4.5 跨平台移植:HAL层如何隔离差异?
不同芯片的UART寄存器、中断号、时钟配置完全不同。HAL层要做的,就是把差异封装在底层。我常用的方法是:
- 定义平台抽象层(PAL):在HAL下面再加一层,专门处理寄存器操作。
- 使用宏或内联函数:把寄存器读写操作抽象成宏,不同平台提供不同实现。
- 配置表驱动:把波特率、时钟分频等参数做成表格,避免硬编码。
举个例子,寄存器读写宏:
/* uart_hal_port.h */
/* STM32实现 */
#define UART_REG_TX_READY(port) ((port)->SR & USART_SR_TXE)
#define UART_REG_WRITE(port, d) ((port)->DR = (d))
/* NXP LPC实现 */
#define UART_REG_TX_READY(port) ((port)->LSR & (1 << 5))
#define UART_REG_WRITE(port, d) ((port)->THR = (d))
这样,上层的 uart_send_byte 函数只需要调用 UART_REG_TX_READY 和 UART_REG_WRITE,底层差异被完全隔离。
4.6 知识结构图:UART HAL设计全景
下面这张图,把UART HAL设计的整体脉络画出来了。你可以看到,从应用层到底层寄存器,中间经过了几层抽象。
4.7 总结:设计HAL接口的几个原则
最后,我把自己多年总结的几个原则列出来,供你参考:
- 接口要稳定:一旦发布,尽量不改。我见过一个项目,HAL接口改了三次,上层应用跟着改了三遍,开发周期直接翻倍。
- 不要过度抽象:如果某个功能只有特定芯片支持,不要强行塞进HAL接口。可以用扩展接口或条件编译处理。
- 性能可预测:轮询模式的超时时间、中断模式的响应延迟,都要有明确的文档说明。我在一个项目中,因为没写清楚中断响应时间,导致上层任务调度出了问题。
- 测试要覆盖两种模式:轮询和中断的测试用例要分开写。我曾经以为中断模式没问题,结果发现环形缓冲区在高速率下会丢数据——因为ISR里忘了关中断。
嗯,UART驱动封装就聊到这里。记住,好的HAL接口,是让上层开发者感觉不到底层存在。你做到了吗?