3、硬件抽象层(HAL)设计:MCU外设驱动抽象、GPIO/UART/SPI接口封装、中断管理

好,咱们进入第三章。这一章我打算聊聊硬件抽象层,也就是 HAL 的设计。

说实话,很多做上层应用的朋友,一听到「硬件抽象」就觉得头大。觉得那是驱动工程师的事。但你想啊,一个全栈开发者,如果不懂 HAL 的设计思路,移植协议栈的时候,那真是寸步难行。

我自己的经验是,HAL 层设计得好不好,直接决定了你后面三个月是喝茶还是加班。嗯,一点不夸张。

3.1 为什么要搞硬件抽象?

说白了,就是为了「换芯不换栈」。你今天用 STM32,明天换成 GD32,后天可能又换成 ESP32。如果没有 HAL 层,你的协议栈代码里到处都是寄存器操作,那移植起来……啧啧,我经历过,太痛苦了。

HAL 的核心目标就一个:把硬件相关的操作,封装成统一的接口。上层代码只调用这些接口,不关心底层是哪个 MCU。

核心原则: 上层协议栈永远不直接操作寄存器。所有硬件访问,必须经过 HAL 层。

3.2 MCU 外设驱动抽象

先说说外设驱动怎么抽象。我习惯把每个外设抽象成一个「对象」。这个对象包含两部分:

  • 配置结构体:描述外设的工作参数
  • 操作函数指针:指向具体的硬件实现

举个例子,UART 的抽象可以这样搞:

/* UART 配置结构体 */
typedef struct {
    uint32_t baudrate;      /* 波特率 */
    uint8_t  data_bits;     /* 数据位:5/6/7/8 */
    uint8_t  stop_bits;     /* 停止位:1/2 */
    uint8_t  parity;        /* 校验位:NONE/ODD/EVEN */
} hal_uart_config_t;

/* UART 操作句柄 */
typedef struct {
    void   (*init)(hal_uart_config_t *cfg);
    int32_t (*send)(uint8_t *data, uint32_t len);
    int32_t (*recv)(uint8_t *data, uint32_t timeout);
    void   (*irq_handler)(void);
} hal_uart_ops_t;

你看,上层代码拿到 hal_uart_ops_t 这个结构体,直接调用 ops->send() 就行。至于底层是用的中断还是 DMA,上层根本不用管。

我的小技巧: 每个外设的抽象接口,尽量控制在 5-8 个函数以内。太多了,移植起来累;太少了,功能不够用。

3.3 GPIO/UART/SPI 接口封装

这三个接口,是协议栈移植中最常用的。我一个个说。

3.3.1 GPIO 封装

GPIO 看似简单,但坑不少。我建议封装成三个函数:

/* GPIO 操作接口 */
void hal_gpio_init(uint32_t pin, uint32_t mode);   /* 初始化 */
void hal_gpio_set(uint32_t pin, uint8_t level);    /* 输出电平 */
uint8_t hal_gpio_get(uint32_t pin);                /* 读取电平 */

这里要注意,pin 参数不要直接传硬件引脚号。我习惯定义一个虚拟引脚映射表:

/* 虚拟引脚定义 */
#define PIN_LED_RED     0
#define PIN_LED_GREEN   1
#define PIN_CS_FLASH    2
#define PIN_INT_BUTTON  3

/* 映射表,移植时只需改这里 */
static const struct {
    void *port;
    uint16_t pin;
} pin_map[] = {
    [PIN_LED_RED]    = {GPIOA, GPIO_PIN_5},
    [PIN_LED_GREEN]  = {GPIOB, GPIO_PIN_0},
    [PIN_CS_FLASH]   = {GPIOA, GPIO_PIN_15},
    [PIN_INT_BUTTON] = {GPIOC, GPIO_PIN_13},
};

这样做的好处是,上层代码只认 PIN_LED_RED 这种逻辑编号。换芯片?改一下映射表就行。

3.3.2 UART 封装

UART 在协议栈里,主要用来做调试输出和 AT 指令通信。我一般封装成这样:

/* UART 接口 */
int32_t hal_uart_send(uint8_t ch);                 /* 发送一个字节 */
int32_t hal_uart_send_buf(uint8_t *buf, uint32_t len); /* 发送缓冲区 */
int32_t hal_uart_recv_byte(uint8_t *ch, uint32_t timeout_ms); /* 接收一个字节 */

我曾经在一个项目里,因为 UART 接收没有加超时机制,导致协议栈死锁。从那以后,我所有的接收函数都强制加上了超时参数。哪怕你传 0xFFFFFFFF 表示无限等待,也得有这个参数。

注意: UART 的发送和接收,一定要考虑缓冲区溢出。我建议在 HAL 层内部实现一个环形缓冲区,防止数据丢失。

3.3.3 SPI 封装

SPI 主要用于和无线模块、传感器通信。封装要点是:

/* SPI 接口 */
int32_t hal_spi_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint32_t len);
int32_t hal_spi_cs_control(uint8_t cs_pin, uint8_t level);

这里有个细节:片选信号(CS)最好单独控制。不要把它放在 SPI 硬件自动控制里。为什么?因为有些从设备在 CS 拉低后需要一段延时才能开始通信,硬件自动控制往往做不到这么灵活。

3.4 中断管理

中断管理,是 HAL 设计里最容易出问题的地方。我见过太多人在这里翻车。

3.4.1 中断优先级

首先,中断优先级要统一规划。我习惯用一张表来管理:

中断源 抢占优先级 子优先级 说明
系统滴答定时器 0 0 最高优先级,用于时间基准
无线模块中断 1 0 协议栈核心中断
UART 接收中断 2 0 数据接收
定时器中断 3 0 周期性任务
GPIO 外部中断 3 1 按键等

为什么要这么分?因为协议栈的中断响应时间是有要求的。无线模块中断如果被其他中断阻塞太久,可能会丢包。嗯,这个坑我踩过。

3.4.2 中断处理函数封装

我建议把中断处理函数也抽象出来。上层协议栈注册一个回调,底层中断触发时调用这个回调:

/* 中断回调注册 */
typedef void (*hal_irq_callback_t)(void *arg);

int32_t hal_irq_register(uint32_t irq_num, hal_irq_callback_t cb, void *arg);
int32_t hal_irq_enable(uint32_t irq_num);
int32_t hal_irq_disable(uint32_t irq_num);

这样做的好处是,协议栈可以自己决定什么时候开中断、什么时候关中断。比如在发送关键数据包时,可以先关掉不必要的中断,防止被打扰。

避坑指南: 中断处理函数里,千万不要做耗时操作。我曾经在中断里直接调用 printf 打印调试信息,结果系统直接卡死。中断里只做标记,具体处理放到主循环或者任务里。

3.4.3 临界区保护

最后,说说临界区。协议栈里经常有共享资源访问,比如链表操作、缓冲区读写。这时候需要关中断保护:

/* 临界区操作 */
uint32_t hal_critical_enter(void);   /* 关中断,返回当前状态 */
void     hal_critical_exit(uint32_t state); /* 恢复中断状态 */

用法很简单:

uint32_t state = hal_critical_enter();
/* 这里操作共享资源,不会被中断打断 */
hal_critical_exit(state);

注意,hal_critical_enter 返回的是当前的中断状态,hal_critical_exit 恢复这个状态。这样做是为了支持嵌套调用——你可以在一个临界区里再进入另一个临界区,不会出问题。

3.5 小结

好了,这一章的内容就这些。总结一下:

  • HAL 的核心是「抽象」,把硬件差异隔离在底层
  • GPIO/UART/SPI 的接口要简洁、统一、可移植
  • 中断管理要规划优先级、封装回调、保护临界区

下一章,我会聊聊内存管理。协议栈里的内存池怎么设计?动态分配和静态分配怎么选?到时候见。