4、GPIO与按键驱动:从按下到唤醒的完整链路

好,咱们进入第四章。这一章我打算聊点实在的——GPIO和按键驱动。

你可能会想:「按键嘛,不就是读个电平高低?」嗯,表面上看确实如此。但实际项目中,一个按键能让你调试到怀疑人生。我当年做第一款智能手表时,就因为在按键消抖上偷了懒,结果用户一抬手,屏幕就乱跳。那叫一个尴尬。

今天咱们就把这块彻底讲透。从GPIO模式配置,到外部中断,再到两种消抖算法,最后撸一个按键唤醒功能。走起。

4.1 GPIO模式配置:别上来就瞎设

很多新手拿到开发板,第一件事就是把GPIO设成输入,然后开始读电平。其实这里有个坑——你选的模式决定了你的信号质量

以STM32为例,GPIO输入模式主要有这么几种:

模式 特点 适用场景
浮空输入 高阻态,电平不确定 外部有明确驱动源
上拉输入 内部接VCC,默认高电平 按键接地(常用)
下拉输入 内部接GND,默认低电平 按键接VCC
模拟输入 关闭数字功能 ADC采样

我个人习惯,按键驱动一律用上拉输入。为什么?因为按键另一端接地,按下时读到低电平,松开时靠内部上拉电阻拉高。省一个外部电阻,还省心。

关键配置代码(HAL库风格):

GPIO_InitTypeDef GPIO_InitStruct = {0};

__HAL_RCC_GPIOA_CLK_ENABLE();   // 别忘了开时钟

GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;   // 上拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

小提示:时钟使能是新手最容易忘的。我见过有人调了一下午,结果发现GPIO没反应——时钟没开。先查时钟,再查模式,这俩搞定,80%的问题就解决了。

4.2 外部中断实现:别让CPU一直傻等

按键检测有两种方式:轮询和中断。

轮询就是CPU一直问「你按了吗?你按了吗?」——这在低功耗设备上简直是灾难。智能手表电池就那么点,你让CPU一直跑,续航直接崩。

所以,咱们用外部中断。按键按下时,CPU从休眠中被唤醒,处理完再睡回去。这才是正经做法。

配置外部中断的步骤其实不复杂:

  1. 配置GPIO为中断模式(不是普通输入)
  2. 选择触发边沿(上升沿、下降沿、双边沿)
  3. 配置NVIC(嵌套向量中断控制器)优先级
  4. 编写中断服务函数

这里我重点说一下触发边沿的选择。按键按下时,电平从高变低,所以用下降沿触发最直接。但注意——如果你按下后手抖了一下,可能会触发多次。这就是消抖要解决的问题。

// 配置外部中断
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置NVIC
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

注意:中断服务函数里不要做耗时操作!我见过有人直接在中断里跑延时消抖,结果整个系统卡死。中断里只该做一件事——设置标志位。具体处理放到主循环或任务里。

4.3 按键消抖算法:硬件消抖 vs 软件消抖

按键按下和松开的瞬间,机械触点会弹跳。这个弹跳时间大概5-20ms。如果不处理,一次按下会被识别成好几次。

消抖有两种思路:硬件消抖和软件消抖。

4.3.1 硬件消抖

说白了就是加个RC滤波电路。电阻和电容组成低通滤波器,把毛刺滤掉。优点是CPU不用管,缺点是占PCB面积、增加BOM成本。

在智能手表这种寸土寸金的地方,我一般不推荐硬件消抖。除非你对功耗有极致要求,连CPU那几ms的消抖时间都不想浪费。

4.3.2 软件消抖

这才是主流做法。核心思想就一句话:检测到电平变化后,等一段时间再确认一次

最简单的实现:

// 简易消抖:检测到按下后延时10ms再读一次
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
    HAL_Delay(10);  // 延时10ms
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        // 确认按下,执行操作
    }
}

但说实话,HAL_Delay在中断里用会出问题。更好的做法是用状态机+定时器

typedef enum {
    KEY_IDLE,
    KEY_PRESS_DETECTED,
    KEY_PRESS_CONFIRMED,
    KEY_RELEASE_DETECTED
} KeyState;

KeyState keyState = KEY_IDLE;
uint32_t lastTick = 0;

void Key_Scan(void) {
    uint8_t level = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
    
    switch (keyState) {
        case KEY_IDLE:
            if (level == 0) {
                keyState = KEY_PRESS_DETECTED;
                lastTick = HAL_GetTick();
            }
            break;
            
        case KEY_PRESS_DETECTED:
            if (HAL_GetTick() - lastTick > 20) {  // 20ms消抖
                if (level == 0) {
                    keyState = KEY_PRESS_CONFIRMED;
                    // 触发按键事件
                } else {
                    keyState = KEY_IDLE;  // 抖动,复位
                }
            }
            break;
            
        case KEY_PRESS_CONFIRMED:
            if (level == 1) {
                keyState = KEY_RELEASE_DETECTED;
                lastTick = HAL_GetTick();
            }
            break;
            
        case KEY_RELEASE_DETECTED:
            if (HAL_GetTick() - lastTick > 20) {
                if (level == 1) {
                    keyState = KEY_IDLE;  // 完全释放
                } else {
                    keyState = KEY_PRESS_CONFIRMED;  // 抖动,回到按下状态
                }
            }
            break;
    }
}

核心思想:状态机消抖不阻塞CPU,适合在低功耗系统中使用。你想想看,如果每次消抖都用delay,那CPU在这10ms里啥也干不了,多浪费。

4.4 实战:实现一个按键唤醒功能

好,终于到实战环节了。咱们要实现的功能是:手表休眠时,按一下按键就唤醒屏幕。

整体思路:

  1. 系统进入休眠模式(Stop模式或Standby模式)
  2. 配置按键GPIO为外部中断唤醒源
  3. 按键按下产生中断,CPU从休眠中醒来
  4. 在中断中设置唤醒标志,主循环检测到标志后点亮屏幕

代码实现:

// 进入休眠
void System_Enter_Sleep(void) {
    // 关闭不需要的外设时钟
    __HAL_RCC_GPIOB_CLK_DISABLE();
    __HAL_RCC_GPIOC_CLK_DISABLE();
    
    // 配置唤醒引脚(PA0)
    HAL_GPIO_DeInit(GPIOA, GPIO_PIN_0);
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 使能唤醒中断
    HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
    
    // 进入Stop模式
    HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
    
    // 醒来后重新配置系统时钟
    SystemClock_Config();
}

// 中断服务函数
void EXTI0_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
        wakeup_flag = 1;  // 设置唤醒标志
    }
}

// 主循环
int main(void) {
    HAL_Init();
    SystemClock_Config();
    
    while (1) {
        if (wakeup_flag) {
            wakeup_flag = 0;
            Display_WakeUp();  // 点亮屏幕
        }
        
        // 如果一段时间无操作,进入休眠
        if (no_operation_timeout > 5000) {
            System_Enter_Sleep();
        }
        
        HAL_Delay(10);
    }
}

避坑指南:我曾经在唤醒后忘了重新配置系统时钟,结果屏幕刷得巨慢,像幻灯片一样。记住——从Stop模式醒来后,HSE(外部高速晶振)是关闭的,必须手动重新初始化时钟。

另外,关于功耗,我多说一句。进入休眠前,记得把不用的GPIO也处理一下。浮空的GPIO会漏电,虽然单个引脚只有几微安,但几十个引脚加起来,对电池续航的影响就很明显了。

好了,这一章的内容就到这里。按键驱动看似简单,但做好了能省很多事。下一章咱们聊定时器,那个更有意思——你可以用定时器做PWM调光、做呼吸灯、做精确延时。到时候见。