第3章 GPIO驱动开发:从寄存器到中断

GPIO驱动,说白了就是让芯片的引脚听话。你想让它输出高电平就输出高电平,想让它检测按键就检测按键。这章我带你从寄存器映射开始,一步步把GPIO驱动写完整。

本章核心脉络:寄存器映射 → 结构体封装 → 输出驱动(LED) → 输入驱动(按键) → 中断式按键

GPIO驱动开发知识体系 寄存器映射 结构体封装 GPIO输出(LED) GPIO输入(按键) 中断式按键驱动 💡 从底层寄存器到上层中断,每一层都依赖前一层的基础。结构体封装是承上启下的关键。

3.1 GPIO模块寄存器映射

每个GPIO模块都有一组寄存器。它们控制引脚的模式、输出值、输入值、上下拉等。我刚开始做BSP时,第一件事就是翻开芯片手册,找到GPIO寄存器的基地址。

以STM32F4为例,GPIOA的基地址是0x40020000。每个GPIO端口有10个32位寄存器。它们的偏移量是固定的:

偏移量 寄存器名称 功能描述
0x00GPIOx_MODER模式寄存器(输入/输出/复用/模拟)
0x04GPIOx_OTYPER输出类型寄存器(推挽/开漏)
0x08GPIOx_OSPEEDR输出速度寄存器
0x0CGPIOx_PUPDR上下拉寄存器
0x10GPIOx_IDR输入数据寄存器
0x14GPIOx_ODR输出数据寄存器
0x18GPIOx_BSRR位设置/复位寄存器
0x1CGPIOx_LCKR配置锁定寄存器
0x20GPIOx_AFRL复用功能低寄存器
0x24GPIOx_AFRH复用功能高寄存器

我的经验:别死记偏移量。把手册的寄存器映射表截图存下来,开发时随时对照。我踩过最大的坑就是记错了BSRR的偏移量,结果点灯变成了灭灯。

3.2 使用结构体封装寄存器

裸机开发里,最优雅的方式就是用结构体把寄存器组映射到基地址。C语言的结构体内存布局和寄存器偏移量天然对齐——前提是你得注意编译器不要加填充。

// GPIO寄存器结构体封装
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 AFRL;     // 0x20
    volatile uint32_t AFRH;     // 0x24
} GPIO_TypeDef;

// 映射到外设基地址
#define GPIOA_BASE      0x40020000
#define GPIOB_BASE      0x40020400
#define GPIOC_BASE      0x40020800

#define GPIOA           ((GPIO_TypeDef *) GPIOA_BASE)
#define GPIOB           ((GPIO_TypeDef *) GPIOB_BASE)
#define GPIOC           ((GPIO_TypeDef *) GPIOC_BASE)

为什么要加volatile?因为寄存器的值可能被硬件修改。编译器如果优化掉读操作,你的代码就废了。我见过有人忘了加volatile,调试了一整天。

注意:结构体封装依赖编译器不插入填充字节。GCC/ARMCC默认不会在32位字段间加填充,但如果你用了__packed属性会更安全。跨平台时一定要检查sizeof(GPIO_TypeDef)是否等于0x28。

3.3 GPIO输出驱动——点亮LED

点灯是嵌入式界的"Hello World"。但别小看它,这里面藏着GPIO配置的完整流程。

驱动LED的步骤其实就四步:

  1. 使能GPIO时钟——寄存器不使能时钟,写什么都没用
  2. 配置模式为输出——MODER寄存器对应位设为01
  3. 配置输出类型——推挽还是开漏?LED一般用推挽
  4. 写输出值——ODR或BSRR寄存器
// 点亮PA0上的LED
// 1. 使能GPIOA时钟(RCC_AHB1ENR的bit0)
RCC->AHB1ENR |= (1 << 0);

// 2. 配置PA0为输出模式(MODER[1:0] = 01)
GPIOA->MODER &= ~(0x3 << (0 * 2));
GPIOA->MODER |=  (0x1 << (0 * 2));

// 3. 配置为推挽输出(OTYPER bit0 = 0)
GPIOA->OTYPER &= ~(1 << 0);

// 4. 输出高电平点亮LED
GPIOA->BSRR = (1 << 0);   // 用BSRR比ODR更安全,不会干扰其他位

我个人习惯用BSRR操作输出。为什么?BSRR是"写1有效",写0不影响。你用ODR的话,读-改-写中间可能被中断打断,造成其他引脚误动作。

避坑指南:我曾经在点灯时忘了使能时钟,结果寄存器写进去的值根本没用。调试器看寄存器全是0x00000000。从那以后,我每次配置外设前第一件事就是检查时钟是否使能。

3.4 GPIO输入驱动——按键扫描

按键检测比LED输出多了一个麻烦:抖动。你按下去那几十毫秒,电平会跳来跳去。不做消抖的话,一次按键可能触发十次动作。

按键扫描的基本流程:

  1. 配置GPIO为输入模式(MODER = 00)
  2. 配置上下拉(PUPDR),一般用上拉,按键接地
  3. 读取IDR寄存器获取电平
  4. 软件消抖——延时10~20ms再读一次
// 按键初始化(PA1,上拉输入)
void key_init(void) {
    RCC->AHB1ENR |= (1 << 0);           // 使能GPIOA时钟
    GPIOA->MODER &= ~(0x3 << (1 * 2)); // 输入模式
    GPIOA->PUPDR |=  (0x1 << (1 * 2)); // 上拉
}

// 按键扫描(返回1表示按下,0表示未按下)
uint8_t key_scan(void) {
    if ((GPIOA->IDR & (1 << 1)) == 0) { // 检测到低电平
        delay_ms(10);                     // 消抖延时
        if ((GPIOA->IDR & (1 << 1)) == 0) {
            while ((GPIOA->IDR & (1 << 1)) == 0); // 等待释放
            return 1;
        }
    }
    return 0;
}

你想想看,这个while等待释放的写法有个大问题——它会卡死CPU。如果按键一直按着,CPU就一直在那转圈。实际产品里很少这么干,一般用状态机或者中断来处理。

3.5 中断式按键驱动

轮询扫描太浪费CPU了。中断式按键才是正经做法。按键按下时触发中断,CPU去处理,平时该干嘛干嘛。

配置中断式按键的步骤:

  1. GPIO配置为输入模式(同上)
  2. 配置外部中断线(EXTI)——选择引脚和触发边沿
  3. 配置NVIC——使能中断通道,设置优先级
  4. 编写中断服务函数——读IDR确认电平,软件消抖,置标志位
// 配置PA1为中断输入(下降沿触发)
void key_interrupt_init(void) {
    // 1. GPIO配置(上拉输入)
    RCC->AHB1ENR |= (1 << 0);
    GPIOA->MODER &= ~(0x3 << (1 * 2));
    GPIOA->PUPDR |=  (0x1 << (1 * 2));
    
    // 2. 将PA1连接到EXTI1
    SYSCFG->EXTICR[0] &= ~(0xF << (1 * 4));
    SYSCFG->EXTICR[0] |=  (0x0 << (1 * 4)); // PA组
    
    // 3. 配置EXTI1:下降沿触发,中断使能
    EXTI->FTSR |= (1 << 1);    // 下降沿触发
    EXTI->IMR  |= (1 << 1);    // 中断屏蔽使能
    
    // 4. 配置NVIC
    NVIC_SetPriority(EXTI1_IRQn, 2);
    NVIC_EnableIRQ(EXTI1_IRQn);
}

// 中断服务函数
void EXTI1_IRQHandler(void) {
    if (EXTI->PR & (1 << 1)) {  // 检查中断标志
        // 软件消抖:延时10ms再读一次
        delay_ms(10);
        if ((GPIOA->IDR & (1 << 1)) == 0) {
            key_press_flag = 1;  // 置位按键标志
        }
        EXTI->PR = (1 << 1);    // 清除中断标志
    }
}

重要提醒:中断服务函数里不要做耗时操作。消抖延时10ms已经算长了,但还能接受。千万别在中断里做printf、延时100ms这种事——你会把整个系统卡死的。

中断式驱动有个经典问题:中断标志位必须在最后清除。如果你先清标志再处理,处理过程中又来了新的中断,标志位可能被错误清除。我踩过这个坑,调试了整整一个下午。

3.6 三种驱动方式的对比

我把三种方式放在一起对比,你根据实际场景选:

驱动方式 CPU占用率 实时性 代码复杂度 适用场景
轮询输出极低高低LED指示、状态显示
轮询输入高(一直查)中低简单按键、调试用
中断输入极低(事件触发)高中产品级按键、唤醒

说白了,产品里按键驱动几乎都用中断。轮询只适合做原型验证。我见过有人把轮询按键直接用在量产产品里,结果电池两天就没电了——CPU一直在跑循环,根本没法休眠。

我的建议:中断服务函数里只做两件事——清标志、置位一个全局标志位。真正的按键处理逻辑放在主循环里判断这个标志位。这样既保证了实时性,又不会阻塞中断系统。

嗯,GPIO驱动这块就这些。从寄存器映射到结构体封装,从点灯到中断按键,每一步都是BSP开发的基本功。你把这些代码跑通了,后面学I2C、SPI这些复杂外设就会顺手很多。