3. GPIO 驱动开发:寄存器映射、推挽/开漏输出、上拉/下拉输入、中断触发模式配置与实战
GPIO 是嵌入式开发里最基础、也最绕不开的外设。说它简单吧,点个灯确实就几行代码;说它复杂吧,我在项目中见过太多因为 GPIO 配置不当导致的诡异问题——比如按键误触发、功耗异常、甚至芯片锁死。这一章,咱们就把 GPIO 的寄存器映射、输出模式、输入模式、中断配置彻底讲透。
3.1 寄存器映射:从物理地址到 C 语言指针
ARMv7 的 GPIO 外设挂在 APB 总线上,每个 GPIO 端口(如 GPIOA、GPIOB)都有一组固定的寄存器偏移。我习惯的做法是:先查芯片手册拿到基地址,然后用结构体把寄存器组映射出来。
/* STM32F4 GPIO 寄存器映射示例 */
#define GPIOA_BASE 0x40020000
#define GPIOB_BASE 0x40020400
typedef struct {
volatile uint32_t MODER; /* 模式寄存器,偏移 0x00 */
volatile uint32_t OTYPER; /* 输出类型寄存器,偏移 0x04 */
volatile uint32_t OSPEEDR; /* 输出速度寄存器,偏移 0x08 */
volatile uint32_t PUPDR; /* 上拉/下拉寄存器,偏移 0x0C */
volatile uint32_t IDR; /* 输入数据寄存器,偏移 0x10 */
volatile uint32_t ODR; /* 输出数据寄存器,偏移 0x14 */
volatile uint32_t BSRR; /* 位设置/清除寄存器,偏移 0x18 */
volatile uint32_t LCKR; /* 锁定寄存器,偏移 0x1C */
volatile uint32_t AFR[2]; /* 复用功能寄存器,偏移 0x20-0x24 */
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
#define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)
这里有个关键点:volatile 关键字绝对不能省。编译器优化会缓存寄存器值,不加 volatile 的话,你写了个 1 进去,读回来可能还是 0。我曾经在调试一个 SPI 片选信号时,就因为漏了 volatile,折腾了整整一个下午。
3.2 推挽输出 vs 开漏输出
输出模式是 GPIO 最常用的功能。但推挽和开漏到底怎么选?我直接说结论:
| 特性 | 推挽输出 | 开漏输出 |
|---|---|---|
| 输出高电平 | 由 VDD 直接驱动,电流能力强 | 需要外部上拉电阻,否则为高阻 |
| 输出低电平 | 由 GND 直接驱动 | 由 GND 直接驱动 |
| 典型应用 | LED、普通 GPIO 控制 | I²C 总线、多设备共享线 |
| 电平转换 | 不支持 | 通过上拉电阻到不同电压 |
推挽输出,说白了就是 PMOS 和 NMOS 交替导通。输出 1 时 PMOS 导通,直接拉到 VDD;输出 0 时 NMOS 导通,拉到 GND。这种模式驱动能力强,但多个推挽输出不能直接连在一起——一个输出 1 一个输出 0,那就是短路。
开漏输出则只控制低电平,高电平靠外部上拉电阻。我做过一个项目,需要把 3.3V 的 MCU 和 5V 的传感器通信,就是用开漏输出加 5V 上拉实现的。你想想看,如果当时用了推挽,电平不匹配,通信肯定失败。
/* 配置推挽输出:PA5 控制 LED */
GPIOA->MODER &= ~(0x3 << (5 * 2)); /* 清零 */
GPIOA->MODER |= (0x1 << (5 * 2)); /* 设为输出模式 */
GPIOA->OTYPER &= ~(0x1 << 5); /* 推挽输出 */
GPIOA->OSPEEDR |= (0x2 << (5 * 2)); /* 高速 */
/* 配置开漏输出:PB6 用于 I²C 时钟 */
GPIOB->MODER |= (0x1 << (6 * 2)); /* 输出模式 */
GPIOB->OTYPER |= (0x1 << 6); /* 开漏输出 */
GPIOB->PUPDR |= (0x1 << (6 * 2)); /* 内部上拉(可选) */
3.3 上拉/下拉输入与电平采样
输入模式看似简单,但坑不少。GPIO 作为输入时,引脚处于高阻态,外部信号直接驱动。但问题来了:如果引脚悬空,电平会飘忽不定。这时候就需要上拉或下拉电阻来固定默认电平。
ARMv7 的 GPIO 内部集成了可配置的上拉/下拉电阻,阻值一般在 40kΩ 左右。配置方式在 PUPDR 寄存器:
/* 配置 PA0 为上拉输入,用于检测按键 */
GPIOA->MODER &= ~(0x3 << (0 * 2)); /* 输入模式 */
GPIOA->PUPDR |= (0x1 << (0 * 2)); /* 上拉 */
/* 配置 PC13 为下拉输入 */
GPIOC->MODER &= ~(0x3 << (13 * 2)); /* 输入模式 */
GPIOC->PUPDR |= (0x2 << (13 * 2)); /* 下拉 */
读输入电平用 IDR 寄存器:
uint8_t key_state = (GPIOA->IDR >> 0) & 0x01;
if (key_state == 0) {
/* 按键按下(上拉输入,按下为低) */
}
这里有个经验:按键检测一定要做消抖。硬件上可以加 RC 滤波,软件上我习惯用 10-20ms 的延时采样。别问我为什么——有一次产品在振动环境下,没消抖的按键一天误触发几百次,售后差点把我电话打爆。
3.4 中断触发模式配置
轮询读 GPIO 太浪费 CPU 了,尤其是按键、传感器这类事件。中断才是正道。ARMv7 的 GPIO 中断配置分三步:
- 配置 GPIO 模式:设为输入模式,选择上拉/下拉
- 配置中断触发条件:上升沿、下降沿、双边沿、高电平、低电平
- 使能 NVIC 中断:设置中断优先级,使能中断线
以 STM32F4 为例,GPIO 中断通过 EXTI(外部中断/事件控制器)实现:
/* 配置 PA0 为下降沿触发中断 */
/* 1. 使能 SYSCFG 时钟 */
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
/* 2. 将 PA0 连接到 EXTI0 */
SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
SYSCFG->EXTICR[0] |= SYSCFG_EXTICR1_EXTI0_PA;
/* 3. 配置 EXTI 触发方式 */
EXTI->IMR |= EXTI_IMR_MR0; /* 不屏蔽中断 */
EXTI->FTSR |= EXTI_FTSR_TR0; /* 下降沿触发 */
/* 4. 配置 NVIC */
NVIC_SetPriority(EXTI0_IRQn, 2);
NVIC_EnableIRQ(EXTI0_IRQn);
中断服务函数里,记得清除中断标志位:
void EXTI0_IRQHandler(void) {
if (EXTI->PR & EXTI_PR_PR0) {
/* 处理按键事件 */
EXTI->PR = EXTI_PR_PR0; /* 写 1 清除标志 */
}
}
3.5 实战:按键中断控制 LED
理论说完了,咱们来个完整的例子。用 PA0 按键(下降沿触发)控制 PB0 LED 的亮灭:
/* gpio_interrupt_demo.c */
#include "stm32f4xx.h"
volatile uint8_t led_toggle_flag = 0;
void GPIO_Init(void) {
/* 使能 GPIO 时钟 */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOBEN;
/* PA0: 上拉输入 */
GPIOA->MODER &= ~(0x3 << (0 * 2));
GPIOA->PUPDR |= (0x1 << (0 * 2));
/* PB0: 推挽输出 */
GPIOB->MODER |= (0x1 << (0 * 2));
GPIOB->OTYPER &= ~(0x1 << 0);
GPIOB->OSPEEDR |= (0x2 << (0 * 2));
}
void EXTI_Init(void) {
/* 使能 SYSCFG 时钟 */
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
/* PA0 -> EXTI0 */
SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
SYSCFG->EXTICR[0] |= SYSCFG_EXTICR1_EXTI0_PA;
/* 下降沿触发 */
EXTI->IMR |= EXTI_IMR_MR0;
EXTI->FTSR |= EXTI_FTSR_TR0;
/* NVIC 配置 */
NVIC_SetPriority(EXTI0_IRQn, 1);
NVIC_EnableIRQ(EXTI0_IRQn);
}
void EXTI0_IRQHandler(void) {
if (EXTI->PR & EXTI_PR_PR0) {
led_toggle_flag = 1;
EXTI->PR = EXTI_PR_PR0;
}
}
int main(void) {
GPIO_Init();
EXTI_Init();
while (1) {
if (led_toggle_flag) {
led_toggle_flag = 0;
GPIOB->ODR ^= (0x1 << 0); /* 翻转 LED */
}
}
}
这个例子虽然简单,但包含了 GPIO 中断的全部要素。你可以在实际项目中把它扩展成多按键、多中断优先级的系统。
3.6 知识体系总览
下面这张图把 GPIO 驱动的核心知识点串起来了,建议你保存下来,开发时对照着看:
嗯,这张图基本把 GPIO 驱动的骨架画清楚了。从上到下依次是:寄存器映射 → 输出/输入模式选择 → 中断触发配置 → NVIC 使能。每个环节都有对应的寄存器和配置方法。
- 寄存器映射用结构体 + volatile 关键字,保证读写正确
- 推挽输出驱动能力强,开漏输出适合多设备共享总线
- 输入模式必须配上拉或下拉,防止引脚悬空
- 中断配置三步走:GPIO 模式 → EXTI 触发条件 → NVIC 使能
- 中断服务函数要短,用标志位通知主循环处理