2、GPIO驱动接口封装(一):标准GPIO读写接口定义,输入/输出/中断模式配置。

各位同学,咱们今天正式进入驱动封装的第一站——GPIO。

GPIO 这东西,说简单是真简单,无非就是读个电平、写个电平。但说复杂也复杂,因为它是 MCU 和外界交互最基础、最频繁的通道。我见过太多项目,前期 GPIO 接口定义得随意,后期移植到另一颗芯片时,改代码改到怀疑人生。

所以,这一章的核心目标就一个:定义一套干净、通用、不依赖具体芯片的 GPIO 抽象接口。这套接口,以后会贯穿我们整个课程。

2.1 接口设计的核心原则

在写代码之前,我们先聊聊设计思路。我个人习惯,做抽象层驱动时,脑子里始终绷着三根弦:

  • 原子性:一个函数只做一件事。读就是读,写就是写,别把配置和操作混在一起。
  • 可移植性:接口参数里不能出现某个芯片特有的寄存器名或位定义。要用枚举、结构体把这些东西包起来。
  • 易用性:调用者不需要知道底层是怎么操作的。他只需要说“我要把 PA0 设为输出高”,剩下的交给驱动。

你想想看,如果每个工程师都按自己的喜好来定义接口,那项目一大了,维护成本得多高?

2.2 标准 GPIO 读写接口定义

我们先从最基础的读写操作开始。一个 GPIO 引脚,无非就是读它的电平状态,或者写一个电平状态出去。

下面是我在多个项目中沉淀下来的一套接口原型。注意看参数类型,我用的是 uint8_t 和 uint16_t,而不是某个芯片的 GPIO_PinState。

/* gpio_interface.h */
#ifndef __GPIO_INTERFACE_H__
#define __GPIO_INTERFACE_H__

#include <stdint.h>

/* 电平状态定义 */
#define GPIO_PIN_RESET   0u
#define GPIO_PIN_SET     1u

/* 读取指定引脚的电平 */
uint8_t GPIO_ReadPin(uint16_t port, uint16_t pin);

/* 设置指定引脚的电平 */
void    GPIO_WritePin(uint16_t port, uint16_t pin, uint8_t state);

/* 翻转指定引脚的电平 */
void    GPIO_TogglePin(uint16_t port, uint16_t pin);

#endif /* __GPIO_INTERFACE_H__ */

设计要点:

  • port 和 pin 用 uint16_t,足够覆盖绝大多数 MCU 的 GPIO 数量。
  • 返回值统一用 uint8_t,0 表示低电平,非0 表示高电平。简单直接。
  • GPIO_TogglePin 这个函数很实用。我在做 LED 闪烁或者按键状态机时,经常用它,省去一次读操作。

2.3 输入/输出/中断模式配置

读写操作之前,得先把引脚配置好。GPIO 的模式配置,是移植过程中最容易出问题的地方。

为什么?因为不同 MCU 的 GPIO 模式定义差异太大了。有的芯片有 8 种模式,有的有 16 种。我们不能把这些差异暴露给上层。

我的做法是:定义一套精简的、通用的模式枚举,然后在底层适配时,再映射到具体的芯片寄存器。

/* gpio_interface.h (续) */

/* GPIO 通用模式枚举 */
typedef enum {
    GPIO_MODE_INPUT         = 0x00,  /* 浮空输入或上拉/下拉输入(由PU/PD决定) */
    GPIO_MODE_OUTPUT_PP     = 0x01,  /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD     = 0x02,  /* 开漏输出 */
    GPIO_MODE_AF_PP         = 0x03,  /* 复用推挽 */
    GPIO_MODE_AF_OD         = 0x04,  /* 复用开漏 */
    GPIO_MODE_IT_RISING     = 0x05,  /* 上升沿中断输入 */
    GPIO_MODE_IT_FALLING    = 0x06,  /* 下降沿中断输入 */
    GPIO_MODE_IT_RISING_FALLING = 0x07, /* 双边沿中断输入 */
} GPIO_Mode_t;

/* GPIO 输出速度枚举 */
typedef enum {
    GPIO_SPEED_LOW          = 0x00,
    GPIO_SPEED_MEDIUM       = 0x01,
    GPIO_SPEED_HIGH         = 0x02,
    GPIO_SPEED_VERY_HIGH    = 0x03,
} GPIO_Speed_t;

/* GPIO 内部上下拉枚举 */
typedef enum {
    GPIO_PULL_NONE          = 0x00,
    GPIO_PULL_UP            = 0x01,
    GPIO_PULL_DOWN          = 0x02,
} GPIO_Pull_t;

/* GPIO 初始化结构体 */
typedef struct {
    uint16_t        Pin;        /* 引脚号,支持按位或,如 GPIO_PIN_0 | GPIO_PIN_1 */
    GPIO_Mode_t     Mode;       /* 工作模式 */
    GPIO_Speed_t    Speed;      /* 输出速度,仅在输出模式有效 */
    GPIO_Pull_t     Pull;       /* 上下拉配置 */
} GPIO_Init_t;

/* 初始化/反初始化GPIO */
void GPIO_Init(uint16_t port, GPIO_Init_t* init);
void GPIO_DeInit(uint16_t port, uint16_t pin);

我曾经踩过的坑:

早期我做 STM32 到 NXP LPC 的移植时,直接把 STM32 的 8 种模式枚举拿过来用。结果 LPC 的 GPIO 模式寄存器结构完全不同,映射起来非常痛苦。后来我学乖了,抽象层只保留最通用的 7 种模式,把芯片特有的“模拟模式”、“事件输出”等特性,放到芯片级驱动里单独处理。

记住:抽象层不是万能的,它只解决 80% 的通用场景。剩下的 20% 特殊需求,可以通过扩展接口或者直接操作寄存器来实现。

2.4 中断回调机制

GPIO 中断是嵌入式系统里最常用的异步事件处理方式。抽象层的中断接口,需要解决两个问题:

  1. 如何注册中断回调函数?
  2. 如何区分是哪个引脚触发了中断?

我的方案是:提供一个统一的注册接口,让用户为每个引脚绑定一个回调函数。底层的中断服务程序(ISR)负责查询中断状态,然后调用对应的回调。

/* gpio_interface.h (续) */

/* 中断回调函数类型 */
typedef void (*GPIO_IRQ_Callback_t)(uint16_t pin);

/* 注册中断回调函数 */
void GPIO_RegisterIRQCallback(uint16_t port, uint16_t pin, GPIO_IRQ_Callback_t callback);

/* 注销中断回调函数 */
void GPIO_UnregisterIRQCallback(uint16_t port, uint16_t pin);

/* 全局中断使能/禁能 */
void GPIO_IRQ_Enable(uint16_t port, uint16_t pin);
void GPIO_IRQ_Disable(uint16_t port, uint16_t pin);

个人经验:

回调函数的执行时间一定要短。我在一个项目中,把延时较长的按键消抖逻辑直接写在了回调里,结果导致其他中断响应变慢。正确的做法是:回调里只设置一个事件标志,真正的处理逻辑放在主循环或者任务中执行。

2.5 本章知识体系总览

下面这张图,把 GPIO 抽象层的核心模块和它们之间的关系梳理了一下。你可以把它当作本章的“地图”。

GPIO 抽象层核心模块 用户应用层 读写接口 (ReadPin/WritePin/TogglePin) 配置接口 (Init/DeInit/Mode/Speed/Pull) 中断接口 (Register/Enable/Callback) 芯片级驱动实现 (STM32 / NXP / GD32 / ...) 设计要点说明: 1. 用户应用层只与抽象接口层交互,不直接操作芯片寄存器。 2. 抽象接口层定义通用枚举和结构体,屏蔽芯片差异。 3. 芯片级驱动实现具体的寄存器操作,完成模式映射。 4. 更换芯片时,只需重写芯片级驱动,上层代码无需改动。

2.6 接口使用示例

光说不练假把式。我们来看一个完整的例子:配置一个按键(输入中断)和一个 LED(输出)。

/* main.c 示例 */
#include "gpio_interface.h"

/* 按键中断回调 */
void Key_IRQHandler(uint16_t pin)
{
    if(pin == GPIO_PIN_0) {
        /* 翻转LED */
        GPIO_TogglePin(GPIO_PORTB, GPIO_PIN_1);
    }
}

void main(void)
{
    GPIO_Init_t led_init, key_init;

    /* 配置 LED: PB1, 推挽输出, 高速, 无上下拉 */
    led_init.Pin   = GPIO_PIN_1;
    led_init.Mode  = GPIO_MODE_OUTPUT_PP;
    led_init.Speed = GPIO_SPEED_HIGH;
    led_init.Pull  = GPIO_PULL_NONE;
    GPIO_Init(GPIO_PORTB, &led_init);

    /* 配置按键: PA0, 上升沿中断, 内部上拉 */
    key_init.Pin   = GPIO_PIN_0;
    key_init.Mode  = GPIO_MODE_IT_RISING;
    key_init.Speed = GPIO_SPEED_LOW;   /* 输入模式,速度无效 */
    key_init.Pull  = GPIO_PULL_UP;
    GPIO_Init(GPIO_PORTA, &key_init);

    /* 注册中断回调 */
    GPIO_RegisterIRQCallback(GPIO_PORTA, GPIO_PIN_0, Key_IRQHandler);

    /* 使能中断 */
    GPIO_IRQ_Enable(GPIO_PORTA, GPIO_PIN_0);

    while(1) {
        /* 主循环做其他事情 */
    }
}

你看,整个使用过程,完全不需要知道底层是 STM32 还是 GD32。这就是抽象层的力量。

2.7 本章小结

这一章我们完成了 GPIO 抽象层的核心接口定义。总结一下关键点:

  • 读写接口:ReadPin、WritePin、TogglePin,原子操作,参数通用。
  • 配置接口:通过枚举和结构体,统一了输入、输出、中断模式的配置方式。
  • 中断接口:提供了回调注册机制,让中断处理与硬件解耦。