2、GPIO驱动封装(基础篇):寄存器操作 vs 标准库 vs HAL库,定义GPIO_Pin结构体,实现初始化与电平控制。

各位同学,咱们直接进入正题。

GPIO,说白了就是芯片的“手脚”。你要让LED亮、让蜂鸣器响、读按键状态,全得靠它。但问题来了——怎么写GPIO代码,才算“好”的嵌入式代码?

我见过太多项目,GPIO初始化散落在各个.c文件里,今天用寄存器直接怼,明天又换成HAL库。等要移植到另一颗芯片时,那叫一个酸爽。所以这一章,咱们就彻底把GPIO的“底裤”扒干净,然后封装出一套能到处搬的驱动层。

2.1 三种操作方式的“爱恨情仇”

先看个对比,心里有个底。

操作方式 代码量 可读性 移植性 执行效率 我的评价
寄存器直接操作 少 差 极差 最高 调试时看一眼寄存器还行,量产代码别这么写
标准外设库(SPL) 中等 中等 中等 较高 老项目还在用,但官方早不维护了
HAL库 多 好 好 较低 CubeMX生成快,但中断里别用HAL_Delay

你可能会问:“那我到底该学哪个?”

我的答案是:全都要会,但写应用层时,一个都不用。

为什么?因为我们要做的是——分层封装。底层用寄存器还是HAL,那是驱动的事。上层应用只管调用 GPIO_SetPin() 就行。

核心思想: 驱动层向上提供统一接口,向下屏蔽硬件差异。这样你从STM32F1换到F4,甚至换到GD32,应用代码一行都不用改。

2.2 定义GPIO_Pin结构体——给每个引脚一个“身份证”

我个人习惯,先定义一个结构体,把引脚的所有属性打包在一起。这样初始化时传一个指针进去,清爽得很。

/* gpio_drv.h */
#ifndef __GPIO_DRV_H
#define __GPIO_DRV_H

#include <stdint.h>

/* GPIO端口枚举,方便扩展 */
typedef enum {
    GPIO_PORT_A = 0,
    GPIO_PORT_B,
    GPIO_PORT_C,
    GPIO_PORT_D,
    GPIO_PORT_E,
    /* ... 按芯片型号扩展 */
} GPIO_Port_t;

/* GPIO引脚号枚举 */
typedef enum {
    GPIO_PIN_0  = 0,
    GPIO_PIN_1,
    GPIO_PIN_2,
    /* ... 一直到 PIN_15 */
    GPIO_PIN_15
} GPIO_PinNum_t;

/* 引脚模式枚举 */
typedef enum {
    GPIO_MODE_INPUT         = 0x00,
    GPIO_MODE_OUTPUT_PP     = 0x01,  /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD     = 0x02,  /* 开漏输出 */
    GPIO_MODE_AF_PP         = 0x03,  /* 复用推挽 */
    GPIO_MODE_AF_OD         = 0x04,  /* 复用开漏 */
    GPIO_MODE_ANALOG        = 0x05   /* 模拟模式 */
} GPIO_Mode_t;

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

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

/* ★★★ GPIO引脚结构体 —— 核心数据结构 ★★★ */
typedef struct {
    GPIO_Port_t     port;       /* 端口号 */
    GPIO_PinNum_t   pin;        /* 引脚号 */
    GPIO_Mode_t     mode;       /* 工作模式 */
    GPIO_Speed_t    speed;      /* 输出速度(输出模式有效) */
    GPIO_Pull_t     pull;       /* 上下拉 */
} GPIO_Pin_t;

/* 对外接口声明 */
void GPIO_Init(GPIO_Pin_t* gpio);
void GPIO_SetPin(GPIO_Pin_t* gpio);
void GPIO_ResetPin(GPIO_Pin_t* gpio);
uint8_t GPIO_ReadPin(GPIO_Pin_t* gpio);
void GPIO_TogglePin(GPIO_Pin_t* gpio);

#endif /* __GPIO_DRV_H */

我的小习惯: 枚举值从0开始,并且预留注释“按芯片型号扩展”。这样换芯片时,只需要在枚举里加新端口,上层逻辑完全不受影响。

2.3 初始化实现——以寄存器操作为例,把底层“焊死”

好,结构体定义完了。接下来是实现。我选择用寄存器操作来做底层实现,原因很简单:效率最高,且不依赖任何库。你只要把寄存器地址宏定义好,就能跑。

先看代码,再解释。

/* gpio_drv.c */
#include "gpio_drv.h"

/* 寄存器基地址 —— 以STM32F1为例 */
#define GPIOA_BASE      ((uint32_t)0x40010800)
#define GPIOB_BASE      ((uint32_t)0x40010C00)
#define GPIOC_BASE      ((uint32_t)0x40011000)
/* ... 其他端口 */

/* 寄存器偏移 */
#define GPIO_CRL_OFFSET     0x00    /* 配置低寄存器 */
#define GPIO_CRH_OFFSET     0x04    /* 配置高寄存器 */
#define GPIO_IDR_OFFSET     0x08    /* 输入数据寄存器 */
#define GPIO_ODR_OFFSET     0x0C    /* 输出数据寄存器 */
#define GPIO_BSRR_OFFSET    0x10    /* 位设置/清除寄存器 */
#define GPIO_BRR_OFFSET     0x14    /* 位清除寄存器 */

/* 获取端口基地址的辅助宏 */
#define GPIO_BASE(port)     (GPIOA_BASE + (port) * 0x0400)

/* 获取寄存器地址的辅助宏 */
#define GPIO_REG(port, offset)  (*(volatile uint32_t *)(GPIO_BASE(port) + (offset)))

void GPIO_Init(GPIO_Pin_t* gpio)
{
    uint32_t crl, crh;
    uint32_t mode_bits, cnf_bits;
    uint32_t pin_pos = gpio->pin;

    /* 1. 计算模式位和配置位 */
    /* 模式:00=输入, 01=输出10MHz, 10=输出2MHz, 11=输出50MHz */
    /* 配置:输入模式时 00=模拟, 01=浮空, 10=上拉/下拉, 11=保留 */
    /*       输出模式时 00=推挽, 01=开漏, 10=复用推挽, 11=复用开漏 */

    if (gpio->mode == GPIO_MODE_INPUT) {
        mode_bits = 0x00;  /* 输入模式 */
        if (gpio->pull == GPIO_PULL_UP) {
            cnf_bits = 0x02;  /* 上拉/下拉输入 */
        } else if (gpio->pull == GPIO_PULL_DOWN) {
            cnf_bits = 0x02;  /* 同上,上下拉由ODR决定 */
        } else {
            cnf_bits = 0x01;  /* 浮空输入 */
        }
    } else {
        /* 输出模式 */
        switch (gpio->speed) {
            case GPIO_SPEED_LOW:      mode_bits = 0x01; break;  /* 2MHz */
            case GPIO_SPEED_MEDIUM:   mode_bits = 0x01; break;  /* 10MHz */
            case GPIO_SPEED_HIGH:     mode_bits = 0x01; break;  /* 50MHz */
            default:                  mode_bits = 0x01; break;
        }

        switch (gpio->mode) {
            case GPIO_MODE_OUTPUT_PP: cnf_bits = 0x00; break;
            case GPIO_MODE_OUTPUT_OD: cnf_bits = 0x01; break;
            case GPIO_MODE_AF_PP:     cnf_bits = 0x02; break;
            case GPIO_MODE_AF_OD:     cnf_bits = 0x03; break;
            default:                  cnf_bits = 0x00; break;
        }
    }

    /* 2. 判断是CRL还是CRH */
    if (pin_pos < 8) {
        /* 低8位,操作CRL */
        crl = GPIO_REG(gpio->port, GPIO_CRL_OFFSET);
        crl &= ~(0x0F << (pin_pos * 4));  /* 先清零 */
        crl |= ((cnf_bits << 2) | mode_bits) << (pin_pos * 4);
        GPIO_REG(gpio->port, GPIO_CRL_OFFSET) = crl;
    } else {
        /* 高8位,操作CRH */
        pin_pos -= 8;
        crh = GPIO_REG(gpio->port, GPIO_CRH_OFFSET);
        crh &= ~(0x0F << (pin_pos * 4));
        crh |= ((cnf_bits << 2) | mode_bits) << (pin_pos * 4);
        GPIO_REG(gpio->port, GPIO_CRH_OFFSET) = crh;
    }

    /* 3. 处理上下拉(输入模式时,上下拉由ODR控制) */
    if (gpio->mode == GPIO_MODE_INPUT) {
        if (gpio->pull == GPIO_PULL_UP) {
            GPIO_REG(gpio->port, GPIO_ODR_OFFSET) |= (1 << gpio->pin);
        } else if (gpio->pull == GPIO_PULL_DOWN) {
            GPIO_REG(gpio->port, GPIO_ODR_OFFSET) &= ~(1 << gpio->pin);
        }
    }
}

我曾经踩过的坑: 写CRL/CRH寄存器时,一定要先“读-改-写”,不能直接赋值。否则会把其他引脚配置冲掉。我见过有人直接写 GPIOA->CRL = 0x33333333,结果PA0~PA7全变成50MHz推挽输出,调试时一脸懵。

2.4 电平控制——BSRR才是王道

初始化搞定了,接下来是电平控制。很多新手喜欢直接操作ODR寄存器,比如:

GPIOA->ODR |= (1 << 5);   /* 置高 */
GPIOA->ODR &= ~(1 << 5);  /* 置低 */

这样写有什么问题?不是原子操作。 如果在中断里和主循环同时操作同一个端口的不同引脚,可能会产生“读-改-写”冲突,导致电平异常。

正确的做法是:使用BSRR(位设置/清除寄存器)。 写1到低16位置高,写1到高16位置低,硬件自动完成,无需读-改-写。

void GPIO_SetPin(GPIO_Pin_t* gpio)
{
    /* BSRR低16位:写1置高 */
    GPIO_REG(gpio->port, GPIO_BSRR_OFFSET) = (1 << gpio->pin);
}

void GPIO_ResetPin(GPIO_Pin_t* gpio)
{
    /* BSRR高16位:写1置低(或者用BRR寄存器) */
    GPIO_REG(gpio->port, GPIO_BSRR_OFFSET) = (1 << (gpio->pin + 16));
}

void GPIO_TogglePin(GPIO_Pin_t* gpio)
{
    /* 翻转:读ODR,写BSRR */
    uint32_t odr = GPIO_REG(gpio->port, GPIO_ODR_OFFSET);
    if (odr & (1 << gpio->pin)) {
        GPIO_ResetPin(gpio);
    } else {
        GPIO_SetPin(gpio);
    }
}

uint8_t GPIO_ReadPin(GPIO_Pin_t* gpio)
{
    uint32_t idr = GPIO_REG(gpio->port, GPIO_IDR_OFFSET);
    return (idr & (1 << gpio->pin)) ? 1 : 0;
}

性能小贴士: 如果你需要频繁翻转一个引脚(比如软件模拟PWM),建议直接用BSRR的原子操作。我曾在电机驱动项目里,用BSRR实现了20ns级别的引脚翻转,HAL库的HAL_GPIO_TogglePin根本做不到这个速度。

2.5 知识体系总览

说了这么多,咱们用一张图把GPIO封装的核心逻辑串起来。

GPIO驱动分层封装核心逻辑 应用层(main.c / task.c) 统一接口层(GPIO_Init / GPIO_SetPin / GPIO_ReadPin) 寄存器操作 标准外设库(SPL) HAL库 GPIO_Pin结构体(port, pin, mode, speed, pull) 贯穿三层,作为参数传递 核心原则:上层不关心底层实现,底层更换不影响应用代码 移植时只需重写底层三个函数,上层和结构体定义完全复用

2.6 使用示例——跑起来看看

最后,咱们写个main.c,看看这套封装用起来有多爽。

/* main.c */
#include "gpio_drv.h"

/* 定义LED引脚:PA5,推挽输出,50MHz,无上下拉 */
GPIO_Pin_t led_pin = {
    .port   = GPIO_PORT_A,
    .pin    = GPIO_PIN_5,
    .mode   = GPIO_MODE_OUTPUT_PP,
    .speed  = GPIO_SPEED_HIGH,
    .pull   = GPIO_PULL_NONE
};

/* 定义按键引脚:PB0,上拉输入 */
GPIO_Pin_t key_pin = {
    .port   = GPIO_PORT_B,
    .pin    = GPIO_PIN_0,
    .mode   = GPIO_MODE_INPUT,
    .speed  = GPIO_SPEED_LOW,   /* 输入模式,速度无效 */
    .pull   = GPIO_PULL_UP
};

int main(void)
{
    /* 初始化两个引脚 */
    GPIO_Init(&led_pin);
    GPIO_Init(&key_pin);

    while (1) {
        if (GPIO_ReadPin(&key_pin) == 0) {  /* 按键按下(低电平) */
            GPIO_SetPin(&led_pin);           /* LED亮 */
        } else {
            GPIO_ResetPin(&led_pin);         /* LED灭 */
        }

        /* 或者更骚的操作:按键按一次,LED翻转一次 */
        /* GPIO_TogglePin(&led_pin); */
    }
}

你看,应用层完全不知道底层是寄存器还是HAL。哪天你想换到HAL库,只需要重写 gpio_drv.c 里的五个函数,main.c 一行都不用动。

总结一下这章的核心:

  • 结构体先行: GPIO_Pin 把引脚所有属性打包,初始化传指针,清晰又安全。
  • 接口统一: Init / Set / Reset / Read / Toggle 五个函数,覆盖所有基本操作。
  • 底层隔离: 寄存器、SPL、HAL 随便换,上层代码纹丝不动。
  • BSRR大法好: 电平控制用BSRR,原子操作,中断安全。