3. GPIO驱动抽象:GPIO通用接口设计

GPIO 这玩意儿,说简单也简单,说复杂……嗯,等你需要在五六个不同架构的芯片上跑同一套业务代码时,就知道什么叫「一个引脚引发的血案」了。

我个人习惯,做 HAL 层设计时,第一个拿 GPIO 开刀。为什么?因为 GPIO 是外设里最基础、最通用的,接口模式也相对固定。你把它抽象好了,后面的 UART、I2C、SPI 都能顺着这个路子来。

3.1 通用接口设计:方向控制、电平读写、中断配置

先别急着写代码。咱们得想清楚:一个「通用」的 GPIO 接口,到底要暴露哪些能力?

我总结下来,核心就三件事:

  • 方向控制:输入还是输出?开漏还是推挽?上拉下拉要不要开?
  • 电平读写:读引脚电平、写引脚电平、翻转引脚电平。
  • 中断配置:上升沿触发?下降沿触发?双边沿?高/低电平触发?

你看,不管你是 STM32、NXP i.MX RT 还是国产 GD32、AT32,底层寄存器千差万别,但上层应用关心的就这三板斧。

核心设计原则:接口要「够用」,但不要「过度设计」。别想着一个函数覆盖所有芯片的所有变态特性——那只会让接口变得臃肿难用。

下面是我在实际项目中沉淀下来的一套 GPIO HAL 接口定义。你拿过去可以直接用,也可以根据自家芯片特性微调。

3.1.1 枚举与结构体定义

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

#include <stdint.h>

/* GPIO 方向 */
typedef enum {
    GPIO_DIR_INPUT  = 0,
    GPIO_DIR_OUTPUT = 1
} gpio_dir_t;

/* GPIO 输出模式 */
typedef enum {
    GPIO_OUTPUT_PP = 0,  /* 推挽输出 */
    GPIO_OUTPUT_OD = 1   /* 开漏输出 */
} gpio_output_mode_t;

/* GPIO 内部上下拉 */
typedef enum {
    GPIO_PULL_NONE  = 0,
    GPIO_PULL_UP    = 1,
    GPIO_PULL_DOWN  = 2
} gpio_pull_t;

/* GPIO 中断触发方式 */
typedef enum {
    GPIO_INT_RISING      = 0,
    GPIO_INT_FALLING     = 1,
    GPIO_INT_BOTH_EDGE   = 2,
    GPIO_INT_LOW_LEVEL   = 3,
    GPIO_INT_HIGH_LEVEL  = 4
} gpio_int_trig_t;

/* GPIO 配置结构体 */
typedef struct {
    uint32_t         pin;        /* 引脚号,例如 PA0 的 pin = 0 */
    gpio_dir_t       dir;        /* 方向 */
    gpio_output_mode_t output_mode; /* 输出模式(仅输出时有效) */
    gpio_pull_t      pull;       /* 上下拉 */
    uint8_t          init_level; /* 初始电平(仅输出时有效) */
} gpio_cfg_t;

/* GPIO 中断回调函数类型 */
typedef void (*gpio_irq_callback_t)(void *arg);

#endif /* __GPIO_HAL_H__ */

这里有个细节:gpio_cfg_t 把引脚配置打包成一个结构体。为什么这么做?因为初始化时一次性传参,比一个一个 set 要清晰得多。我在项目里见过有人写 gpio_set_dir()、gpio_set_pull()、gpio_set_output_mode() 三个函数分开调——调试的时候漏掉一个,查半天。

3.1.2 核心 API 声明

/* 初始化/反初始化 */
int32_t gpio_hal_init(gpio_cfg_t *cfg);
int32_t gp_hal_deinit(uint32_t pin);

/* 方向控制 */
int32_t gpio_hal_set_dir(uint32_t pin, gpio_dir_t dir);
gpio_dir_t gpio_hal_get_dir(uint32_t pin);

/* 电平读写 */
int32_t gpio_hal_write_pin(uint32_t pin, uint8_t level);
int32_t gpio_hal_toggle_pin(uint32_t pin);
uint8_t gpio_hal_read_pin(uint32_t pin);

/* 中断配置 */
int32_t gpio_hal_attach_irq(uint32_t pin, gpio_int_trig_t trig, 
                            gpio_irq_callback_t cb, void *arg);
int32_t gpio_hal_detach_irq(uint32_t pin);
int32_t gpio_hal_enable_irq(uint32_t pin);
int32_t gpio_hal_disable_irq(uint32_t pin);

我的习惯:所有 API 返回值统一用 int32_t,0 表示成功,负数表示错误码。这样上层可以统一做错误处理,不用每个函数记一套返回值规则。

3.2 多平台GPIO适配层实现

接口定义好了,接下来就是「填坑」环节——把同一套接口映射到不同芯片的寄存器上。

你想想看,STM32 的 GPIO 寄存器长这样:

  • GPIOx->MODER:模式寄存器(输入/输出/复用/模拟)
  • GPIOx->OTYPER:输出类型寄存器(推挽/开漏)
  • GPIOx->PUPDR:上下拉寄存器
  • GPIOx->IDR / ODR:输入/输出数据寄存器

而 NXP i.MX RT 的 GPIO 呢?它用的是 GPIO->DR(数据寄存器)和 GPIO->GDIR(方向寄存器),IOMUX 还要单独配置。至于国产芯片……嗯,每家都有自己的「创意」。

适配层的作用,就是把这些差异「吃掉」。上层代码永远只调 gpio_hal_write_pin(),底层适配层负责跟寄存器打交道。

3.2.1 适配层实现框架

我一般会在每个平台建一个 gpio_hal_port.c,里面实现上面声明的所有 API。举个例子,STM32 平台的部分实现:

/* gpio_hal_port_stm32.c */
#include "gpio_hal.h"
#include "stm32f4xx.h"

/* 内部辅助:根据 pin 号获取 GPIO 组和组内偏移 */
static GPIO_TypeDef *get_gpio_port(uint32_t pin, uint32_t *offset) {
    /* 假设 pin 编码:高 4 位表示组,低 4 位表示组内偏移 */
    uint32_t port_idx = (pin >> 4) & 0x0F;
    *offset = pin & 0x0F;
    
    static GPIO_TypeDef *ports[] = {
        GPIOA, GPIOB, GPIOC, GPIOD, GPIOE, GPIOF, GPIOG
    };
    if (port_idx >= sizeof(ports)/sizeof(ports[0])) return NULL;
    return ports[port_idx];
}

int32_t gpio_hal_set_dir(uint32_t pin, gpio_dir_t dir) {
    uint32_t offset;
    GPIO_TypeDef *port = get_gpio_port(pin, &offset);
    if (!port) return -1;
    
    /* STM32 MODER 寄存器:每 2 位控制一个引脚 */
    port->MODER &= ~(0x03 << (offset * 2));
    port->MODER |= (dir == GPIO_DIR_OUTPUT ? 0x01 : 0x00) << (offset * 2);
    
    return 0;
}

int32_t gpio_hal_write_pin(uint32_t pin, uint8_t level) {
    uint32_t offset;
    GPIO_TypeDef *port = get_gpio_port(pin, &offset);
    if (!port) return -1;
    
    if (level) {
        port->BSRR = (1 << offset);      /* 置位 */
    } else {
        port->BSRR = (1 << (offset + 16)); /* 复位 */
    }
    
    return 0;
}

你看,每个平台的实现都不一样,但对外暴露的接口完全一致。这就是 HAL 层的核心价值——隔离变化。

我曾经踩过的坑:在某个国产 RISC-V 芯片上,它的 GPIO 输出寄存器写 1 是低电平,写 0 是高电平。跟 STM32 正好相反。如果适配层不做反转,上层逻辑全乱套。所以适配层一定要做「电平极性归一化」——上层永远认为写 1 是高电平,写 0 是低电平,适配层负责翻转。

3.2.2 多平台适配层对比

功能 STM32 (Cortex-M) NXP i.MX RT (Cortex-M) 国产 RISC-V 芯片
方向控制 MODER 寄存器 GDIR 寄存器 GPIO_CFG 寄存器
输出电平 BSRR / BRR DR 寄存器 GPIO_OUT 寄存器
输入电平 IDR 寄存器 DR 寄存器 GPIO_IN 寄存器
上下拉 PUPDR 寄存器 IOMUXC 配置 GPIO_PULL 寄存器
中断配置 EXTI + NVIC GPIO_ISR + GIC GPIO_INT 寄存器

这张表你看完就明白了——寄存器名字不一样、位域位置不一样、甚至中断控制器都不一样。但有了适配层,上层代码根本不需要知道这些。

3.3 GPIO HAL 层整体架构

说了这么多,咱们用一张图把整个 GPIO HAL 层的结构串起来。

GPIO HAL 层架构图 GPIO HAL 层架构 上层应用 / 业务逻辑 HAL 通用接口层 gpio_hal_init / gpio_hal_write_pin / gpio_hal_attach_irq ... 平台适配层(多平台实现) gpio_hal_port_stm32.c | gpio_hal_port_imxrt.c | gpio_hal_port_riscv.c 硬件寄存器层(MCU 外设寄存器) 上层代码只依赖 HAL 通用接口,不关心底层是 STM32 还是 RISC-V 更换芯片时,只需替换适配层文件,上层代码零修改

这张图你看懂了吗?从上到下分四层:

  1. 上层应用:你的业务逻辑,比如按键扫描、LED 闪烁、中断处理。
  2. HAL 通用接口层:我们刚才定义的 gpio_hal_xxx() 函数声明,平台无关。
  3. 平台适配层:每个芯片一个 .c 文件,实现具体的寄存器操作。
  4. 硬件寄存器层:芯片厂商提供的寄存器定义,适配层直接操作它们。

关键点:适配层是唯一需要修改的部分。你从 STM32 换到 GD32,只需要重新实现适配层,上层代码一行都不用动。这就是「低耦合」的真谛。

3.4 避坑指南与个人经验

最后,分享几个我在实际项目中踩过的坑,希望能帮你少走弯路。

  • 引脚编号编码要统一:我见过有人用 PA0、PB1 字符串传参,解析起来又慢又容易错。建议用整数编码,比如高 4 位表示组,低 4 位表示偏移,PA0 = 0x00,PB1 = 0x11。
  • 中断回调要加参数:回调函数一定要带 void *arg,否则多个引脚共用同一个中断服务函数时,你没法区分是哪个引脚触发的。
  • 初始化要幂等:gpio_hal_init() 调用两次,不应该导致配置混乱。我习惯在初始化前先复位引脚配置。
  • 电平极性归一化:前面说过了,适配层必须保证上层写 1 就是高电平,写 0 就是低电平。遇到奇葩芯片,适配层里做反转。

我的小技巧:调试 GPIO 时,我会在适配层加一个 #define GPIO_HAL_DEBUG 开关。打开后,每次读写引脚都会打印日志。这样排查硬件问题时,能快速定位是上层逻辑错了,还是适配层实现错了。

好了,GPIO 驱动抽象这一章就到这里。接口定义、适配层实现、架构图、避坑指南,该给的都给了。你拿去用,有问题咱们下章接着聊。