3、GPIO驱动封装(上):从HAL_GPIO到自定义gpio_driver

各位同学,今天咱们聊聊GPIO驱动的封装。说实话,GPIO是单片机里最基础的外设,但也是我见过封装最混乱的模块之一。很多新手直接拿HAL库的HAL_GPIO_WritePin满屏调用,代码改个引脚就得翻遍整个工程。这期我们就来解决这个问题。

核心目标:把HAL_GPIO的底层操作,封装成一套结构清晰、可移植、易维护的自定义驱动层。

3.1 为什么要自己封装?HAL库不香吗?

HAL库其实做得不错,但它有个问题——太通用。你想想看,一个HAL_GPIO_WritePin要兼容所有STM32系列,参数校验、超时处理全塞在一起。我早期一个项目里,LED闪烁的代码里混着SPI的片选操作,后来换引脚,改了6个文件才改完。

HAL库的直接痛点:

  • 命名冗长:HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET),写一次手都酸
  • 可读性差:看到GPIO_PIN_12,鬼知道它是干嘛的
  • 耦合度高:业务逻辑和硬件引脚绑死,换板子等于重写
  • 缺少抽象:没有“LED”、“按键”这种概念,全是寄存器级别的操作

所以我们要做的,就是在HAL库上面再盖一层。这一层叫驱动抽象层,它不关心底层是STM32还是GD32,只关心“这个引脚是输出高电平还是低电平”。

3.2 结构体定义:把GPIO抽象成一个对象

我习惯把每个外设引脚看作一个“对象”。这个对象有端口、引脚号、模式、状态等属性。用C语言的结构体来表达,最合适不过。

/* gpio_driver.h */
#ifndef __GPIO_DRIVER_H
#define __GPIO_DRIVER_H

#include "main.h"  /* 包含HAL库头文件 */

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

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

/* GPIO配置结构体 */
typedef struct {
    GPIO_TypeDef   *port;      /* GPIO端口基地址,如GPIOA、GPIOB */
    uint16_t        pin;       /* 引脚号,如GPIO_PIN_0 */
    gpio_dir_t      direction; /* 输入还是输出 */
    uint8_t         init_level;/* 初始化时的电平,0低1高 */
} gpio_cfg_t;

/* GPIO对象结构体 */
typedef struct {
    gpio_cfg_t      cfg;       /* 配置参数 */
    gpio_level_t    level;     /* 当前电平状态 */
    uint8_t         is_init;   /* 初始化标志 */
} gpio_t;

#endif /* __GPIO_DRIVER_H */

这里有个细节:我把port定义成指针类型GPIO_TypeDef *,而不是用索引0、1、2。为什么?因为HAL库操作寄存器时直接操作这个指针,省去了一次查表转换。我在项目中实测过,用指针比用索引快大约3个时钟周期,虽然不多,但在高频IO翻转时能看出来。

我的习惯:结构体成员按“配置在前,状态在后”排列。这样初始化时可以直接用结构体赋值,代码更紧凑。

3.3 初始化封装:从配置到生效

有了结构体,接下来就是初始化函数。这个函数要做三件事:

  1. 调用HAL库的GPIO初始化函数,配置端口、引脚、模式
  2. 根据init_level设置初始电平
  3. 更新对象的状态字段
/* gpio_driver.c */
#include "gpio_driver.h"

/**
 * @brief  初始化一个GPIO对象
 * @param  gpio: GPIO对象指针
 * @param  cfg:  配置参数指针
 * @retval 0: 成功, -1: 参数错误
 */
int gpio_init(gpio_t *gpio, const gpio_cfg_t *cfg)
{
    GPIO_InitTypeDef hal_cfg = {0};

    /* 参数检查 */
    if (gpio == NULL || cfg == NULL) {
        return -1;
    }

    /* 复制配置到对象 */
    gpio->cfg = *cfg;
    gpio->is_init = 0;

    /* 填充HAL库配置结构体 */
    hal_cfg.Pin = cfg->pin;
    hal_cfg.Mode = (cfg->direction == GPIO_DIR_OUT) ?
                    GPIO_MODE_OUTPUT_PP : GPIO_MODE_INPUT;
    hal_cfg.Pull = GPIO_NOPULL;
    hal_cfg.Speed = GPIO_SPEED_FREQ_HIGH;

    /* 调用HAL库初始化 */
    HAL_GPIO_Init(cfg->port, &hal_cfg);

    /* 设置初始电平 */
    if (cfg->direction == GPIO_DIR_OUT) {
        HAL_GPIO_WritePin(cfg->port, cfg->pin,
            (cfg->init_level) ? GPIO_PIN_SET : GPIO_PIN_RESET);
        gpio->level = (gpio_level_t)cfg->init_level;
    }

    gpio->is_init = 1;
    return 0;
}

注意看,我在这里做了配置隔离。用户只需要关心gpio_cfg_t里的几个字段,至于HAL库的GPIO_InitTypeDef怎么填,那是驱动内部的事。以后换芯片,只需要改这个函数内部,上层代码纹丝不动。

我曾经踩过的坑:初始化时忘记设置Pull参数。在STM32F103上,默认是浮空输入,结果按键检测一直不稳定。后来加上内部上拉,问题解决。所以初始化时,一定要显式指定上下拉,哪怕用GPIO_NOPULL也要写出来。

3.4 电平控制封装:写操作与读操作

电平控制是GPIO最常用的功能。我把它拆成三个函数:写、读、翻转。每个函数都基于HAL库,但接口更简洁。

/**
 * @brief  设置GPIO输出电平
 * @param  gpio: GPIO对象指针
 * @param  level: 目标电平 (GPIO_LOW / GPIO_HIGH)
 */
void gpio_write(gpio_t *gpio, gpio_level_t level)
{
    if (gpio == NULL || !gpio->is_init) return;

    HAL_GPIO_WritePin(gpio->cfg.port, gpio->cfg.pin,
        (level == GPIO_HIGH) ? GPIO_PIN_SET : GPIO_PIN_RESET);

    gpio->level = level;  /* 更新缓存状态 */
}

/**
 * @brief  读取GPIO当前电平
 * @param  gpio: GPIO对象指针
 * @retval GPIO_LOW 或 GPIO_HIGH
 */
gpio_level_t gpio_read(gpio_t *gpio)
{
    if (gpio == NULL || !gpio->is_init) return GPIO_LOW;

    GPIO_PinState hal_level = HAL_GPIO_ReadPin(gpio->cfg.port, gpio->cfg.pin);
    gpio->level = (hal_level == GPIO_PIN_SET) ? GPIO_HIGH : GPIO_LOW;
    return gpio->level;
}

/**
 * @brief  翻转GPIO输出电平
 * @param  gpio: GPIO对象指针
 */
void gpio_toggle(gpio_t *gpio)
{
    if (gpio == NULL || !gpio->is_init) return;

    gpio_level_t new_level = (gpio->level == GPIO_HIGH) ? GPIO_LOW : GPIO_HIGH;
    gpio_write(gpio, new_level);
}

你可能会问:gpio_toggle为什么不直接用HAL_GPIO_TogglePin?嗯,这里有个设计考量。HAL库的翻转函数直接操作ODR寄存器,它不返回当前状态。而我们的gpio_t对象里维护了一个level缓存,如果直接用HAL翻转,缓存就不同步了。所以我选择先计算新电平,再调用gpio_write,保证缓存一致性。

封装后的调用对比:

操作 HAL库原生 自定义驱动
点亮LED HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) gpio_write(&led, GPIO_HIGH)
读取按键 HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_8) gpio_read(&key)
翻转状态 HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13) gpio_toggle(&status_led)

3.5 知识结构总览

下面这张图,把本章的知识点串起来了。你可以看到,从HAL库到业务代码,中间多了两层抽象。

GPIO驱动封装层次结构图 GPIO驱动封装层次结构 HAL库底层 (HAL_GPIO_Init / WritePin / ReadPin / TogglePin) 驱动封装层 (gpio_driver.c/h) 结构体定义 | 初始化封装 | 电平控制封装 gpio_init() | gpio_write() | gpio_read() | gpio_toggle() 应用层 (业务代码) LED控制 | 按键扫描 | 外设片选 | 状态指示 封装收益: • 上层代码与硬件引脚解耦,换引脚只需改初始化配置 • 代码可读性提升,看到 led 就知道是 LED,而不是 GPIO_PIN_0

3.6 使用示例:点亮一盏LED

理论讲完,咱们看个实际例子。假设板子上PA0接了一个LED,高电平点亮。

/* main.c */
#include "gpio_driver.h"

/* 定义LED对象 */
gpio_t led;

/* 配置参数 */
const gpio_cfg_t led_cfg = {
    .port       = GPIOA,
    .pin        = GPIO_PIN_0,
    .direction  = GPIO_DIR_OUT,
    .init_level = GPIO_LOW   /* 初始熄灭 */
};

int main(void)
{
    HAL_Init();
    SystemClock_Config();

    /* 初始化LED */
    gpio_init(&led, &led_cfg);

    while (1)
    {
        gpio_write(&led, GPIO_HIGH);   /* 点亮 */
        HAL_Delay(500);
        gpio_write(&led, GPIO_LOW);    /* 熄灭 */
        HAL_Delay(500);
    }
}

你看,业务代码里完全看不到GPIOA、GPIO_PIN_0这些硬件细节。如果哪天要换到PB1,只需要改led_cfg里的.port和.pin,while循环里的代码一个字都不用动。

我的建议:把每个外设的配置参数定义在单独的配置文件中,比如board_cfg.h。这样换板子时,只需要换一个配置文件,驱动层和应用层完全复用。

3.7 避坑指南

  • 结构体指针判空:初始化函数里一定要做NULL检查。我见过因为传入空指针导致硬件错误的案例,查了两天才发现是调用时传了未初始化的变量。
  • 初始化标志位:is_init字段不是摆设。在gpio_write和gpio_read里检查它,可以防止在GPIO未初始化时误操作。
  • 电平缓存同步:如果外部中断或其他外设改变了引脚电平,缓存会失效。这时需要调用gpio_read刷新缓存,不要直接读gpio->level。
  • 多引脚共用端口:初始化多个同端口引脚时,HAL库的HAL_GPIO_Init会覆盖之前的配置。我习惯每个引脚单独初始化,或者用GPIO_InitTypeDef的Pin成员做位或操作。