硬件抽象层(HAL)设计(上):GPIO驱动封装
说到嵌入式软件分层,硬件抽象层(HAL)是绕不开的核心。我做了十几年嵌入式,见过太多项目因为硬件耦合太紧,换颗MCU就得重写大半代码。说白了,HAL就是给上层软件一个“统一接口”,让应用代码不直接跟寄存器打交道。
这一章,我们聚焦在最基础的GPIO上。别小看它,LED闪烁、按键检测、甚至I2C模拟,底层都是GPIO。把GPIO封装好了,整个HAL的架子就立起来了。
寄存器操作 vs 标准库 vs HAL库
先看三种操作GPIO的方式。我当年刚入行时,用的就是寄存器操作。那时候MCU资源紧张,库函数都嫌大。现在情况变了,但理解寄存器仍然是基本功。
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 寄存器操作 | 执行最快,代码最小 | 可读性差,移植困难 | Bootloader、中断极速响应 |
| 标准库 | 封装了寄存器,可读性好 | 不同厂商库不通用 | 中小型项目,单芯片 |
| HAL库 | 跨芯片、跨系列移植方便 | 有性能开销,代码体积大 | 大型项目,多平台 |
举个例子,点亮一个LED。寄存器操作直接写:
// 寄存器方式 - STM32F103
GPIOB->BSRR = GPIO_Pin_0; // 置位PB0
标准库方式:
// 标准库方式
GPIO_SetBits(GPIOB, GPIO_Pin_0);
HAL库方式:
// HAL库方式
HAL_GPIO_WritePin(GPIOB, GPIO_Pin_0, GPIO_PIN_SET);
三种写法,效果一样。但如果你从STM32F1换到GD32F3,寄存器地址变了,标准库函数名变了,只有HAL接口可以不改上层代码。
设计可移植的HAL接口
好的HAL接口长什么样?我个人的习惯是:接口只描述“做什么”,不描述“怎么做”。比如:
HAL_GPIO_SetPin(port, pin)—— 置位HAL_GPIO_ResetPin(port, pin)—— 复位HAL_GPIO_TogglePin(port, pin)—— 翻转HAL_GPIO_ReadPin(port, pin)—— 读取
你看,这些接口里没有出现任何芯片相关的寄存器名。上层应用只需要知道“我要让LED亮”,不需要知道是哪个寄存器。
具体实现时,我通常用结构体封装端口和引脚:
// hal_gpio.h
typedef struct {
void* port; // 端口基地址,不同MCU类型不同
uint16_t pin; // 引脚号
} HAL_GPIO_Pin_t;
void HAL_GPIO_Init(HAL_GPIO_Pin_t* gpio, uint32_t mode);
void HAL_GPIO_SetPin(HAL_GPIO_Pin_t* gpio);
void HAL_GPIO_ResetPin(HAL_GPIO_Pin_t* gpio);
uint8_t HAL_GPIO_ReadPin(HAL_GPIO_Pin_t* gpio);
然后在具体芯片的实现文件里,把void* port强转为实际的寄存器结构体指针。这样换芯片时,只需要换底层实现文件,接口头文件不动。
实战:LED与按键驱动
理论说完了,咱们动手。我设计一个简单的LED驱动和按键驱动,全部基于上面定义的HAL接口。
LED驱动
// led_driver.h
#include "hal_gpio.h"
typedef struct {
HAL_GPIO_Pin_t gpio;
uint8_t active_level; // 0:低电平亮, 1:高电平亮
} LED_Device_t;
void LED_Init(LED_Device_t* led);
void LED_On(LED_Device_t* led);
void LED_Off(LED_Device_t* led);
void LED_Toggle(LED_Device_t* led);
实现文件里,LED_On会根据active_level决定调用HAL_GPIO_SetPin还是HAL_GPIO_ResetPin。这样同一个驱动,既能控制高电平亮的LED,也能控制低电平亮的LED。
按键驱动
按键比LED复杂一点,因为有抖动。我习惯在驱动层做消抖,而不是让应用层操心。
// key_driver.h
#include "hal_gpio.h"
typedef enum {
KEY_RELEASED = 0,
KEY_PRESSED,
KEY_LONG_PRESS
} KeyState_t;
typedef struct {
HAL_GPIO_Pin_t gpio;
uint8_t press_level; // 按下时的电平
uint32_t long_press_ms; // 长按阈值
// 内部状态
uint8_t last_raw;
uint32_t press_start;
KeyState_t state;
} Key_Device_t;
void Key_Init(Key_Device_t* key);
KeyState_t Key_Scan(Key_Device_t* key);
Key_Scan函数里,我做了10ms的软件消抖。具体做法是:连续两次读取一致才认为状态变化。长按检测则用系统滴答计时。
我曾经在一个项目中,把按键驱动写得过于复杂,把双击、三击、组合键都塞进去了。结果产品改需求时,驱动层改得一团糟。后来我学乖了:驱动只做最基础的状态检测,复杂逻辑交给应用层。
本章知识结构
下面这张图,把GPIO封装的层次关系画清楚了。你可以看到,应用层只跟LED/按键驱动打交道,驱动层调用HAL接口,HAL再操作寄存器。每一层各司其职。