4、GPIO驱动模块封装(上):抽象GPIO接口

好,咱们进入第四讲。前面三章我们把分层架构的核心理念讲清楚了,也搭好了工程骨架。从这章开始,真正动手写代码。

今天的目标很明确:把GPIO这个最基础的外设,封装成一个可复用的驱动模块。不是简单地在main.c里写几个HAL_GPIO_WritePin完事,而是要做成接口抽象、多实例支持、与上层业务解耦的“正经驱动”。

本章核心产出:gpio_interface.h(抽象接口层) + gpio_driver.c/gpio_driver.h(驱动实现层)。支持任意GPIO引脚初始化、电平输出、电平读取,且一个驱动实例可管理多个引脚。

4.1 为什么GPIO也需要“接口抽象”?

你可能觉得:GPIO不就是拉高拉低吗?有什么好封装的?

我在项目里吃过这个亏。早期做一款物联网网关,LED指示灯、继电器、按键检测全都在main.c里直接调HAL库函数。后来换了MCU型号,从STM32F103换到STM32L4,HAL库API没变,但引脚号、时钟使能、模式配置全要改。那叫一个痛苦——几十处散落的GPIO操作,改到怀疑人生。

所以,抽象的意义不在于“现在省事”,而在于“将来不哭”。你想想看,如果业务代码只跟gpio_interface.h打交道,底层换MCU、换引脚、换板子,业务层一行代码都不用动。

对比维度 直接调用HAL库 抽象接口封装
耦合度 业务代码直接依赖HAL库 业务代码只依赖抽象接口
可移植性 换MCU需修改所有业务代码 只需替换驱动实现层
多实例支持 每个引脚需单独写配置 一个驱动对象管理多个引脚
测试便利性 硬件依赖,无法单元测试 可Mock接口层进行测试

4.2 接口设计:gpio_interface.h

接口层只定义“做什么”,不关心“怎么做”。我习惯用结构体函数指针的方式来实现面向对象风格的接口。当然,C语言没有class,但我们可以用结构体把操作打包。

// gpio_interface.h
#ifndef __GPIO_INTERFACE_H__
#define __GPIO_INTERFACE_H__

#include <stdint.h>
#include <stdbool.h>

/* GPIO电平状态枚举 */
typedef enum {
    GPIO_LEVEL_LOW  = 0,
    GPIO_LEVEL_HIGH = 1
} gpio_level_t;

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

/* GPIO配置结构体 —— 描述一个引脚的全部配置 */
typedef struct {
    uint16_t        pin;        /* 引脚号,如 GPIO_PIN_0 */
    gpio_dir_t      direction;  /* 输入/输出 */
    bool            pull_up;    /* 是否上拉 */
    bool            pull_down;  /* 是否下拉 */
    bool            init_level; /* 输出初始电平(仅输出模式有效) */
} gpio_cfg_t;

/* GPIO操作接口 —— 纯虚函数表 */
typedef struct {
    void     (*init)(void *handle, const gpio_cfg_t *cfg, uint8_t cfg_count);
    void     (*set_level)(void *handle, uint16_t pin, gpio_level_t level);
    gpio_level_t (*get_level)(void *handle, uint16_t pin);
    void     (*toggle)(void *handle, uint16_t pin);
} gpio_ops_t;

/* GPIO设备结构体 —— 一个实例对应一组引脚 */
typedef struct {
    const gpio_ops_t *ops;   /* 操作接口指针 */
    void             *priv;  /* 私有数据指针(指向具体驱动上下文) */
} gpio_dev_t;

/* 提供给上层的便捷宏 */
#define gpio_init(dev, cfg, cnt)      ((dev)->ops->init((dev)->priv, (cfg), (cnt)))
#define gpio_set(dev, pin, level)     ((dev)->ops->set_level((dev)->priv, (pin), (level)))
#define gpio_get(dev, pin)            ((dev)->ops->get_level((dev)->priv, (pin)))
#define gpio_toggle(dev, pin)         ((dev)->ops->toggle((dev)->priv, (pin)))

#endif /* __GPIO_INTERFACE_H__ */

设计要点:

  • gpio_cfg_t 用数组传入,一次init可以配置多个引脚。我在项目中经常遇到一组LED或一组按键需要统一初始化,数组方式最自然。
  • gpio_ops_t 是纯虚函数表,具体实现由驱动层填充。这样上层代码只看到gpio_dev_t,完全不知道底层是HAL库还是寄存器操作。
  • priv指针 是关键——它让同一个接口可以挂载不同的硬件上下文。比如你有两片I2C扩展GPIO芯片,每个芯片有自己的I2C地址,priv就指向各自的上下文结构体。

4.3 驱动实现:基于STM32 HAL库

接口定义好了,接下来是实现。我们基于STM32 HAL库写一套具体的驱动。这部分就是“怎么做”的细节。

// gpio_driver.h
#ifndef __GPIO_DRIVER_H__
#define __GPIO_DRIVER_H__

#include "gpio_interface.h"
#include "stm32f1xx_hal.h"  /* 以F1系列为例 */

/* GPIO驱动私有上下文 —— 每个实例一份 */
typedef struct {
    GPIO_TypeDef *port;           /* GPIO端口基地址,如 GPIOA */
    uint16_t      active_pins;    /* 当前实例管理的引脚掩码 */
    uint8_t       cfg_count;      /* 配置的引脚数量 */
} gpio_priv_t;

/* 驱动实例初始化函数 —— 返回一个gpio_dev_t */
gpio_dev_t *gpio_driver_create(GPIO_TypeDef *port);

#endif /* __GPIO_DRIVER_H__ */
// gpio_driver.c
#include "gpio_driver.h"
#include <stdlib.h>

/* 静态函数声明 */
static void _init(void *handle, const gpio_cfg_t *cfg, uint8_t cfg_count);
static void _set_level(void *handle, uint16_t pin, gpio_level_t level);
static gpio_level_t _get_level(void *handle, uint16_t pin);
static void _toggle(void *handle, uint16_t pin);

/* 全局操作函数表 —— 所有实例共享同一套函数 */
static const gpio_ops_t gpio_hal_ops = {
    .init      = _init,
    .set_level = _set_level,
    .get_level = _get_level,
    .toggle    = _toggle
};

gpio_dev_t *gpio_driver_create(GPIO_TypeDef *port) {
    gpio_dev_t *dev = malloc(sizeof(gpio_dev_t));
    gpio_priv_t *priv = malloc(sizeof(gpio_priv_t));
    if (!dev || !priv) return NULL;

    priv->port = port;
    priv->active_pins = 0;
    priv->cfg_count = 0;

    dev->ops  = &gpio_hal_ops;
    dev->priv = priv;
    return dev;
}

/* 初始化实现 */
static void _init(void *handle, const gpio_cfg_t *cfg, uint8_t cfg_count) {
    gpio_priv_t *priv = (gpio_priv_t *)handle;
    GPIO_InitTypeDef gpio_init = {0};

    for (uint8_t i = 0; i < cfg_count; i++) {
        gpio_init.Pin = cfg[i].pin;
        priv->active_pins |= cfg[i].pin;

        if (cfg[i].direction == GPIO_DIR_OUTPUT) {
            gpio_init.Mode = GPIO_MODE_OUTPUT_PP;
            gpio_init.Speed = GPIO_SPEED_FREQ_LOW;
            /* 设置初始电平 */
            HAL_GPIO_WritePin(priv->port, cfg[i].pin,
                cfg[i].init_level ? GPIO_PIN_SET : GPIO_PIN_RESET);
        } else {
            gpio_init.Mode = GPIO_MODE_INPUT;
            if (cfg[i].pull_up)      gpio_init.Pull = GPIO_PULLUP;
            else if (cfg[i].pull_down) gpio_init.Pull = GPIO_PULLDOWN;
            else                     gpio_init.Pull = GPIO_NOPULL;
        }
        HAL_GPIO_Init(priv->port, cfg[i].pin, &gpio_init);
    }
    priv->cfg_count = cfg_count;
}

/* 电平输出实现 */
static void _set_level(void *handle, uint16_t pin, gpio_level_t level) {
    gpio_priv_t *priv = (gpio_priv_t *)handle;
    HAL_GPIO_WritePin(priv->port, pin,
        (level == GPIO_LEVEL_HIGH) ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

/* 电平读取实现 */
static gpio_level_t _get_level(void *handle, uint16_t pin) {
    gpio_priv_t *priv = (gpio_priv_t *)handle;
    return (HAL_GPIO_ReadPin(priv->port, pin) == GPIO_PIN_SET)
           ? GPIO_LEVEL_HIGH : GPIO_LEVEL_LOW;
}

/* 电平翻转实现 */
static void _toggle(void *handle, uint16_t pin) {
    gpio_priv_t *priv = (gpio_priv_t *)handle;
    HAL_GPIO_TogglePin(priv->port, pin);
}

避坑指南:我曾经在_init函数里忘记保存active_pins掩码,结果后面set_level时无法校验引脚是否属于当前实例。虽然不校验也能工作,但调试时非常容易踩坑——你可能会误操作到其他驱动实例管理的引脚。所以,一定要在init时记录当前实例管理的引脚集合,并在set/get时做合法性检查(本例中为简洁未展示,生产代码建议加上)。

4.4 多实例使用示例

来看看实际怎么用。假设板子上有两组LED:一组在PA0-PA3,另一组在PB4-PB7。我们创建两个驱动实例,各自管理自己的引脚。

// main.c 片段
#include "gpio_driver.h"

/* 实例1:PA0-PA3 作为输出,初始高电平 */
static const gpio_cfg_t led_cfg_a[] = {
    {GPIO_PIN_0, GPIO_DIR_OUTPUT, false, false, true},
    {GPIO_PIN_1, GPIO_DIR_OUTPUT, false, false, true},
    {GPIO_PIN_2, GPIO_DIR_OUTPUT, false, false, true},
    {GPIO_PIN_3, GPIO_DIR_OUTPUT, false, false, true}
};

/* 实例2:PB4-PB7 作为输出,初始低电平 */
static const gpio_cfg_t led_cfg_b[] = {
    {GPIO_PIN_4, GPIO_DIR_OUTPUT, false, false, false},
    {GPIO_PIN_5, GPIO_DIR_OUTPUT, false, false, false},
    {GPIO_PIN_6, GPIO_DIR_OUTPUT, false, false, false},
    {GPIO_PIN_7, GPIO_DIR_OUTPUT, false, false, false}
};

int main(void) {
    HAL_Init();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();

    /* 创建两个驱动实例 */
    gpio_dev_t *led_a = gpio_driver_create(GPIOA);
    gpio_dev_t *led_b = gpio_driver_create(GPIOB);

    /* 分别初始化 */
    gpio_init(led_a, led_cfg_a, 4);
    gpio_init(led_b, led_cfg_b, 4);

    while (1) {
        /* 操作实例1的引脚 */
        gpio_set(led_a, GPIO_PIN_0, GPIO_LEVEL_LOW);
        HAL_Delay(500);
        gpio_set(led_a, GPIO_PIN_0, GPIO_LEVEL_HIGH);

        /* 操作实例2的引脚 */
        gpio_toggle(led_b, GPIO_PIN_4);
        HAL_Delay(500);
    }
}

你看,业务代码里完全看不到HAL_GPIO_Init、HAL_GPIO_WritePin这些底层调用。如果哪天要换到STM32G0系列,只需要重写gpio_driver.c,接口和业务代码纹丝不动。

4.5 本章知识结构图

下面这张图把GPIO驱动模块的分层关系画清楚了。我建议你对照代码看,理解每一层负责什么。

GPIO驱动模块分层架构 上层业务代码 main.c / app_led.c / app_key.c 抽象接口层 gpio_interface.h gpio_dev_t / gpio_ops_t / gpio_cfg_t 驱动实现层 gpio_driver.c / gpio_driver.h _init / _set_level / _get_level / _toggle MCU硬件层 STM32 HAL库 / 寄存器 关键设计点 • 接口与实现分离 • 多实例通过priv区分 • 操作函数表共享 • 配置数组支持批量 • 上层零硬件依赖 • 换MCU只改驱动层 多实例示意 实例1: GPIOA priv->port = GPIOA active_pins = 0x000F 实例2: GPIOB priv->port = GPIOB active_pins = 0x00F0

个人习惯:我一般把gpio_dev_t的创建函数(gpio_driver_create)放在驱动层,而不是接口层。原因是创建过程涉及内存分配和私有数据初始化,这些是具体实现的细节。接口层只暴露“怎么用”,不暴露“怎么造”。