3. GPIO驱动HAL封装:从寄存器到标准接口

GPIO 是嵌入式开发里最基础、也最绕不开的外设。你想想看,点灯、按键、中断、模拟协议……几乎每个项目都要跟它打交道。但问题也出在这里——不同芯片的 GPIO 寄存器长得完全不一样。STM32 有 CRL/CRH、ODR、IDR;NXP 的 LPC 系列是 GPIO_DIR、GPIO_DATA;到了国产 GD32 又换了一套。

我早年做项目时,就吃过这个亏。产品要换主控,结果 GPIO 驱动几乎重写了一遍。从那以后,我下定决心:必须把 GPIO 的 HAL 层抽象好。今天这一章,我们就来聊聊怎么设计一套可移植的 GPIO HAL 层。

3.1 GPIO标准接口定义

抽象的第一步,是定义接口。说白了,就是问自己一个问题:上层驱动到底需要 GPIO 做什么?

答案其实很固定:

  • 初始化:设置引脚模式(输入、输出、复用、模拟)
  • 写操作:输出高电平或低电平
  • 读操作:读取引脚当前电平
  • 中断配置:使能/禁止中断,设置触发边沿

基于这个理解,我一般会定义这样一组接口:

/* gpio_hal.h */
#ifndef __GPIO_HAL_H__
#define __GPIO_HAL_H__

#include <stdint.h>

/* GPIO 引脚模式枚举 */
typedef enum {
    GPIO_MODE_INPUT         = 0x00,
    GPIO_MODE_OUTPUT_PP     = 0x01,  /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD     = 0x02,  /* 开漏输出 */
    GPIO_MODE_AF_PP         = 0x03,  /* 复用推挽 */
    GPIO_MODE_AF_OD         = 0x04,  /* 复用开漏 */
    GPIO_MODE_ANALOG        = 0x05
} gpio_mode_t;

/* 中断触发方式枚举 */
typedef enum {
    GPIO_TRIGGER_RISING     = 0x00,
    GPIO_TRIGGER_FALLING    = 0x01,
    GPIO_TRIGGER_BOTH       = 0x02
} gpio_trigger_t;

/* GPIO 引脚状态 */
typedef enum {
    GPIO_LOW  = 0,
    GPIO_HIGH = 1
} gpio_pin_state_t;

/* 初始化 */
void gpio_hal_init(uint32_t port, uint32_t pin, gpio_mode_t mode);

/* 写操作 */
void gpio_hal_write(uint32_t port, uint32_t pin, gpio_pin_state_t state);

/* 读操作 */
gpio_pin_state_t gpio_hal_read(uint32_t port, uint32_t pin);

/* 中断配置 */
void gpio_hal_irq_enable(uint32_t port, uint32_t pin, gpio_trigger_t trigger);
void gpio_hal_irq_disable(uint32_t port, uint32_t pin);

/* 中断回调注册 */
typedef void (*gpio_irq_callback_t)(uint32_t port, uint32_t pin);
void gpio_hal_irq_register(uint32_t port, uint32_t pin, gpio_irq_callback_t cb);

#endif /* __GPIO_HAL_H__ */

核心思路:接口参数用 port 和 pin 编号,而不是具体的寄存器地址。这样上层代码就完全跟硬件解耦了。

3.2 多平台寄存器差异抽象

接口定义好了,接下来就是最头疼的部分——怎么把不同芯片的寄存器差异藏起来?

我的做法是:分层映射。在 HAL 接口和硬件寄存器之间,加一层“平台适配层”。

举个例子,STM32 的 GPIO 输出寄存器是 BSRR(写 1 置位,写 1 复位),而 NXP LPC 系列是 GPIO_DATA(直接写 0/1)。差异很大吧?但在适配层里,我们只需要做一件事:

/* platform_gpio.c - STM32 实现 */
void gpio_hal_write(uint32_t port, uint32_t pin, gpio_pin_state_t state) {
    GPIO_TypeDef *gpio = (GPIO_TypeDef *)port;
    if (state == GPIO_HIGH) {
        gpio->BSRR = (1U << pin);      /* 置位 */
    } else {
        gpio->BSRR = (1U << (pin + 16)); /* 复位 */
    }
}

/* platform_gpio.c - NXP LPC 实现 */
void gpio_hal_write(uint32_t port, uint32_t pin, gpio_pin_state_t state) {
    uint32_t *gpio_data = (uint32_t *)(port + 0x3FFC); /* 数据寄存器偏移 */
    if (state == GPIO_HIGH) {
        *gpio_data |= (1U << pin);
    } else {
        *gpio_data &= ~(1U << pin);
    }
}

你看,上层调用 gpio_hal_write 时,根本不知道底层是 BSRR 还是 GPIO_DATA。这就是抽象的价值。

我的习惯:把 port 参数直接传寄存器基地址。这样既避免了全局变量,又方便扩展。不同芯片只要改基地址宏定义就行。

3.3 实战:编写可移植的GPIO HAL层

光说不练假把式。我们拿一个实际场景来走一遍:在 STM32F103 和 GD32F103 上实现同一套 GPIO HAL。

这两款芯片的 GPIO 寄存器几乎一样,但中断控制寄存器完全不同。STM32 用 EXTI 和 NVIC,GD32 用 EXTI 和 SYSCFG。嗯,这里要注意:中断配置是 GPIO HAL 里最容易出坑的地方。

我曾经在一个项目中,把 STM32 的 EXTI 配置代码直接搬到 GD32 上,结果中断死活不触发。查了两天才发现,GD32 需要额外配置 SYSCFG_EXTICR 寄存器来映射引脚。从那以后,我养成了一个习惯:中断配置单独抽成一个函数,每个平台各自实现。

下面是完整的 HAL 层实现框架:

/* gpio_hal.c - 通用框架 */
#include "gpio_hal.h"

/* 内部函数声明:平台相关实现 */
static void platform_gpio_init(uint32_t port, uint32_t pin, gpio_mode_t mode);
static void platform_gpio_irq_config(uint32_t port, uint32_t pin, gpio_trigger_t trigger, uint8_t enable);

/* HAL 接口实现 */
void gpio_hal_init(uint32_t port, uint32_t pin, gpio_mode_t mode) {
    /* 参数校验 */
    if (port == 0 || pin > 15) return;
    platform_gpio_init(port, pin, mode);
}

void gpio_hal_write(uint32_t port, uint32_t pin, gpio_pin_state_t state) {
    /* 直接调用平台写函数 */
    platform_gpio_write(port, pin, state);
}

gpio_pin_state_t gpio_hal_read(uint32_t port, uint32_t pin) {
    return platform_gpio_read(port, pin);
}

void gpio_hal_irq_enable(uint32_t port, uint32_t pin, gpio_trigger_t trigger) {
    platform_gpio_irq_config(port, pin, trigger, 1);
}

void gpio_hal_irq_disable(uint32_t port, uint32_t pin) {
    platform_gpio_irq_config(port, pin, GPIO_TRIGGER_RISING, 0);
}

避坑指南:我曾经在中断回调里直接操作 GPIO 寄存器,导致中断嵌套死锁。后来我规定:中断回调里只做标志位设置,具体操作放到主循环或任务里处理。这个习惯救了我好几次。

3.4 知识结构总览

为了让你更直观地理解 GPIO HAL 层的架构,我画了一张图:

GPIO HAL 层架构图 应用层(业务代码) HAL 接口层(gpio_hal.h) 平台适配层(platform_gpio.c) 硬件寄存器层(CRL/CRH、ODR、IDR、BSRR、EXTI...) 说明: 应用层只调用 HAL 接口,不关心硬件细节。 平台适配层负责将 HAL 接口映射到具体寄存器操作。

3.5 接口与平台差异对照表

为了让你更清楚不同平台的差异,我整理了一个对照表:

功能 HAL 接口 STM32 实现 GD32 实现 NXP LPC 实现
初始化 gpio_hal_init CRL/CRH + ODR CTL + OCTL GPIO_DIR + GPIO_DATA
写操作 gpio_hal_write BSRR BOP GPIO_DATA
读操作 gpio_hal_read IDR ISTAT GPIO_DATA
中断使能 gpio_hal_irq_enable EXTI + NVIC EXTI + SYSCFG GPIO_IS + NVIC

我的建议:刚开始做 HAL 抽象时,不要追求“一套代码覆盖所有平台”。先针对 2-3 个主流平台做适配,等模式成熟了再扩展。步子迈大了,容易扯着蛋。

3.6 小结

这一章我们聊了 GPIO HAL 层的核心设计思路:

  • 标准接口定义:初始化、读写、中断配置,参数用 port/pin 编号
  • 寄存器差异抽象:通过平台适配层隔离硬件差异
  • 实战框架:通用框架 + 平台具体实现,中断配置单独处理

说实话,GPIO 的 HAL 封装是所有外设抽象里最简单的。但正是因为它简单,才最适合用来练手。把这一章的内容吃透了,后面做 SPI、I2C、UART 的 HAL 层时,你会发现思路完全一样。