4. UART驱动封装:轮询模式与中断模式的HAL接口设计

UART,说白了就是嵌入式世界里最常用的“串口”。从调试打印到模块通信,几乎每个项目都离不开它。但不同芯片的UART寄存器、时钟配置、中断处理方式千差万别。我见过不少团队,换一次芯片就得重写一遍UART驱动,累不累?

这一章,我们就来聊聊怎么把UART的轮询模式和中断模式封装成统一的HAL接口。让你以后换平台时,上层应用代码几乎不用动。

核心目标: 设计一套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 来查询还有多少数据没读。

我的经验: 环形缓冲区的大小要仔细算。我曾经在一个项目中,接收缓冲区只设了64字节,结果GPS模块一秒钟发10条NMEA语句,每条80字节,直接溢出。后来改成512字节才稳定。建议至少设为最大数据帧长度的2倍。

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);
}
避坑指南: 我曾经在ISR里直接调用用户回调函数,结果回调函数里做了耗时操作,导致中断响应变慢,丢数据。正确的做法是:ISR只负责把数据放入环形缓冲区,用户回调在任务或主循环中处理。如果非要回调,请用“软中断”或“任务通知”机制。

4.5 跨平台移植:HAL层如何隔离差异?

不同芯片的UART寄存器、中断号、时钟配置完全不同。HAL层要做的,就是把差异封装在底层。我常用的方法是:

  1. 定义平台抽象层(PAL):在HAL下面再加一层,专门处理寄存器操作。
  2. 使用宏或内联函数:把寄存器读写操作抽象成宏,不同平台提供不同实现。
  3. 配置表驱动:把波特率、时钟分频等参数做成表格,避免硬编码。

举个例子,寄存器读写宏:

/* 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设计的整体脉络画出来了。你可以看到,从应用层到底层寄存器,中间经过了几层抽象。

UART HAL 设计全景图 应用层(App) HAL 接口层 uart_init() | uart_send_byte() | uart_recv_byte() | uart_read_byte() 轮询模式实现 中断模式实现 平台抽象层(PAL) 平台抽象层(PAL) 芯片寄存器(如STM32 USART) 芯片寄存器(如NXP LPC UART) 说明:应用层只与HAL接口层交互,不关心底层是轮询还是中断,也不关心是什么芯片。

4.7 总结:设计HAL接口的几个原则

最后,我把自己多年总结的几个原则列出来,供你参考:

  • 接口要稳定:一旦发布,尽量不改。我见过一个项目,HAL接口改了三次,上层应用跟着改了三遍,开发周期直接翻倍。
  • 不要过度抽象:如果某个功能只有特定芯片支持,不要强行塞进HAL接口。可以用扩展接口或条件编译处理。
  • 性能可预测:轮询模式的超时时间、中断模式的响应延迟,都要有明确的文档说明。我在一个项目中,因为没写清楚中断响应时间,导致上层任务调度出了问题。
  • 测试要覆盖两种模式:轮询和中断的测试用例要分开写。我曾经以为中断模式没问题,结果发现环形缓冲区在高速率下会丢数据——因为ISR里忘了关中断。

嗯,UART驱动封装就聊到这里。记住,好的HAL接口,是让上层开发者感觉不到底层存在。你做到了吗?