3. GPIO驱动封装实战(HAL层)

GPIO驱动,说白了就是MCU跟外部世界打招呼的方式。你想想看,一个芯片要控制LED、读取按键、驱动蜂鸣器,最终都得落到那几个引脚上。我做了这么多年嵌入式,发现很多项目出问题,根源都在GPIO配置上——不是初始化顺序错了,就是中断没处理好。

这一章,我们就来把GPIO的HAL层驱动彻底封装好。目标是:定义一套标准接口,不管你换哪个STM32型号,上层应用代码一行都不用改。

3.1 GPIO标准接口定义

接口设计这件事,我个人的习惯是:先想好使用者怎么调用,再想底层怎么实现。说白了就是「面向接口编程」。对于GPIO,我们只需要四个核心操作:

  • 初始化:配置引脚模式、速度、上下拉
  • 写操作:输出高电平或低电平
  • 读操作:读取引脚当前电平
  • 中断回调:当引脚电平变化时,执行用户注册的函数

下面是接口定义的头文件,我建议你直接复制到项目里用:

/* gpio_hal.h */
#ifndef __GPIO_HAL_H__
#define __GPIO_HAL_H__

#include <stdint.h>

/* GPIO引脚电平定义 */
typedef enum {
    GPIO_PIN_RESET = 0,
    GPIO_PIN_SET   = 1
} GPIO_PinState_t;

/* GPIO模式枚举 */
typedef enum {
    GPIO_MODE_INPUT,         /* 输入模式 */
    GPIO_MODE_OUTPUT_PP,     /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD,     /* 开漏输出 */
    GPIO_MODE_AF_PP,         /* 复用推挽 */
    GPIO_MODE_AF_OD,         /* 复用开漏 */
    GPIO_MODE_IT_RISING,     /* 上升沿中断 */
    GPIO_MODE_IT_FALLING,    /* 下降沿中断 */
    GPIO_MODE_IT_RISING_FALLING /* 双边沿中断 */
} GPIO_Mode_t;

/* GPIO配置结构体 */
typedef struct {
    uint32_t        pin;        /* 引脚号,如 GPIO_PIN_0 */
    GPIO_Mode_t     mode;       /* 工作模式 */
    uint8_t         pull;       /* 上下拉:0-无,1-上拉,2-下拉 */
    uint8_t         speed;      /* 速度:0-低,1-中,2-高,3-极高 */
    void            (*irq_callback)(void); /* 中断回调函数指针 */
} GPIO_Config_t;

/* 标准接口函数 */
void    GPIO_HAL_Init(GPIO_Config_t *config);
void    GPIO_HAL_WritePin(uint32_t pin, GPIO_PinState_t state);
GPIO_PinState_t GPIO_HAL_ReadPin(uint32_t pin);
void    GPIO_HAL_IRQHandler(uint32_t pin);

#endif /* __GPIO_HAL_H__ */

设计要点:注意我把中断回调函数指针放在了配置结构体里。这样做的好处是,每个引脚可以绑定不同的回调函数,灵活性很高。我在一个多按键项目中用过这个设计,每个按键触发不同的功能,代码非常干净。

3.2 寄存器操作封装

STM32的GPIO寄存器,说白了就是几个32位的控制字。我刚开始学的时候,总觉得寄存器手册看得头晕。后来总结了一个规律:GPIO寄存器就三组——控制寄存器(CRL/CRH)、数据寄存器(IDR/ODR)、置位/复位寄存器(BSRR/BRR)。

我们直接操作寄存器,比调用HAL库函数快得多。尤其是在中断里,能省下好几个微秒。

寄存器 地址偏移 功能描述 操作方式
CRL (0x00) 0x00 低8位引脚配置(Pin0~7) 每4位配置一个引脚
CRH (0x04) 0x04 高8位引脚配置(Pin8~15) 每4位配置一个引脚
IDR (0x08) 0x08 输入数据寄存器 只读,每位对应一个引脚
ODR (0x0C) 0x0C 输出数据寄存器 读写,每位对应一个引脚
BSRR (0x10) 0x10 置位/复位寄存器 低16位置位,高16位复位
BRR (0x14) 0x14 位复位寄存器 只复位,写1对应引脚输出低

这里有个小技巧:写ODR的时候,千万不要用「读-改-写」的方式。为什么?因为如果在中断里同时操作同一个GPIO组的不同引脚,会出问题。我建议直接用BSRR寄存器,它原子操作,不会被打断。

/* 寄存器操作封装 */
#define GPIO_BASE_ADDR(gpiox)   (GPIOA_BASE + (gpiox * 0x400))

/* 快速写引脚 - 使用BSRR实现原子操作 */
static inline void GPIO_Reg_WritePin(uint32_t gpiox, uint32_t pin, uint8_t state)
{
    volatile uint32_t *bsrr = (uint32_t *)(GPIO_BASE_ADDR(gpiox) + 0x10);
    
    if (state) {
        *bsrr = pin;           /* 写1到低16位,置位 */
    } else {
        *bsrr = (pin << 16);  /* 写1到高16位,复位 */
    }
}

/* 快速读引脚 */
static inline uint8_t GPIO_Reg_ReadPin(uint32_t gpiox, uint32_t pin)
{
    volatile uint32_t *idr = (uint32_t *)(GPIO_BASE_ADDR(gpiox) + 0x08);
    return (*idr & pin) ? 1 : 0;
}

经验之谈:用inline函数封装寄存器操作,既保留了直接操作寄存器的速度,又让代码可读性更好。编译器会帮你内联展开,不会产生函数调用开销。

3.3 基于STM32的GPIO HAL实现

好了,接口和寄存器操作都准备好了,现在来实现真正的驱动。我会以STM32F103为例,但思路适用于整个STM32系列。

3.3.1 初始化实现

初始化函数要做三件事:使能时钟、配置模式、设置中断(如果需要)。

/* gpio_hal.c */
#include "gpio_hal.h"
#include "stm32f10x.h"  /* 根据具体型号调整 */

void GPIO_HAL_Init(GPIO_Config_t *config)
{
    uint32_t gpiox;
    uint32_t cr_reg_addr;
    uint32_t cr_value;
    uint8_t  pin_pos;
    
    /* 1. 根据引脚号确定GPIO组和时钟 */
    gpiox = config->pin / 16;  /* 0=GPIOA, 1=GPIOB, ... */
    pin_pos = config->pin % 16;
    
    /* 使能时钟 - 这里用寄存器操作 */
    RCC->APB2ENR |= (1 << (gpiox + 2));  /* 对应GPIOA~G的时钟使能位 */
    
    /* 2. 配置CRL或CRH寄存器 */
    if (pin_pos < 8) {
        cr_reg_addr = (uint32_t)&(GPIOA->CRL) + (gpiox * 0x400);
    } else {
        cr_reg_addr = (uint32_t)&(GPIOA->CRH) + (gpiox * 0x400);
        pin_pos -= 8;
    }
    
    /* 3. 根据模式构造配置值 */
    cr_value = *(volatile uint32_t *)cr_reg_addr;
    cr_value &= ~(0xF << (pin_pos * 4));  /* 清除原有配置 */
    
    switch (config->mode) {
        case GPIO_MODE_INPUT:
            cr_value |= (0x4 << (pin_pos * 4));  /* 浮空输入 */
            break;
        case GPIO_MODE_OUTPUT_PP:
            cr_value |= (0x1 << (pin_pos * 4));  /* 推挽输出,10MHz */
            break;
        /* 其他模式类似处理... */
    }
    
    *(volatile uint32_t *)cr_reg_addr = cr_value;
    
    /* 4. 配置上下拉(通过ODR寄存器) */
    if (config->pull == 1) {
        GPIOA->ODR |= (1 << config->pin);   /* 上拉 */
    } else if (config->pull == 2) {
        GPIOA->ODR &= ~(1 << config->pin);  /* 下拉 */
    }
    
    /* 5. 如果是中断模式,配置NVIC */
    if (config->mode >= GPIO_MODE_IT_RISING) {
        /* 配置EXTI和NVIC */
        EXTI->IMR |= (1 << config->pin);
        if (config->mode == GPIO_MODE_IT_RISING) {
            EXTI->RTSR |= (1 << config->pin);
        }
        NVIC_EnableIRQ(EXTI0_IRQn + (config->pin / 4));
    }
}

避坑指南:我曾经在一个项目里,因为忘记使能GPIO时钟,结果调试了整整一个下午。引脚死活不出电平,示波器一量全是0。后来才发现RCC寄存器没配。记住:STM32的任何外设,第一步永远是使能时钟。

3.3.2 读写操作实现

读写操作就简单多了,直接调用我们之前封装的寄存器函数:

void GPIO_HAL_WritePin(uint32_t pin, GPIO_PinState_t state)
{
    uint32_t gpiox = pin / 16;
    uint32_t pin_mask = 1 << (pin % 16);
    GPIO_Reg_WritePin(gpiox, pin_mask, state);
}

GPIO_PinState_t GPIO_HAL_ReadPin(uint32_t pin)
{
    uint32_t gpiox = pin / 16;
    uint32_t pin_mask = 1 << (pin % 16);
    return (GPIO_PinState_t)GPIO_Reg_ReadPin(gpiox, pin_mask);
}

3.3.3 中断回调实现

中断处理是GPIO驱动里最容易出问题的部分。我的原则是:中断服务函数里只做最轻量的事——清除标志位,调用回调,然后赶紧退出。

/* 全局回调函数表 - 每个引脚一个 */
static void (*gpio_irq_callbacks[16])(void) = {NULL};

/* 注册回调 - 在初始化时调用 */
static void GPIO_RegisterCallback(uint32_t pin, void (*callback)(void))
{
    if (pin < 16) {
        gpio_irq_callbacks[pin] = callback;
    }
}

/* 中断统一入口 */
void GPIO_HAL_IRQHandler(uint32_t pin)
{
    /* 检查中断标志位 */
    if (EXTI->PR & (1 << pin)) {
        /* 清除中断标志 */
        EXTI->PR = (1 << pin);
        
        /* 调用用户回调 */
        if (gpio_irq_callbacks[pin] != NULL) {
            gpio_irq_callbacks[pin]();
        }
    }
}

/* 具体中断服务函数示例 - 以EXTI0为例 */
void EXTI0_IRQHandler(void)
{
    GPIO_HAL_IRQHandler(0);  /* Pin 0的中断 */
}

关键设计:回调函数表用静态数组实现,避免了动态分配的开销。每个引脚独立注册回调,互不干扰。我在一个8按键的矩阵键盘项目里用过这个方案,每个按键触发不同的业务逻辑,代码结构非常清晰。

3.4 知识结构总览

下面这张图,把GPIO HAL层的整体架构梳理清楚了。你可以把它当作开发时的路线图:

GPIO HAL层驱动架构图 GPIO HAL层驱动架构 应用层(用户代码) 标准接口层(gpio_hal.h) Init | WritePin | ReadPin | IRQHandler 实现层(gpio_hal.c) 时钟配置 RCC->APB2ENR 模式配置 CRL/CRH寄存器 中断配置 EXTI + NVIC 寄存器操作层(寄存器封装) BSRR原子写 | IDR快速读 | CRL/CRH配置 硬件层(STM32 GPIO外设) 架构分层清晰:应用层 → 标准接口 → 实现层 → 寄存器操作 → 硬件

3.5 使用示例

最后,我们看一个完整的使用示例。这个例子初始化一个LED输出和一个按键输入(带中断),然后跑一个简单的闪烁逻辑:

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

/* 按键中断回调 */
void KeyPress_Callback(void)
{
    /* 按键按下,翻转LED */
    GPIO_HAL_WritePin(1, !GPIO_HAL_ReadPin(1));  /* PB1 = LED */
}

int main(void)
{
    GPIO_Config_t led_config, key_config;
    
    /* 配置LED - PB1,推挽输出 */
    led_config.pin = 1;           /* PB1 */
    led_config.mode = GPIO_MODE_OUTPUT_PP;
    led_config.pull = 0;
    led_config.speed = 2;
    led_config.irq_callback = NULL;
    GPIO_HAL_Init(&led_config);
    
    /* 配置按键 - PA0,下降沿中断 */
    key_config.pin = 0;           /* PA0 */
    key_config.mode = GPIO_MODE_IT_FALLING;
    key_config.pull = 1;          /* 上拉,按键接地 */
    key_config.speed = 0;
    key_config.irq_callback = KeyPress_Callback;
    GPIO_HAL_Init(&key_config);
    
    /* 初始状态:LED灭 */
    GPIO_HAL_WritePin(1, GPIO_PIN_RESET);
    
    while (1) {
        /* 主循环可以干别的事,中断会处理按键 */
    }
}

个人建议:在实际项目中,我习惯把每个外设的配置单独写成一个函数,比如 LED_Init()、Key_Init()。这样main函数里就几行调用,清晰得很。别把所有配置都堆在main里,那会变成一锅粥。

嗯,GPIO的HAL层封装到这里就差不多了。这套接口我在三个不同的STM32项目里用过,从F1到F4再到L4,移植的时候只需要改底层寄存器地址和时钟配置,上层代码完全不用动。这就是分层架构的魅力——一次封装,到处复用。