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 的接口要简洁、统一、可移植
- 中断管理要规划优先级、封装回调、保护临界区
下一章,我会聊聊内存管理。协议栈里的内存池怎么设计?动态分配和静态分配怎么选?到时候见。