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_dev_t的创建函数(gpio_driver_create)放在驱动层,而不是接口层。原因是创建过程涉及内存分配和私有数据初始化,这些是具体实现的细节。接口层只暴露“怎么用”,不暴露“怎么造”。