4. GPIO抽象层设计:从寄存器到回调的完整封装
GPIO 是嵌入式开发里最基础、也最容易被轻视的外设。很多人写 LED 驱动就是直接往寄存器里填数,项目小的时候没问题,一旦代码量上来,这种写法会让你改到怀疑人生。我早期做的一个智能家居项目,就因为 GPIO 操作散落在各个模块里,后期换芯片时差点重写整个驱动层。
这一章,我们就把 GPIO 抽象层拆开揉碎。从接口定义到底层寄存器封装,再到中断回调机制,最后用 LED 驱动把整个流程串起来。
4.1 接口定义:先定规矩再干活
抽象层的第一步,是定义一套通用的数据结构。说白了,就是让所有 GPIO 操作都走同一套 API。我习惯把 GPIO 的配置参数打包成一个结构体,这样扩展起来方便。
/* gpio_interface.h */
#ifndef __GPIO_INTERFACE_H
#define __GPIO_INTERFACE_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;
/* GPIO 中断触发方式 */
typedef enum {
GPIO_IRQ_RISING, /* 上升沿触发 */
GPIO_IRQ_FALLING, /* 下降沿触发 */
GPIO_IRQ_BOTH, /* 双边沿触发 */
GPIO_IRQ_LOW_LEVEL, /* 低电平触发 */
GPIO_IRQ_HIGH_LEVEL /* 高电平触发 */
} gpio_irq_mode_t;
/* GPIO 配置结构体 */
typedef struct {
uint32_t port; /* GPIO 端口基地址 */
uint16_t pin; /* GPIO 引脚号 (0-15) */
gpio_dir_t dir; /* 输入/输出 */
gpio_level_t init_level;/* 初始电平(输出模式有效) */
uint8_t pull_up; /* 是否上拉 */
uint8_t pull_down; /* 是否下拉 */
} gpio_cfg_t;
/* GPIO 操作句柄 */
typedef struct {
void (*init)(gpio_cfg_t *cfg);
void (*set_level)(uint32_t port, uint16_t pin, gpio_level_t level);
gpio_level_t (*get_level)(uint32_t port, uint16_t pin);
void (*toggle)(uint32_t port, uint16_t pin);
int (*register_irq)(uint32_t port, uint16_t pin,
gpio_irq_mode_t mode, void (*callback)(void));
void (*unregister_irq)(uint32_t port, uint16_t pin);
} gpio_ops_t;
/* 全局 GPIO 操作接口 */
extern const gpio_ops_t gpio;
#endif /* __GPIO_INTERFACE_H */
这个接口文件里,我用了函数指针结构体 gpio_ops_t。为什么这么做?因为不同的 STM32 系列,寄存器布局不一样。你想想看,如果每个芯片都重新写一套 API,那上层代码就得跟着改。用函数指针,底层换实现,上层代码一行都不用动。
4.2 寄存器操作封装:把位操作藏起来
STM32 的 GPIO 寄存器,说复杂也复杂,说简单也简单。关键是你要把那些位操作封装成可读性强的函数。我记得刚入行时,看到同事写的代码里到处都是 GPIOA->ODR |= (1 << 5),调试时根本分不清哪个引脚在干什么。
下面是我常用的封装方式,以 STM32F4 为例:
/* gpio_stm32f4.c */
#include "gpio_interface.h"
#include "stm32f4xx.h"
/* 内部辅助宏:将端口基地址转换为 GPIO 寄存器结构体指针 */
#define GPIO_REG(port) ((GPIO_TypeDef *)port)
/* 内部辅助宏:计算引脚掩码 */
#define PIN_MASK(pin) (1UL << (pin))
/* 初始化 GPIO */
static void gpio_stm32_init(gpio_cfg_t *cfg)
{
GPIO_TypeDef *reg = GPIO_REG(cfg->port);
uint32_t mask = PIN_MASK(cfg->pin);
/* 使能 GPIO 时钟(这里简化处理,实际需要查 RCC 映射) */
RCC->AHB1ENR |= (1UL << ((cfg->port - GPIOA_BASE) / 0x400));
/* 配置模式:输入或输出 */
reg->MODER &= ~(0x3UL << (cfg->pin * 2));
if (cfg->dir == GPIO_DIR_OUTPUT) {
reg->MODER |= (0x1UL << (cfg->pin * 2));
}
/* 配置上下拉 */
reg->PUPDR &= ~(0x3UL << (cfg->pin * 2));
if (cfg->pull_up) {
reg->PUPDR |= (0x1UL << (cfg->pin * 2));
} else if (cfg->pull_down) {
reg->PUPDR |= (0x2UL << (cfg->pin * 2));
}
/* 输出模式设置初始电平 */
if (cfg->dir == GPIO_DIR_OUTPUT) {
if (cfg->init_level == GPIO_LEVEL_HIGH) {
reg->BSRR = mask; /* 置位 */
} else {
reg->BSRR = (mask << 16); /* 复位 */
}
}
}
/* 设置电平 */
static void gpio_stm32_set_level(uint32_t port, uint16_t pin, gpio_level_t level)
{
GPIO_TypeDef *reg = GPIO_REG(port);
uint32_t mask = PIN_MASK(pin);
if (level == GPIO_LEVEL_HIGH) {
reg->BSRR = mask; /* 写 BSRR 置位 */
} else {
reg->BSRR = (mask << 16); /* 写 BSRR 复位 */
}
}
/* 读取电平 */
static gpio_level_t gpio_stm32_get_level(uint32_t port, uint16_t pin)
{
GPIO_TypeDef *reg = GPIO_REG(port);
return (reg->IDR & PIN_MASK(pin)) ? GPIO_LEVEL_HIGH : GPIO_LEVEL_LOW;
}
/* 翻转电平 */
static void gpio_stm32_toggle(uint32_t port, uint16_t pin)
{
GPIO_TypeDef *reg = GPIO_REG(port);
reg->ODR ^= PIN_MASK(pin);
}
/* 绑定函数指针 */
const gpio_ops_t gpio = {
.init = gpio_stm32_init,
.set_level = gpio_stm32_set_level,
.get_level = gpio_stm32_get_level,
.toggle = gpio_stm32_toggle,
.register_irq = gpio_stm32_register_irq,
.unregister_irq = gpio_stm32_unregister_irq
};
4.3 中断回调机制:让 GPIO 学会主动通知
轮询 GPIO 电平,在低功耗场景下就是灾难。你想想看,CPU 一直在那里死循环读寄存器,别的活还干不干了?中断回调才是正经做法。
STM32 的 EXTI 中断系统,可以把 GPIO 引脚映射到 16 条中断线上。我的封装思路是:维护一个回调函数表,中断发生时直接查表调用。
/* 中断回调表:最多支持 16 个 EXTI 线 */
static void (*irq_callbacks[16])(void) = { NULL };
/* 注册中断回调 */
static int gpio_stm32_register_irq(uint32_t port, uint16_t pin,
gpio_irq_mode_t mode, void (*callback)(void))
{
if (pin > 15 || callback == NULL) {
return -1; /* 参数错误 */
}
/* 保存回调函数 */
irq_callbacks[pin] = callback;
/* 配置 EXTI 中断(简化,实际需要配置 SYSCFG 外部中断映射) */
/* ... 配置 EXTI 寄存器 ... */
/* 使能 NVIC 中断 */
NVIC_EnableIRQ(EXTI0_IRQn + (pin / 4));
return 0;
}
/* 注销中断回调 */
static void gpio_stm32_unregister_irq(uint32_t port, uint16_t pin)
{
if (pin > 15) return;
irq_callbacks[pin] = NULL;
/* 关闭 EXTI 中断 */
/* ... */
}
/* EXTI 中断服务函数(以 EXTI0 为例) */
void EXTI0_IRQHandler(void)
{
/* 检查中断标志位 */
if (EXTI->PR & (1UL << 0)) {
/* 清除标志位 */
EXTI->PR = (1UL << 0);
/* 调用回调 */
if (irq_callbacks[0] != NULL) {
irq_callbacks[0]();
}
}
}
4.4 实战:LED 驱动——把抽象层跑起来
理论说完了,咱们用 LED 驱动来验证一下这套抽象层好不好用。假设板子上有两个 LED:一个红色接 PA5,一个绿色接 PB0。
/* led_driver.c */
#include "gpio_interface.h"
/* LED 配置表 */
static gpio_cfg_t led_red_cfg = {
.port = GPIOA_BASE,
.pin = 5,
.dir = GPIO_DIR_OUTPUT,
.init_level = GPIO_LEVEL_LOW, /* 默认熄灭 */
.pull_up = 0,
.pull_down = 0
};
static gpio_cfg_t led_green_cfg = {
.port = GPIOB_BASE,
.pin = 0,
.dir = GPIO_DIR_OUTPUT,
.init_level = GPIO_LEVEL_LOW,
.pull_up = 0,
.pull_down = 0
};
/* 初始化所有 LED */
void led_init(void)
{
gpio.init(&led_red_cfg);
gpio.init(&led_green_cfg);
}
/* 控制红色 LED */
void led_red_on(void) { gpio.set_level(GPIOA_BASE, 5, GPIO_LEVEL_HIGH); }
void led_red_off(void) { gpio.set_level(GPIOA_BASE, 5, GPIO_LEVEL_LOW); }
void led_red_toggle(void) { gpio.toggle(GPIOA_BASE, 5); }
/* 控制绿色 LED */
void led_green_on(void) { gpio.set_level(GPIOB_BASE, 0, GPIO_LEVEL_HIGH); }
void led_green_off(void) { gpio.set_level(GPIOB_BASE, 0, GPIO_LEVEL_LOW); }
void led_green_toggle(void) { gpio.toggle(GPIOB_BASE, 0); }
/* 测试:LED 闪烁 + 按键中断 */
void led_test_with_button(void)
{
led_init();
/* 注册按键中断(假设按键接 PA0,下降沿触发) */
gpio_cfg_t btn_cfg = {
.port = GPIOA_BASE,
.pin = 0,
.dir = GPIO_DIR_INPUT,
.pull_up = 1
};
gpio.init(&btn_cfg);
gpio.register_irq(GPIOA_BASE, 0, GPIO_IRQ_FALLING, led_red_toggle);
while(1) {
/* 主循环做其他事情 */
led_green_toggle();
delay_ms(500);
}
}
你看,上层代码完全不知道底层是 STM32F4 还是 F1,甚至不知道 GPIO 寄存器长什么样。这就是抽象层的目的——把变化关在笼子里。
4.5 本章知识结构图
下面这张图,把 GPIO 抽象层的三层结构画清楚了。你可以看到,上层应用只跟接口层打交道,中间层做适配,底层才是真正的寄存器操作。
4.6 本章小结
| 知识点 | 核心要点 | 常见误区 |
|---|---|---|
| 接口定义 | 用函数指针结构体解耦 | 把硬件宏写进接口头文件 |
| 寄存器封装 | BSRR 操作比 ODR 更安全 | 忘记使能 GPIO 时钟 |
| 中断回调 | 查表法管理多个回调 | 中断里做耗时操作 |
| LED 驱动 | 先初始化再开中断 | 电平初始状态不确定 |
这套 GPIO 抽象层,我在三个不同的项目里验证过。从 STM32F103 到 F407,再到 G0 系列,上层代码一个字都没改。你想想看,如果一开始就把寄存器操作散落在业务代码里,换芯片时得有多痛苦。
嗯,这一章的内容就到这。代码你可以直接拿去用,但建议你根据自己的芯片型号,把时钟使能和 EXTI 配置部分补全。毕竟,抽象层是骨架,血肉还得你自己填。