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 上,高电平点亮。步骤很简单:

  1. 使能 GPIOA 时钟 —— 在 RCC 寄存器里打开对应位。
  2. 配置 MODER —— 将 PA5 设为输出模式(01)。
  3. 配置 OTYPER —— 推挽输出(默认 0)。
  4. 写 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,啥也干不了。中断才是正经做法。

按键中断的配置流程:

  1. 配置 GPIO 为输入模式 —— MODER 设为 00。
  2. 配置上下拉 —— 外部没上拉电阻时,用内部上拉。
  3. 配置 EXTI(外部中断/事件控制器) —— 选择中断线、触发边沿。
  4. 配置 NVIC —— 使能中断通道、设置优先级。
  5. 编写中断服务函数 —— 处理按键逻辑。

以 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 驱动的核心流程串起来了。从寄存器映射到外设控制,再到中断响应,每一步都有对应的寄存器操作。

GPIO 驱动知识结构图 寄存器映射 时钟使能 模式配置 输出模式 输入模式 复用功能 写 ODR/BSRR 读 IDR 配置 AFR EXTI 配置 NVIC 使能 中断服务函数 LED 点亮 / 按键响应 从底层寄存器到上层应用 逐层构建 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 驱动时,我总结了一套排查流程:

  1. 检查时钟 —— GPIO 外设时钟没使能,写寄存器等于白写。
  2. 检查模式 —— 输出引脚配成了输入模式,电平死活拉不高。
  3. 检查上下拉 —— 按键输入没配上拉,引脚浮空,电平乱跳。
  4. 检查中断标志 —— 中断触发后没清标志,死循环。
  5. 检查 NVIC —— 中断使能位没置,或者优先级配错了。

曾经踩过的坑: 有一次调试按键中断,按下去没反应。查了半天,发现是 SYSCFG 的 EXTICR 没配置——EXTI0 默认连的是 PB0,不是 PA0。所以,别只看 GPIO 配置,EXTI 的输入选择也得确认。

GPIO 驱动看似简单,但它是所有外设驱动的基础。寄存器映射的功底、中断处理的规范、调试排查的思路,这些能力会贯穿你整个嵌入式开发生涯。把这一章吃透,后面的定时器、串口、I2C 驱动,你上手会快很多。