3. 分层架构设计思想

好,咱们接着聊GPIO驱动封装。基础篇里我们把单个引脚的操作封装好了,但说实话,那只是热身。真正到了项目里,你会发现一个尴尬的问题:换了个MCU平台,所有GPIO代码得重写。我早年吃过这个亏,一个智能家居项目从STM32换到GD32,光改GPIO就改了两天,还漏了几个引脚配置,板子烧了。

所以这一章,咱们的目标很明确——设计一套抽象的GPIO设备层接口,让上层应用代码跟具体硬件解耦。说白了,就是给GPIO穿上一件“通用外衣”,换平台只换衣服,不换人。

3.1 为什么需要抽象GPIO设备层?

先看一个典型场景。你的系统里可能有LED、按键、蜂鸣器、SPI的CS引脚……每个都是GPIO。如果代码里到处是HAL_GPIO_WritePin或者GPIO_SetBits,那换平台时就是一场噩梦。

抽象层要解决三个核心问题:

  • 接口统一:不管底层是STM32、GD32还是NXP,上层调用的函数名、参数类型都一样。
  • 配置解耦:引脚的功能(输入、输出、复用)和属性(上拉、下拉、速度)不在应用代码里写死。
  • 资源管理:避免多个模块意外操作同一个引脚,造成冲突。

核心原则:抽象层只定义“做什么”,不关心“怎么做”。底层驱动负责“怎么做”,上层应用只调用“做什么”。

3.2 抽象GPIO设备层接口设计

我个人习惯把GPIO抽象层分成三层:

  • 设备对象层:定义一个GPIO设备结构体,包含引脚号、端口、配置参数、回调函数等。
  • 操作接口层:提供初始化、写、读、中断配置等标准API。
  • 平台适配层:把标准API映射到具体MCU的HAL库或寄存器操作。

来看一个我常用的接口定义:

/* gpio_device.h */
#ifndef __GPIO_DEVICE_H__
#define __GPIO_DEVICE_H__

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

/* GPIO方向枚举 */
typedef enum {
    GPIO_DIR_INPUT  = 0,
    GPIO_DIR_OUTPUT = 1,
    GPIO_DIR_ALT    = 2      /* 复用功能 */
} gpio_dir_t;

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

/* GPIO中断触发方式 */
typedef enum {
    GPIO_IRQ_RISING   = 0,
    GPIO_IRQ_FALLING  = 1,
    GPIO_IRQ_BOTH     = 2,
    GPIO_IRQ_LOW      = 3,
    GPIO_IRQ_HIGH     = 4
} gpio_irq_mode_t;

/* GPIO设备结构体 */
typedef struct {
    uint32_t    port;       /* 端口号,由平台定义 */
    uint32_t    pin;        /* 引脚号 */
    gpio_dir_t  direction;  /* 方向 */
    gpio_pull_t pull;       /* 上下拉 */
    bool        init_level; /* 初始电平 */
    void      (*irq_callback)(void* arg); /* 中断回调 */
    void       *irq_arg;    /* 回调参数 */
} gpio_device_t;

/* 标准操作接口 */
int  gpio_init(gpio_device_t *dev);
int  gpio_deinit(gpio_device_t *dev);
void gpio_write(gpio_device_t *dev, bool level);
bool gpio_read(gpio_device_t *dev);
int  gpio_toggle(gpio_device_t *dev);
int  gpio_irq_register(gpio_device_t *dev, gpio_irq_mode_t mode);
int  gpio_irq_unregister(gpio_device_t *dev);

#endif /* __GPIO_DEVICE_H__ */

嗯,这里要注意:port和pin的类型我用的是uint32_t,而不是具体的GPIO_TypeDef*。为什么?因为不同平台的端口类型不一样,STM32是结构体指针,GD32是枚举,NXP可能是数字编号。用uint32_t统一承载,在适配层做强制转换,这样接口就与平台无关了。

我的经验:gpio_device_t结构体里不要放太多平台相关的字段。我曾经加过一个speed字段,结果在低端MCU上根本用不到,白白浪费内存。抽象层只放通用属性,特殊配置留给平台适配层去处理。

3.3 多平台GPIO驱动移植方案

接口定义好了,怎么移植到不同平台?我总结了一套“三步走”策略:

  1. 实现平台适配层:为每个MCU写一个gpio_platform.c,把标准API映射到具体硬件操作。
  2. 使用条件编译:在头文件里用#ifdef区分平台,编译时自动选择对应的适配层。
  3. 提供默认实现:对于没有HAL库的裸机环境,直接操作寄存器。

举个例子,STM32平台下的gpio_write实现:

/* gpio_platform_stm32.c */
#include "gpio_device.h"
#include "stm32f1xx_hal.h"

int gpio_init(gpio_device_t *dev) {
    GPIO_InitTypeDef gpio_cfg = {0};
    GPIO_TypeDef *gpio_port = (GPIO_TypeDef *)dev->port;

    __HAL_RCC_GPIOA_CLK_ENABLE(); /* 实际应根据端口使能时钟 */

    gpio_cfg.Pin   = dev->pin;
    gpio_cfg.Mode  = (dev->direction == GPIO_DIR_OUTPUT) ?
                     GPIO_MODE_OUTPUT_PP : GPIO_MODE_INPUT;
    gpio_cfg.Pull  = (dev->pull == GPIO_PULL_UP)  ? GPIO_PULLUP :
                     (dev->pull == GPIO_PULL_DOWN) ? GPIO_PULLDOWN : GPIO_NOPULL;
    gpio_cfg.Speed = GPIO_SPEED_FREQ_LOW;

    HAL_GPIO_Init(gpio_port, &gpio_cfg);

    if (dev->direction == GPIO_DIR_OUTPUT) {
        HAL_GPIO_WritePin(gpio_port, dev->pin,
                          dev->init_level ? GPIO_PIN_SET : GPIO_PIN_RESET);
    }
    return 0;
}

void gpio_write(gpio_device_t *dev, bool level) {
    GPIO_TypeDef *gpio_port = (GPIO_TypeDef *)dev->port;
    HAL_GPIO_WritePin(gpio_port, dev->pin,
                      level ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

再看GD32平台的版本,你会发现结构几乎一样,只是HAL函数名变了:

/* gpio_platform_gd32.c */
#include "gpio_device.h"
#include "gd32f30x.h"

int gpio_init(gpio_device_t *dev) {
    rcu_periph_clock_enable(RCU_GPIOA); /* 实际应根据端口选择 */

    gpio_mode_set((uint32_t)dev->port, 
                  (dev->direction == GPIO_DIR_OUTPUT) ? GPIO_MODE_OUTPUT : GPIO_MODE_INPUT,
                  (dev->pull == GPIO_PULL_UP)  ? GPIO_PUPD_PULLUP :
                  (dev->pull == GPIO_PULL_DOWN) ? GPIO_PUPD_PULLDOWN : GPIO_PUPD_NONE,
                  dev->pin);

    if (dev->direction == GPIO_DIR_OUTPUT) {
        gpio_bit_write((uint32_t)dev->port, dev->pin,
                       dev->init_level ? SET : RESET);
    }
    return 0;
}

void gpio_write(gpio_device_t *dev, bool level) {
    gpio_bit_write((uint32_t)dev->port, dev->pin,
                   level ? SET : RESET);
}

避坑指南:我曾经在移植时犯过一个低级错误——GD32的端口基地址和STM32不一样,但我直接用了STM32的GPIOA_BASE宏定义。结果GPIOA初始化正常,GPIOB就崩了。后来我改成在gpio_device_t里存端口基地址的数值,而不是宏,问题就解决了。记住:移植时不要迷信宏定义,数值才是最可靠的。

3.4 实战:封装一个通用的GPIO驱动层

理论说完了,咱们动手。下面是一个完整的通用GPIO驱动层实现,包含设备管理、操作接口和平台适配。

3.4.1 设备管理

我习惯用一个数组来管理所有GPIO设备,支持动态注册和查找:

/* gpio_mgr.c */
#include "gpio_device.h"
#include <string.h>

#define GPIO_DEVICE_MAX  16

static gpio_device_t *gpio_devices[GPIO_DEVICE_MAX];
static uint8_t gpio_device_count = 0;

int gpio_device_register(gpio_device_t *dev) {
    if (gpio_device_count >= GPIO_DEVICE_MAX) return -1;
    gpio_devices[gpio_device_count++] = dev;
    return gpio_init(dev);
}

gpio_device_t* gpio_device_find(uint32_t port, uint32_t pin) {
    for (uint8_t i = 0; i < gpio_device_count; i++) {
        if (gpio_devices[i]->port == port && gpio_devices[i]->pin == pin) {
            return gpio_devices[i];
        }
    }
    return NULL;
}

3.4.2 应用层调用示例

有了抽象层,应用代码写起来就清爽多了:

/* app_led.c */
#include "gpio_device.h"

/* 定义LED设备 */
static gpio_device_t led_dev = {
    .port       = (uint32_t)GPIOA,   /* 平台相关,但类型统一 */
    .pin        = GPIO_PIN_5,
    .direction  = GPIO_DIR_OUTPUT,
    .pull       = GPIO_PULL_NONE,
    .init_level = false,
    .irq_callback = NULL,
    .irq_arg      = NULL
};

void app_led_init(void) {
    gpio_device_register(&led_dev);
}

void app_led_on(void) {
    gpio_write(&led_dev, true);
}

void app_led_off(void) {
    gpio_write(&led_dev, false);
}

void app_led_toggle(void) {
    gpio_toggle(&led_dev);
}

你看,应用层完全不知道底层是STM32还是GD32,它只关心gpio_write和gpio_toggle。换平台时,只需要换gpio_platform_xxx.c文件,应用代码一行都不用改。

3.5 知识结构总览

下面这张图把本章的核心逻辑串起来了,你可以对照着理解:

GPIO抽象层架构图 GPIO抽象层架构 应用层(LED、按键、蜂鸣器...) 抽象接口层(gpio_device.h) gpio_init / gpio_write / gpio_read / gpio_toggle / gpio_irq_register 平台适配层(gpio_platform_xxx.c) STM32 HAL库 GD32 标准库 NXP SDK 硬件寄存器 / 外设 换平台时,只需替换“平台适配层”的源文件,应用层和抽象接口层完全不用动

3.6 移植清单与对比

最后,我整理了一份移植时需要修改的文件清单,方便你对照检查:

文件/模块 是否需要修改 说明
gpio_device.h 否 抽象接口定义,与平台无关
gpio_mgr.c 否 设备管理逻辑,通用实现
gpio_platform_xxx.c 是 每个平台单独实现,包含所有硬件操作
app_led.c / app_key.c 否 应用代码,只调用抽象接口
build_config.h 是 定义当前平台宏,如 #define PLATFORM_STM32

小技巧:移植时先跑一个LED闪烁的例子,验证gpio_init和gpio_write是否正常。这个通了,其他GPIO操作基本就没问题了。我每次移植新平台都是这么干的,屡试不爽。

好了,这一章的内容就到这儿。抽象层设计好了,后面咱们做I2C、SPI、UART的驱动封装时,思路是完全一样的——定义通用接口,分离平台差异。你先把GPIO这套跑通,后面的路就好走了。