硬件抽象层(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库当万能药,结果在低端MCU上跑出了几十微秒的GPIO翻转延迟。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强转为实际的寄存器结构体指针。这样换芯片时,只需要换底层实现文件,接口头文件不动。

经验之谈: 我曾在一次项目中,把GPIO接口从STM32F4移植到NXP LPC1768,只改了底层实现文件,上层200多个调用点一个没动。这就是HAL的价值。

实战: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的软件消抖。具体做法是:连续两次读取一致才认为状态变化。长按检测则用系统滴答计时。

核心思路: 驱动层只处理“硬件状态”到“逻辑状态”的转换。按键是按下还是释放、LED是亮还是灭,这些是驱动的事。至于按下后做什么、亮多久,那是应用层的事。

我曾经在一个项目中,把按键驱动写得过于复杂,把双击、三击、组合键都塞进去了。结果产品改需求时,驱动层改得一团糟。后来我学乖了:驱动只做最基础的状态检测,复杂逻辑交给应用层。

本章知识结构

下面这张图,把GPIO封装的层次关系画清楚了。你可以看到,应用层只跟LED/按键驱动打交道,驱动层调用HAL接口,HAL再操作寄存器。每一层各司其职。

GPIO驱动封装层次结构 应用层 main() / 任务函数 设备驱动层 LED_Driver / Key_Driver 消抖、状态机、逻辑电平转换 硬件抽象层(HAL) HAL_GPIO_SetPin / HAL_GPIO_ReadPin 统一接口,隐藏寄存器细节 MCU寄存器层 GPIOx->BSRR / GPIOx->IDR 每层只依赖下一层,换MCU只需改HAL和寄存器层,驱动和应用层不动