4. GPIO驱动实战:从寄存器映射到按键中断
GPIO 是嵌入式开发里最基础、也最绕不开的外设。说白了,它就是芯片跟外界打交道的那一排引脚。你让它输出高电平,LED 就亮;你检测它电平变化,就知道按键按下了。但真正上手做裸机驱动时,很多人会卡在寄存器映射这一步——明明手册上写着地址,代码里一跑就是不对。
这一章,我们就从寄存器映射开始,一步步点亮 LED,再搞定按键中断。我会把项目中踩过的坑也一并讲出来。
4.1 GPIO寄存器映射——别被地址吓到
ARMv7 的 GPIO 外设,通常挂在 AHB/APB 总线上。每个 GPIO 端口(比如 GPIOA、GPIOB)都有一组控制寄存器,它们被映射到固定的内存地址上。
举个例子,STM32F4 的 GPIOA 基地址是 0x40020000。你往 0x40020000 + 0x00 写数据,就是在配置 GPIOA 的模式寄存器(MODER)。
核心概念: 在 ARM 裸机开发中,操作外设就是操作内存地址。没有操作系统帮你封装,你得自己把地址转换成 C 语言的指针。
我个人习惯这样定义寄存器映射:
// GPIOA 寄存器结构体
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_BASE 0x40020000
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
注意 volatile 关键字。不加它,编译器优化时可能会把寄存器读写操作给优化掉——我在一个项目里吃过这个亏,LED 死活不亮,查了半天发现是编译器把写寄存器的语句给跳过了。
| 寄存器 | 偏移 | 作用 |
|---|---|---|
| MODER | 0x00 | 配置输入/输出/复用/模拟模式 |
| OTYPER | 0x04 | 推挽输出或开漏输出 |
| OSPEEDR | 0x08 | 输出速度等级 |
| PUPDR | 0x0C | 内部上拉/下拉/浮空 |
| IDR | 0x10 | 读取引脚电平 |
| ODR | 0x14 | 设置引脚输出电平 |
| BSRR | 0x18 | 原子操作置位或复位 |
小技巧: 用 BSRR 代替 ODR 来设置引脚,可以避免中断中读写 ODR 导致的读-改-写冲突。BSRR 低 16 位置位,高 16 位复位,一次写入搞定。
4.2 LED灯控制——从点灯到闪烁
点亮 LED 是嵌入式界的 "Hello World"。但别小看它,这里面藏着 GPIO 配置的完整流程。
假设 LED 接在 PA5 上,高电平点亮。步骤很简单:
- 使能 GPIOA 时钟 —— 在 RCC 寄存器里打开对应位。
- 配置 MODER —— 将 PA5 设为输出模式(01)。
- 配置 OTYPER —— 推挽输出(默认 0)。
- 写 ODR 或 BSRR —— 输出高电平。
// 使能 GPIOA 时钟(RCC AHB1 外设时钟使能寄存器)
RCC->AHB1ENR |= (1 << 0);
// 配置 PA5 为输出模式
GPIOA->MODER &= ~(0x3 << (5 * 2)); // 先清零
GPIOA->MODER |= (0x1 << (5 * 2)); // 设为 01
// 输出高电平
GPIOA->BSRR = (1 << 5); // 置位
// GPIOA->BSRR = (1 << (5 + 16)); // 复位
嗯,这里要注意:MODER 每两位控制一个引脚。PA5 对应的是 bit[11:10]。很多人直接写 GPIOA->MODER = 0x1 << 10,结果把其他引脚的模式也改了。我建议养成先清零再置位的习惯。
闪烁就是加个延时循环:
while (1) {
GPIOA->BSRR = (1 << 5); // 亮
delay(500000);
GPIOA->BSRR = (1 << (5 + 16)); // 灭
delay(500000);
}
避坑: 延时函数不要用纯空循环,不同优化等级下延时时间会变。我在调试时遇到过——Debug 版闪得正常,Release 版闪得飞快。后来改用 SysTick 定时器做精确延时才解决。
4.3 按键中断实验——从轮询到中断
轮询按键太浪费 CPU 了。你想想看,CPU 一直在那里读 IDR,啥也干不了。中断才是正经做法。
按键中断的配置流程:
- 配置 GPIO 为输入模式 —— MODER 设为 00。
- 配置上下拉 —— 外部没上拉电阻时,用内部上拉。
- 配置 EXTI(外部中断/事件控制器) —— 选择中断线、触发边沿。
- 配置 NVIC —— 使能中断通道、设置优先级。
- 编写中断服务函数 —— 处理按键逻辑。
以 PA0 按键为例,下降沿触发:
// 1. GPIO 配置
GPIOA->MODER &= ~(0x3 << (0 * 2)); // 输入模式
GPIOA->PUPDR |= (0x1 << (0 * 2)); // 上拉
// 2. EXTI 配置
SYSCFG->EXTICR[0] &= ~0xF; // 选择 PA0 作为 EXTI0 输入
EXTI->IMR |= (1 << 0); // 不屏蔽中断
EXTI->FTSR |= (1 << 0); // 下降沿触发
// 3. NVIC 配置
NVIC->ISER[0] |= (1 << 6); // EXTI0 中断号 6
// 4. 中断服务函数
void EXTI0_IRQHandler(void) {
if (EXTI->PR & (1 << 0)) {
// 清除中断标志位
EXTI->PR = (1 << 0);
// 处理按键逻辑
GPIOA->BSRR = (1 << 5); // 点亮 LED
}
}
关键点: 中断服务函数里一定要清除中断标志位(写 PR 寄存器)。不清除的话,中断会一直触发,系统直接卡死。我曾经在客户现场遇到过这个问题——按键按一下,LED 狂闪,最后发现是忘了清标志位。
4.4 知识结构总览
下面这张图把 GPIO 驱动的核心流程串起来了。从寄存器映射到外设控制,再到中断响应,每一步都有对应的寄存器操作。
4.5 中断优先级与嵌套
ARMv7 的 NVIC 支持最多 16 级抢占优先级(4 位)。优先级数值越小,优先级越高。配置时要注意:
- 抢占优先级:高优先级可以打断低优先级的中断。
- 子优先级:同抢占优先级下,子优先级高的先执行。
- 中断屏蔽:在临界区可以用
__disable_irq()暂时关闭所有中断。
// 设置 EXTI0 中断优先级为 1(高)
NVIC->IP[6] = (1 << 4); // 高 4 位有效
// 使能中断
NVIC->ISER[0] |= (1 << 6);
经验之谈: 中断服务函数要短小精悍。我一般只在里面做标志位设置和简单操作,复杂的逻辑放到主循环里处理。否则中断嵌套一多,系统响应就乱了。
4.6 常见问题与调试方法
调试 GPIO 驱动时,我总结了一套排查流程:
- 检查时钟 —— GPIO 外设时钟没使能,写寄存器等于白写。
- 检查模式 —— 输出引脚配成了输入模式,电平死活拉不高。
- 检查上下拉 —— 按键输入没配上拉,引脚浮空,电平乱跳。
- 检查中断标志 —— 中断触发后没清标志,死循环。
- 检查 NVIC —— 中断使能位没置,或者优先级配错了。
曾经踩过的坑: 有一次调试按键中断,按下去没反应。查了半天,发现是 SYSCFG 的 EXTICR 没配置——EXTI0 默认连的是 PB0,不是 PA0。所以,别只看 GPIO 配置,EXTI 的输入选择也得确认。
GPIO 驱动看似简单,但它是所有外设驱动的基础。寄存器映射的功底、中断处理的规范、调试排查的思路,这些能力会贯穿你整个嵌入式开发生涯。把这一章吃透,后面的定时器、串口、I2C 驱动,你上手会快很多。