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封装的核心逻辑串起来。
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,原子操作,中断安全。