第3章:GPIO驱动封装实战——从寄存器到API,实现统一的GPIO读写、中断配置,支持多实例

GPIO驱动,几乎是每个嵌入式工程师入行时写的第一个驱动。但说实话,我见过太多项目里GPIO代码写得五花八门——有人直接操作寄存器,有人用宏定义封装,还有人把GPIO和业务逻辑混在一起。结果呢?换个芯片平台,代码基本重写。

这一章,我们就来聊聊怎么把GPIO驱动封装好。从最底层的寄存器操作,到提供统一的API接口,再到支持多实例。说白了,就是让你写的GPIO驱动,以后换个MCU也能复用大部分代码。

本章核心目标:设计一个分层、可移植、支持多实例的GPIO驱动框架。底层操作寄存器,中间层抽象硬件差异,上层提供统一API。

3.1 为什么GPIO驱动需要封装?

先问个问题:你平时写GPIO控制,是不是这样?

// 直接操作寄存器
#define GPIOA_BASE 0x40020000
#define GPIOA_ODR  (*(volatile uint32_t *)(GPIOA_BASE + 0x14))
#define GPIOA_BSRR (*(volatile uint32_t *)(GPIOA_BASE + 0x18))

// 点亮LED
GPIOA_BSRR = (1 << 5);  // 设置PA5为高电平

这样写,简单直接,但问题也很明显:

  • 不可移植:换个MCU,寄存器地址全变,代码得重写。
  • 不可复用:每个模块都直接操作寄存器,代码散落各处。
  • 难以维护:想改个引脚功能,得满世界找寄存器操作的地方。
  • 不支持多实例:如果有两个同样的外设(比如两个LED),代码得写两遍。

我在一个项目中就吃过这个亏。当时一个产品用了三颗同系列MCU,引脚分配略有不同。因为GPIO代码没封装好,改引脚配置改到崩溃。从那以后,我下定决心——GPIO驱动必须封装,而且得封好。

3.2 分层设计:从寄存器到API

一个好的GPIO驱动,应该分三层:

  1. 硬件抽象层(HAL):直接操作寄存器,屏蔽MCU差异。
  2. 驱动层(Driver):提供统一的读写、配置接口。
  3. 应用层(App):调用驱动API,不关心底层实现。

你想想看,这样分层之后,换MCU只需要改HAL层,驱动层和应用层基本不动。是不是省事多了?

GPIO驱动分层架构 应用层(App) 调用驱动API,不关心底层实现 驱动层(Driver) 统一API:gpio_init / gpio_write / gpio_read / gpio_irq 硬件抽象层(HAL) 操作寄存器,屏蔽MCU差异 硬件(MCU GPIO外设) 用户代码 业务逻辑 API接口 统一、稳定 寄存器操作 平台相关

3.3 数据结构设计:GPIO对象

要支持多实例,就得用面向对象的思想。在C语言里,我们用结构体来实现。

/* gpio_types.h */
#ifndef GPIO_TYPES_H
#define GPIO_TYPES_H

#include <stdint.h>

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

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

/* 中断触发方式 */
typedef enum {
    GPIO_IRQ_RISING_EDGE  = 0,  /* 上升沿触发 */
    GPIO_IRQ_FALLING_EDGE = 1,  /* 下降沿触发 */
    GPIO_IRQ_BOTH_EDGE    = 2,  /* 双边沿触发 */
    GPIO_IRQ_LEVEL_HIGH   = 3,  /* 高电平触发 */
    GPIO_IRQ_LEVEL_LOW    = 4   /* 低电平触发 */
} gpio_irq_mode_t;

/* GPIO配置结构体 */
typedef struct {
    uint32_t       port;      /* GPIO端口号 */
    uint32_t       pin;       /* GPIO引脚号 */
    gpio_dir_t     dir;       /* 方向 */
    gpio_level_t   init_level;/* 初始电平(输出时有效) */
    uint8_t        pull_up;   /* 上拉使能 */
    uint8_t        pull_down; /* 下拉使能 */
} gpio_config_t;

/* GPIO实例结构体 */
typedef struct gpio_dev {
    gpio_config_t  config;    /* 配置信息 */
    void          *hw_regs;   /* 硬件寄存器基地址 */
    void          *priv;      /* 私有数据(用于扩展) */
    int (*write)(struct gpio_dev *dev, gpio_level_t level);
    int (*read)(struct gpio_dev *dev, gpio_level_t *level);
    int (*irq_setup)(struct gpio_dev *dev, gpio_irq_mode_t mode,
                     void (*callback)(void *arg), void *arg);
} gpio_dev_t;

#endif /* GPIO_TYPES_H */

设计思路:gpio_dev_t结构体里包含了函数指针。这样每个实例可以绑定不同的底层实现。比如,PA0和PB0虽然硬件不同,但通过函数指针,上层调用同样的API。

3.4 HAL层实现:操作寄存器

HAL层负责和硬件打交道。以STM32为例,我们封装寄存器操作。

/* gpio_hal_stm32.c */
#include "gpio_hal_stm32.h"

/* STM32 GPIO寄存器结构 */
typedef struct {
    volatile uint32_t MODER;   /* 模式寄存器 */
    volatile uint32_t OTYPER;  /* 输出类型寄存器 */
    volatile uint32_t OSPEEDR; /* 输出速度寄存器 */
    volatile uint32_t PUPDR;   /* 上下拉寄存器 */
    volatile uint32_t IDR;     /* 输入数据寄存器 */
    volatile uint32_t ODR;     /* 输出数据寄存器 */
    volatile uint32_t BSRR;    /* 置位/复位寄存器 */
    volatile uint32_t LCKR;    /* 锁定寄存器 */
    volatile uint32_t AFRL;    /* 复用功能低寄存器 */
    volatile uint32_t AFRH;    /* 复用功能高寄存器 */
} gpio_regs_t;

/* 初始化GPIO硬件 */
int gpio_hal_init(gpio_dev_t *dev) {
    gpio_regs_t *regs = (gpio_regs_t *)dev->hw_regs;
    uint32_t pin = dev->config.pin;
    uint32_t mode;

    /* 使能GPIO时钟(略,不同MCU时钟使能方式不同) */

    /* 配置模式 */
    if (dev->config.dir == GPIO_DIR_OUTPUT) {
        mode = 0x01;  /* 通用输出模式 */
    } else {
        mode = 0x00;  /* 输入模式 */
    }
    regs->MODER &= ~(0x03 << (pin * 2));
    regs->MODER |= (mode << (pin * 2));

    /* 配置上下拉 */
    uint32_t pupd = 0;
    if (dev->config.pull_up)   pupd = 0x01;
    if (dev->config.pull_down) pupd = 0x02;
    regs->PUPDR &= ~(0x03 << (pin * 2));
    regs->PUPDR |= (pupd << (pin * 2));

    /* 设置初始电平 */
    if (dev->config.dir == GPIO_DIR_OUTPUT) {
        if (dev->config.init_level == GPIO_LEVEL_HIGH) {
            regs->BSRR = (1 << pin);
        } else {
            regs->BSRR = (1 << (pin + 16));
        }
    }

    return 0;
}

/* 写电平 */
int gpio_hal_write(gpio_dev_t *dev, gpio_level_t level) {
    gpio_regs_t *regs = (gpio_regs_t *)dev->hw_regs;
    uint32_t pin = dev->config.pin;

    if (level == GPIO_LEVEL_HIGH) {
        regs->BSRR = (1 << pin);       /* 置位 */
    } else {
        regs->BSRR = (1 << (pin + 16)); /* 复位 */
    }

    return 0;
}

/* 读电平 */
int gpio_hal_read(gpio_dev_t *dev, gpio_level_t *level) {
    gpio_regs_t *regs = (gpio_regs_t *)dev->hw_regs;
    uint32_t pin = dev->config.pin;

    if (regs->IDR & (1 << pin)) {
        *level = GPIO_LEVEL_HIGH;
    } else {
        *level = GPIO_LEVEL_LOW;
    }

    return 0;
}

注意:寄存器操作一定要用volatile关键字,防止编译器优化。我遇到过一个问题——没加volatile,结果读IDR寄存器时,编译器直接用了缓存值,导致电平状态读不对。排查了半天才发现是这个问题。

3.5 驱动层实现:统一API

驱动层把HAL层的函数指针绑定到gpio_dev_t结构体上。这样上层调用时,根本不知道底层是STM32还是GD32。

/* gpio_driver.c */
#include "gpio_driver.h"
#include "gpio_hal_stm32.h"  /* 根据平台选择不同的HAL头文件 */

/* 创建GPIO实例 */
int gpio_create(gpio_dev_t *dev, gpio_config_t *config) {
    if (dev == NULL || config == NULL) {
        return -1;
    }

    /* 保存配置 */
    dev->config = *config;

    /* 绑定硬件寄存器基地址 */
    /* 这里以STM32为例,实际项目中可以通过配置表映射 */
    switch (config->port) {
        case 0:  dev->hw_regs = (void *)0x40020000; break; /* GPIOA */
        case 1:  dev->hw_regs = (void *)0x40020400; break; /* GPIOB */
        case 2:  dev->hw_regs = (void *)0x40020800; break; /* GPIOC */
        default: return -1;
    }

    /* 绑定函数指针 */
    dev->write     = gpio_hal_write;
    dev->read      = gpio_hal_read;
    dev->irq_setup = gpio_hal_irq_setup;

    /* 调用HAL初始化 */
    return gpio_hal_init(dev);
}

/* 写GPIO */
int gpio_write(gpio_dev_t *dev, gpio_level_t level) {
    if (dev == NULL || dev->write == NULL) {
        return -1;
    }
    return dev->write(dev, level);
}

/* 读GPIO */
int gpio_read(gpio_dev_t *dev, gpio_level_t *level) {
    if (dev == NULL || dev->read == NULL) {
        return -1;
    }
    return dev->read(dev, level);
}

/* 配置中断 */
int gpio_irq_setup(gpio_dev_t *dev, gpio_irq_mode_t mode,
                   void (*callback)(void *arg), void *arg) {
    if (dev == NULL || dev->irq_setup == NULL) {
        return -1;
    }
    return dev->irq_setup(dev, mode, callback, arg);
}

3.6 中断配置:统一处理

GPIO中断的配置,不同MCU差异很大。但我们可以抽象出一个统一的接口。

/* gpio_hal_irq.c - 中断配置实现 */
static gpio_dev_t *irq_devices[16];  /* 最多支持16个中断引脚 */
static void (*irq_callbacks[16])(void *arg);
static void *irq_args[16];

int gpio_hal_irq_setup(gpio_dev_t *dev, gpio_irq_mode_t mode,
                       void (*callback)(void *arg), void *arg) {
    uint32_t pin = dev->config.pin;
    gpio_regs_t *regs = (gpio_regs_t *)dev->hw_regs;

    /* 保存回调 */
    irq_devices[pin] = dev;
    irq_callbacks[pin] = callback;
    irq_args[pin] = arg;

    /* 配置中断触发方式(以STM32 EXTI为例) */
    /* 1. 配置SYSCFG外部中断映射 */
    /* 2. 配置EXTI的触发方式 */
    /* 3. 使能NVIC中断 */

    /* 这里简化处理,实际代码需要根据MCU具体寄存器配置 */
    return 0;
}

/* 中断服务函数(在启动文件中注册) */
void EXTI0_IRQHandler(void) {
    uint32_t pin = 0;  /* 根据中断号确定引脚 */

    /* 清除中断标志 */
    /* ... */

    /* 调用回调 */
    if (irq_callbacks[pin] != NULL) {
        irq_callbacks[pin](irq_args[pin]);
    }
}

经验之谈:中断回调里不要做耗时操作。我习惯在回调里只设置一个标志位,真正的处理放在主循环或任务里。这样可以避免中断嵌套导致的问题。

3.7 多实例支持:一个驱动,多个对象

支持多实例,说白了就是可以创建多个gpio_dev_t对象,每个对象控制不同的引脚。

/* main.c - 使用示例 */
#include "gpio_driver.h"

/* 定义两个GPIO实例 */
static gpio_dev_t led_red;
static gpio_dev_t led_green;
static gpio_dev_t button;

/* LED配置 */
gpio_config_t led_cfg = {
    .port       = 0,          /* GPIOA */
    .pin        = 5,          /* PA5 */
    .dir        = GPIO_DIR_OUTPUT,
    .init_level = GPIO_LEVEL_LOW,
    .pull_up    = 0,
    .pull_down  = 0
};

/* 按键配置 */
gpio_config_t btn_cfg = {
    .port       = 1,          /* GPIOB */
    .pin        = 0,          /* PB0 */
    .dir        = GPIO_DIR_INPUT,
    .init_level = GPIO_LEVEL_LOW,
    .pull_up    = 1,          /* 内部上拉 */
    .pull_down  = 0
};

/* 按键中断回调 */
void button_pressed(void *arg) {
    gpio_dev_t *led = (gpio_dev_t *)arg;
    gpio_level_t level;

    gpio_read(led, &level);
    gpio_write(led, !level);  /* 翻转LED */
}

int main(void) {
    /* 创建实例 */
    gpio_create(&led_red, &led_cfg);
    gpio_create(&led_green, &led_cfg);  /* 复用配置,但可以修改引脚 */
    gpio_create(&button, &btn_cfg);

    /* 单独控制 */
    gpio_write(&led_red, GPIO_LEVEL_HIGH);
    gpio_write(&led_green, GPIO_LEVEL_LOW);

    /* 配置按键中断,翻转红灯 */
    gpio_irq_setup(&button, GPIO_IRQ_FALLING_EDGE,
                   button_pressed, &led_red);

    while (1) {
        /* 主循环 */
    }

    return 0;
}

3.8 移植指南:换平台怎么办?

这套框架的移植,只需要改HAL层。我整理了一个对照表:

平台 需要修改的文件 主要改动点
STM32 gpio_hal_stm32.c 寄存器地址、时钟使能、中断控制器
GD32 gpio_hal_gd32.c 寄存器地址(与STM32兼容但略有差异)
NXP LPC gpio_hal_lpc.c 寄存器结构不同,GPIO和中断分开
ESP32 gpio_hal_esp32.c 需要调用ESP-IDF的底层寄存器API

移植步骤:

  1. 新建一个gpio_hal_xxx.c文件。
  2. 实现gpio_hal_init、gpio_hal_write、gpio_hal_read、gpio_hal_irq_setup四个函数。
  3. 在gpio_driver.c里包含新的HAL头文件。
  4. 修改gpio_create里的寄存器基地址映射表。

搞定。应用层代码一行都不用改。

3.9 避坑指南

做GPIO驱动封装,有几个坑我踩过,分享给你:

  • 寄存器位操作别用读-改-写:多任务环境下,读-改-写可能被中断打断。我习惯用BSRR这种原子操作寄存器。
  • 中断回调里别做复杂操作:printf、malloc这些函数不要在中断里调用。我曾经在中断里调printf,结果系统直接卡死。
  • 注意引脚复用功能:有些引脚默认是JTAG/SWD功能,直接当GPIO用可能不行。需要先配置复用功能。
  • 多实例的线程安全:如果驱动在RTOS环境下使用,读写操作要加锁。可以用一个全局互斥量保护。

特别提醒:GPIO的上下拉配置,不同MCU的默认状态不一样。有的默认浮空,有的默认上拉。移植时一定要确认清楚,否则可能出现引脚电平不确定的问题。

3.10 小结

这一章,我们从寄存器操作开始,一步步封装出了一个支持多实例的GPIO驱动框架。核心思想就是分层——HAL层屏蔽硬件差异,驱动层提供统一API,应用层只管调用。

你想想看,以后不管换什么MCU,只要按照这个框架重新实现HAL层,应用代码基本不用动。这就是封装的价值。