GPIO 基础:数字输入输出,LED 闪烁与按键检测实战

各位同学,欢迎来到第二章。上一章我们搭好了开发环境,今天终于可以动手了。

GPIO,全称 General Purpose Input Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的引脚。你可以让它们输出高电平或低电平,也可以读取外部信号是高还是低。

我个人习惯把 GPIO 比作芯片的「手脚」——输出就是「动手」,输入就是「感知」。今天我们就让 ESP32 动动手、感知一下世界。

本章核心目标:
  • 理解 GPIO 的工作模式(输入 vs 输出)
  • 掌握 ESP32 的 GPIO 配置与操作 API
  • 完成 LED 闪烁实验(输出)
  • 完成按键检测实验(输入 + 消抖)

2.1 GPIO 的基本概念

先看一张图,把 GPIO 的内部结构理清楚。

ESP32 GPIO 内部结构示意图 ESP32 芯片内部 输出寄存器 推挽/开漏驱动 输入寄存器 施密特触发器 上拉电阻 下拉电阻 外部电路 LED + 限流电阻 按键 + 上拉电阻 GPIO 引脚 既是输入也是输出

从图上可以看到,一个 GPIO 引脚内部同时连接着输出通路和输入通路。你写寄存器,它就输出;你读寄存器,它就输入。但同一时刻只能选一种模式。

小提示:ESP32 的大部分 GPIO 引脚在复位后默认是输入模式,且内部上拉电阻默认关闭。这一点和很多单片机不同,写代码时要注意初始化。

2.2 硬件准备

做实验前,先把东西准备好。我列个清单:

元件 数量 说明
ESP32 开发板 1 块 推荐 ESP32-DevKitC 或 NodeMCU-32S
LED(任意颜色) 1 个 工作电压 2V,电流 10-20mA
电阻 220Ω 1 个 LED 限流用
轻触按键 1 个 四脚或两脚均可
电阻 10kΩ 1 个 按键上拉用(也可用内部上拉)
杜邦线 若干 公对母、公对公
面包板 1 块 方便搭电路

接线方式很简单:

  • LED 实验:GPIO2 → 220Ω 电阻 → LED 阳极 → LED 阴极 → GND
  • 按键实验:GPIO0 → 按键一端 → 按键另一端 → GND;GPIO0 同时接 10kΩ 电阻到 3.3V
注意:LED 必须串联限流电阻,否则电流过大会烧坏 LED 甚至损坏 GPIO 引脚。ESP32 的 GPIO 最大输出电流约 40mA,别超了。

2.3 LED 闪烁:GPIO 输出实战

先来个经典的「Hello World」——让 LED 闪烁。代码不长,但每个函数都有讲究。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define LED_GPIO   GPIO_NUM_2

void app_main(void)
{
    // 1. 配置 GPIO 为输出模式
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << LED_GPIO),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE
    };
    gpio_config(&io_conf);

    // 2. 主循环:闪烁
    while (1) {
        gpio_set_level(LED_GPIO, 1);   // 点亮
        vTaskDelay(500 / portTICK_PERIOD_MS);
        gpio_set_level(LED_GPIO, 0);   // 熄灭
        vTaskDelay(500 / portTICK_PERIOD_MS);
    }
}

这里我重点讲几个地方:

  • gpio_config_t 结构体:ESP-IDF 统一用这个结构体配置 GPIO,比逐个寄存器操作清晰得多。
  • pin_bit_mask:用位掩码指定要配置的引脚。1ULL << LED_GPIO 表示第 2 位为 1。
  • vTaskDelay:FreeRTOS 的延时函数,单位是 tick。portTICK_PERIOD_MS 把毫秒转成 tick。
经验之谈:我刚开始用 ESP-IDF 时,总忘记配置 gpio_config,结果 LED 死活不亮。后来养成习惯,每次用 GPIO 前先检查配置结构体。另外,延时别用 for 循环空转,那会浪费 CPU。

2.4 按键检测:GPIO 输入实战

输出搞定了,接下来做输入——检测按键是否按下。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define BUTTON_GPIO  GPIO_NUM_0

void app_main(void)
{
    // 1. 配置 GPIO 为输入模式,启用内部上拉
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << BUTTON_GPIO),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,   // 内部上拉
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE
    };
    gpio_config(&io_conf);

    int last_state = 1;  // 上一次按键状态,默认高电平

    while (1) {
        int current_state = gpio_get_level(BUTTON_GPIO);

        // 检测下降沿:从高电平变为低电平(按键按下)
        if (last_state == 1 && current_state == 0) {
            printf("按键按下!\n");
        }

        last_state = current_state;
        vTaskDelay(10 / portTICK_PERIOD_MS);  // 10ms 采样一次
    }
}

这里有个关键点:按键按下时 GPIO 读到低电平,松开时读到高电平。为什么?因为外部接了上拉电阻到 3.3V,按键按下把引脚拉到 GND。

按键检测的两种方式:
  • 轮询方式:主循环不断读取 GPIO 电平,适合简单场景。
  • 中断方式:配置 GPIO 中断,按键触发时自动调用回调函数,效率更高。

2.5 按键消抖:避坑指南

你可能会发现,上面的代码有时会误判——按一次却打印好几条「按键按下」。这就是抖动问题。

机械按键在按下和松开的瞬间,触点会弹跳几次,产生多个脉冲。如果不处理,一次按键会被当成多次。

我曾经在一个项目里吃过这个亏。当时做智能家居面板,按键控制灯光。测试时发现按一次灯闪了好几下,排查了半天才发现是没做消抖。后来老老实实加了消抖逻辑。

消抖方法有两种:

方法 原理 优点 缺点
软件延时消抖 检测到电平变化后,延时 20-50ms 再读一次 实现简单,无需额外硬件 阻塞延时浪费 CPU
硬件消抖 在按键两端并联电容(0.1μF) 不占用 CPU,响应快 增加元件成本
状态机消抖 连续多次采样,状态稳定后才确认 非阻塞,可靠 代码稍复杂

我个人推荐状态机消抖,兼顾可靠性和效率。下面给个示例:

#define DEBOUNCE_TIME_MS  30   // 消抖时间 30ms
#define SAMPLE_INTERVAL_MS 5   // 采样间隔 5ms

void app_main(void)
{
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << BUTTON_GPIO),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE
    };
    gpio_config(&io_conf);

    int stable_state = 1;       // 稳定后的状态
    int temp_state = 1;         // 临时状态
    int count = 0;              // 连续采样计数
    int required = DEBOUNCE_TIME_MS / SAMPLE_INTERVAL_MS;  // 需要连续采样次数

    while (1) {
        int level = gpio_get_level(BUTTON_GPIO);

        if (level == temp_state) {
            count++;
            if (count >= required) {
                // 状态稳定,更新
                if (stable_state != temp_state) {
                    stable_state = temp_state;
                    if (stable_state == 0) {
                        printf("按键按下(已消抖)\n");
                    }
                }
                count = required;  // 防止溢出
            }
        } else {
            // 电平变化,重置计数
            temp_state = level;
            count = 0;
        }

        vTaskDelay(SAMPLE_INTERVAL_MS / portTICK_PERIOD_MS);
    }
}
注意:消抖时间不是越长越好。太短消不掉抖动,太长会感觉按键「迟钝」。一般 20-50ms 比较合适。我习惯用 30ms,兼顾手感与可靠性。

2.6 常见问题与排查

做实验时可能会遇到各种问题,我把常见的列出来:

  • LED 不亮:检查接线极性(LED 长脚接正极),检查 GPIO 号是否正确,检查代码是否调用了 gpio_config。
  • LED 亮度异常:限流电阻阻值不对。220Ω 对应约 10mA,如果太暗可以换成 100Ω,但别低于 50Ω。
  • 按键检测不到:确认上拉电阻接好,或者启用内部上拉。用万用表量一下引脚电压。
  • 按键误触发:没做消抖,或者消抖时间太短。
  • 程序下载后没反应:检查串口监视器波特率是否设为 115200,检查是否按了开发板上的 EN 复位键。
调试技巧:我习惯在关键位置加 printf 打印调试信息。比如每次检测到按键变化时打印电平值,这样能直观看到抖动波形。另外,示波器是排查 GPIO 问题的神器,有条件的话建议用上。

2.7 本章小结

今天我们完成了两件事:让 LED 闪烁(输出),检测按键(输入)。看起来简单,但 GPIO 是所有外设驱动的基础。后面讲 I2C、SPI、UART,本质上都是在操作 GPIO,只是加了协议层。

嗯,这里要提醒一句:别觉得简单就跳过动手环节。我见过太多人看完教程觉得懂了,真到写代码时连 gpio_config 的参数都记不全。一定要自己敲一遍代码,跑通实验。