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驱动移植方案
接口定义好了,怎么移植到不同平台?我总结了一套“三步走”策略:
- 实现平台适配层:为每个MCU写一个
gpio_platform.c,把标准API映射到具体硬件操作。 - 使用条件编译:在头文件里用
#ifdef区分平台,编译时自动选择对应的适配层。 - 提供默认实现:对于没有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 知识结构总览
下面这张图把本章的核心逻辑串起来了,你可以对照着理解:
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这套跑通,后面的路就好走了。