3、GPIO基础与按键输入:GPIO模式详解、按键消抖原理、轮询方式读取按键、控制LED亮灭

好,咱们今天来聊聊GPIO。这玩意儿可以说是嵌入式开发里最基础、也最常用的外设了。说白了,它就是芯片和外界打交道的一根根“手脚”。你让它输出高电平,它就输出3.3V;你让它读取电平,它就知道外面是0V还是3.3V。

我在项目里见过不少新手,一上来就对着寄存器猛写,结果LED死活不亮,按键怎么按都没反应。其实啊,很多时候不是代码写错了,而是GPIO的模式没配对。咱们今天就把这事儿彻底讲明白。

3.1 GPIO模式详解

GPIO的模式,比你想象的要复杂一些。不是简单的“输入”和“输出”两个选项。我习惯把它分成两大类:输出模式输入模式。每种模式下还有细分。

3.1.1 输出模式

输出模式,就是让芯片去控制外部设备。比如点亮一个LED,驱动一个蜂鸣器。这里有两个关键参数:推挽输出开漏输出

  • 推挽输出(Push-Pull):这是最常用的模式。芯片内部有两个管子,一个负责“推”(输出高电平),一个负责“拉”(输出低电平)。输出高电平时,电流从芯片流出;输出低电平时,电流流入芯片。驱动能力强,适合直接驱动LED。
  • 开漏输出(Open-Drain):这个模式只负责“拉”(输出低电平),不负责“推”。要输出高电平,必须外部接一个上拉电阻到VCC。我一般在I2C总线或者需要电平转换的场合用这个模式。你想想看,如果多个设备共用一根线,开漏输出可以避免短路。

重要提醒: 输出模式还有一个“速度”参数,比如2MHz、10MHz、50MHz。这个速度不是输出频率,而是输出电平跳变时的斜率。对于普通LED,2MHz就够了。如果配得太高,反而会引起信号过冲和EMI问题。我曾经在一个项目中,把GPIO速度配到了50MHz去驱动一个继电器,结果继电器没坏,倒是把旁边的传感器给干扰了。

3.1.2 输入模式

输入模式,就是让芯片去读取外部信号。比如检测按键是否按下。这里也有几个选项:

  • 浮空输入(Floating):引脚既不接上拉也不接下拉。外部信号是什么电平,就读到什么电平。但引脚悬空时,电平不确定,容易受干扰。我基本不用这个模式。
  • 上拉输入(Pull-Up):内部接了一个几十千欧的电阻到VCC。默认读到的就是高电平。按键按下时,引脚接地,读到低电平。这个模式最常用。
  • 下拉输入(Pull-Down):内部接了一个电阻到GND。默认读到的就是低电平。按键按下时,引脚接VCC,读到高电平。用得少一些。

我的习惯: 按键输入,我一般用上拉输入。这样按键没按下时,电平是稳定的高电平。按下后,电平被拉低。逻辑上比较直观。而且内部上拉电阻已经够用,不需要额外加外部电阻。

3.2 按键消抖原理

按键消抖,是每个嵌入式工程师都会遇到的“坑”。为什么需要消抖?

机械按键在按下和释放的瞬间,触点会弹跳。这个弹跳时间大概有5-20毫秒。在这段时间里,电平会反复跳变。如果你直接去读,可能会读到多次按下。我刚开始做项目时,就因为这个原因,一个按键按一次,LED闪了三次。嗯,当时我还以为是芯片坏了。

消抖的方法有两种:硬件消抖软件消抖

  • 硬件消抖:在按键两端并联一个电容(通常0.1uF),利用电容的充放电来平滑电平跳变。简单粗暴,但会增加BOM成本。
  • 软件消抖:检测到电平变化后,延时一段时间(比如20ms),再读取一次。如果电平稳定,就认为是有效按下。这是最常用的方法,不增加硬件成本。

注意: 软件消抖的延时时间不是固定的。我建议根据实际按键的弹跳特性来调整。有些劣质按键,弹跳时间可能长达50ms。我曾经在一个工业项目中,用了30ms的消抖时间,结果按键偶尔还是误触发。后来换成了50ms,问题就解决了。

3.3 轮询方式读取按键

轮询,就是CPU不停地去检查按键的状态。虽然效率不高,但对于简单的按键控制来说,完全够用。

下面是一个典型的轮询读取按键的代码示例。我习惯用状态机的方式来处理,这样逻辑更清晰。

// 伪代码示例:轮询读取按键并控制LED

#define KEY_PIN   GPIO_PIN_0   // 按键接在GPIOA的Pin0
#define LED_PIN   GPIO_PIN_1   // LED接在GPIOA的Pin1

uint8_t key_state = 0;  // 0: 未按下, 1: 按下

void GPIO_Init(void) {
    // 配置按键引脚为上拉输入
    GPIOA->MODER &= ~(3 << (0 * 2));  // 输入模式
    GPIOA->PUPDR |= (1 << (0 * 2));   // 上拉

    // 配置LED引脚为推挽输出
    GPIOA->MODER |= (1 << (1 * 2));   // 输出模式
    GPIOA->OTYPER &= ~(1 << 1);      // 推挽
    GPIOA->OSPEEDR |= (1 << (1 * 2)); // 低速
}

void Delay_ms(uint32_t ms) {
    // 简单的延时函数,实际项目中建议用定时器
    for (uint32_t i = 0; i < ms * 1000; i++) {
        __NOP();
    }
}

uint8_t Read_Key(void) {
    // 读取按键状态
    if ((GPIOA->IDR & (1 << 0)) == 0) {  // 按键按下(低电平)
        Delay_ms(20);  // 消抖延时
        if ((GPIOA->IDR & (1 << 0)) == 0) {  // 再次确认
            return 1;  // 有效按下
        }
    }
    return 0;  // 未按下
}

int main(void) {
    GPIO_Init();

    while (1) {
        if (Read_Key()) {
            // 按键按下,翻转LED
            GPIOA->ODR ^= (1 << 1);
            // 等待按键释放,防止一次按下多次触发
            while ((GPIOA->IDR & (1 << 0)) == 0);
        }
    }
}

小技巧: 上面的代码中,我加了一个“等待按键释放”的循环。这样每次按下只触发一次动作。如果不加这个循环,你按一次按键,LED可能会连续翻转多次。因为CPU跑得很快,在消抖延时之后,按键可能还没释放,Read_Key()会一直返回1。

3.4 控制LED亮灭

控制LED亮灭,其实就是控制GPIO输出高电平或低电平。对于大多数单片机,直接操作ODR寄存器(输出数据寄存器)就可以了。

但这里有一个细节:不要用读-改-写的方式操作ODR。比如:

// 不推荐的做法
GPIOA->ODR |= (1 << 1);  // 置位
GPIOA->ODR &= ~(1 << 1); // 清零

为什么?因为ODR寄存器是可读可写的。如果你在读和写之间,发生了中断,中断里也修改了ODR,那么你的修改可能会被覆盖。这就是所谓的“读-改-写”问题。

我建议使用BSRR寄存器(位设置/复位寄存器)。这个寄存器是只写的,写1到低16位,对应的引脚输出高电平;写1到高16位,对应的引脚输出低电平。不会出现读-改-写的问题。

// 推荐的做法
GPIOA->BSRR = (1 << 1);       // 输出高电平,点亮LED
GPIOA->BSRR = (1 << (1 + 16)); // 输出低电平,熄灭LED

总结一下: 控制LED亮灭,看似简单,但背后涉及GPIO模式配置、消抖处理、寄存器操作等多个知识点。我在项目中见过太多因为GPIO模式配错、消抖没做好而导致的问题。希望你能把这些基础打牢,后面学中断、定时器、PWM的时候,才会更顺畅。

好了,这一章的内容就到这里。下一章咱们会讲中断,那才是真正的高效处理方式。轮询嘛,说白了就是“傻等”,中断才是“主动通知”。到时候你就知道,为什么按键用中断更合适了。