← 返回教程库

读按键与去抖:硬件的第一个"输入"

最后更新 2026-06-22
L2 · 传感与交互 ⏱ 约 16 分钟 🟢 软件/低风险
你将学到
  • 接一个按键并用 gpio_config 稳定读出它的按下状态
  • 用 gpio_config 开内部上拉,搞懂"按下为何读到的是低电平"
  • 理解抖动从哪来,并用三种方式把它干掉
  • 写出非阻塞去抖(esp_timer 时间戳),并能识别长按

点灯是输出,按键是输入——这一节让硬件第一次"听你的"。看着简单,你接好线、写两行代码读它,多半会撞上同一个坑:明明手指就按了一下,日志却"按下了"刷出来一串,做计数器时一下加好几。这不是你手抖,是按键真的"抖"了。这一节我们把按键从接线、读取,到把抖动彻底治服,一路讲透。

读这篇前,你得能看懂 app_mainwhile(1)gpio_get_level 这些基本结构。还没把握?回头补一下读懂示例代码


第一步:先接一个按键,把它读出来

按键的接法有好几种,最省事、也最该先学会的,是一个按键 + 内部上拉:按键一端接 GPIO,另一端接 GND,代码里启用芯片内部的上拉电阻。这样连一个外部电阻都不用焊。

GPIO0 ──┐
        │
       [按键]
        │
GND ────┘

把下面这段放进工程的 main/main.cidf.py build flash monitor 跑起来:

// 读一个按键的状态,按下时往日志打印
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"

#define BTN GPIO_NUM_0                 // 按键接在 GPIO0 和 GND 之间

static const char *TAG = "button";

void app_main(void)
{
    gpio_config_t cfg = {
        .pin_bit_mask = 1ULL << BTN,           // 选中 GPIO0 这一根
        .mode         = GPIO_MODE_INPUT,       // 设为输入
        .pull_up_en   = GPIO_PULLUP_ENABLE,    // 关键:打开内部上拉
        .pull_down_en = GPIO_PULLDOWN_DISABLE, // 不要下拉
        .intr_type    = GPIO_INTR_DISABLE,     // 这节先不开中断
    };
    gpio_config(&cfg);

    while (1) {
        if (gpio_get_level(BTN) == 0) {        // 上拉下,读到 0(低电平) = 按下
            ESP_LOGI(TAG, "按下了");
        }
        vTaskDelay(pdMS_TO_TICKS(100));        // 先粗暴地隔 100ms 看一次
    }
}

你应该看到什么

monitor 起来后(默认波特率 115200),盯着串口日志,然后:

  • 不按按键时,日志安静一片,不打印 button 这行。
  • 按住按键不放,日志会连续刷出一行行 I (xxxx) button: 按下了——因为 while 每 100ms 跑一次,只要还按着就一直满足条件。
  • 松手,刷新立刻停下。

能让按键的状态反映到日志上,你就完成了硬件的第一次"输入"。但这段代码还很粗糙——vTaskDelay(100) 让它反应迟钝,松手判断也没有。先别急,我们一层层加固。

💡 提示

gpio_config 是 ESP-IDF 配置引脚的标准入口。它一次把"方向、上拉/下拉、中断类型"都说清楚,比一根脚一个函数调要省事。pin_bit_mask 是按位掩码——想一次配多根脚,就 (1ULL<<A)|(1ULL<<B) 或上去。


第二步:内部上拉到底干了什么

这一步是这节课的"为什么",看懂了,后面按键的各种接法你都能自己推。新手最容易懵的就是这件事:为什么"按下"读到的反而是低电平(0)?

悬空的引脚是"飘"的

一个 GPIO 设成输入后,如果什么都不接,它的电平是不确定的——一会儿被读成高、一会儿被读成低,跟着周围的电磁干扰乱飘。这叫"浮空(floating)"。你拿这种引脚读按键,结果就是一团乱。所以读按键的第一要务,是给引脚一个确定的默认电平

上拉:默认拉到高,按下才拉低

.pull_up_en = GPIO_PULLUP_ENABLE 在芯片内部接了一个电阻,把这根引脚默认拉到高电平(约 3.3V)。于是:

  • 没按时:引脚被内部上拉电阻拽住,稳稳地是高电平gpio_get_level 返回 1
  • 按下时:按键把引脚和 GND 接通,引脚被直接拉到 0Vgpio_get_level 返回 0

所以"按下 = 0"不是反直觉,而是上拉接法的必然结果:平时高,按下接地变低。想把上拉、下拉这套电平默认值的原理搞扎实,看上拉下拉电阻;想顺带复习 GPIO 作为输入/输出引脚的本质,看 GPIO 是什么

📌 说明

反过来也行:用 .pull_down_en = GPIO_PULLDOWN_ENABLE(默认拉低),按键另一端接 3.3V,那就变成"按下 = 1"。两种都对,挑一种用顺手就行。本节统一用内部上拉,因为它接 GND 最省事。提醒一句:ESP32-S3 的纯输入脚(GPIO46 等)和部分 strapping 脚没有内部上下拉,遇到这类脚就老老实实焊一个外部电阻。


第三步:那个坑——抖动是什么

现在把上面的代码改成"做一个按键计数器":按一下,计数加一。

#define BTN GPIO_NUM_0
static const char *TAG = "button";

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

    int count = 0;
    while (1) {
        if (gpio_get_level(BTN) == 0) {
            count++;
            ESP_LOGI(TAG, "count = %d", count);
            vTaskDelay(pdMS_TO_TICKS(200));
        }
    }
}

按一下,你期待 count 加 1。实际跑起来,你会看到 count 经常一下跳了 3、5 甚至更多。问题不在代码逻辑,在按键这个机械零件本身。

机械触点会"弹跳"

按键内部是两片金属触点。你按下去的那一瞬间,两片金属并不是"啪"一下就严丝合缝贴牢的——它们会像扔到地上的乒乓球一样,接触、弹开、再接触、再弹开,在几毫秒内来回弹好几次才真正稳定下来。松手时同理。

单片机扫描引脚的速度极快(gpio_get_level 一秒能读几十万次),快到足以把这"接触—弹开—接触"的每一次都当成一次独立的"按下"读进去。于是你手感上的"一下",在芯片眼里成了一串高低快速跳变的脉冲——这就是抖动(bounce)。计数乱跳、菜单一按就连跳两项、长按当成连点,根子都在这。

🚧 避坑

抖动是机械按键的物理特性,不是个别坏按键的毛病,每一个机械按键都抖。所以"读按键"这件事,本质上就是"读按键 + 去抖",两者不可分。任何认真处理输入的程序都必须去抖,别指望硬件帮你解决。


第四步:去抖法一——最朴素的延时确认

最直观的思路:检测到引脚变低后,先等一小会儿(让弹跳期过去),再确认一次。如果等完还是低,那才是真的按下。

#define BTN GPIO_NUM_0
static const char *TAG = "button";

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

    while (1) {
        if (gpio_get_level(BTN) == 0) {              // ① 第一次读到低,可能是抖动
            vTaskDelay(pdMS_TO_TICKS(20));           // ② 等 20ms,跳过弹跳期
            if (gpio_get_level(BTN) == 0) {          // ③ 再读一次,还是低才算数
                ESP_LOGI(TAG, "确认按下");
                while (gpio_get_level(BTN) == 0) {   // ④ 等到松手,避免一直触发
                    vTaskDelay(pdMS_TO_TICKS(10));
                }
            }
        }
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

逐行拆开看:

  • if (gpio_get_level(BTN) == 0):第一次发现引脚变低。但此刻可能正处在弹跳中,不能信。
  • vTaskDelay(pdMS_TO_TICKS(20)):睡 20 毫秒。机械按键的抖动通常在 5~20ms 内结束,等过这段,触点已经稳定。20ms 是个稳妥的经验值,太短滤不干净,太长会让快速连按失灵。
  • ③ 再读一次:弹跳期已过,这次读到的才是真实状态。还是低,确认是真按下。
  • while (gpio_get_level(BTN) == 0) { vTaskDelay(...); }一直等到你松手(引脚回到高)。没有这段,只要你按着不放,外层 while 会反复进来,又变成连续触发。注意这里循环体里放了 vTaskDelay,而不是裸的空转——空转会把 CPU 占死、还会触发看门狗复位,让出时间片才是 IDF 里的正确姿势。

这段就是入门级去抖的标准骨架:检测变化 → 延时 → 复检 → 等释放。理解它,你就懂了去抖的核心逻辑。

💡 提示

把上面计数器里的 if 块换成这套去抖逻辑,再按一下,count 就稳稳加 1 了。这是验证去抖有没有生效最直接的办法——抖动治没治好,计数器不会骗你。


第五步:去抖法二——esp_timer 非阻塞写法

延时去抖有个硬伤:vTaskDelay(20) 和那个等松手的 while 都会让当前任务停下来,期间这个任务别的事一概干不了(虽然 vTaskDelay 会把 CPU 让给别的任务,但本任务的逻辑卡住了)。只读一个按键时无所谓,可一旦你要在同一个任务里同时刷新屏幕、读传感器、跑状态机,这种"一卡卡一片"的写法就不好使了。

正经项目的做法是记时间,而不是停下来等。ESP-IDF 里用 esp_timer_get_time() 拿"开机到现在的微秒数",思路:记下"上一次电平变化的时刻",只有当前电平稳定保持超过 20ms,才认这次变化有效。

#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
#include "esp_log.h"

#define BTN GPIO_NUM_0
#define DEBOUNCE_US 20000              // 去抖窗口 20ms,注意单位是微秒

static const char *TAG = "button";

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

    int last_reading = 1;              // 上一次读到的原始电平(高=未按)
    int btn_state    = 1;             // 去抖后认定的稳定状态
    int64_t last_change = 0;          // 上次电平变化的时刻(微秒)

    while (1) {
        int reading = gpio_get_level(BTN);            // 当前这一瞬的原始读数
        int64_t now = esp_timer_get_time();           // 开机到现在的微秒数

        if (reading != last_reading) {                // 电平一变(含抖动),就重置计时
            last_change = now;
        }

        if (now - last_change > DEBOUNCE_US) {        // 稳定保持超过 20ms 才采信
            if (reading != btn_state) {               // 且确实和上次稳定状态不同
                btn_state = reading;
                if (btn_state == 0) {                 // 刚从高变低 = 一次有效按下
                    ESP_LOGI(TAG, "按下(非阻塞)");
                }
            }
        }

        last_reading = reading;                       // 记住这次读数,供下轮比对
        vTaskDelay(pdMS_TO_TICKS(2));                 // 让出时间片,别的活照干
    }
}

关键在于:抖动期间引脚高低乱跳,每跳一次都会触发 last_change = now 把计时不断推后,于是 now - last_change 永远凑不满 20ms,抖动期内的所有跳变都被无视。只有当引脚真正稳定下来、连续 20ms 没再变,计时才攒够,这才认定一次有效状态切换。

📌 说明

esp_timer_get_time() 而不是普通整型计数,是因为它返回的是真实物理时间(微秒),不受你 vTaskDelay 多久影响——你把那个 2ms 改成 5ms,去抖窗口还是 20ms,逻辑不会跑偏。还有一点:它的单位是微秒,所以去抖窗口写成 20000(20ms)别漏了三个零。

📌 说明

这里只在"从高变低"那一刻打印一次(这叫"边沿触发"),而不是"只要是低就打印"(那叫"电平触发")。读按键你几乎总是想要前者——按一下算一次事件,按住不放不应该刷屏。


第六步:去抖法三——加一段判断,认出"长按"

原理懂了,再往上加一档就是实战里最常用的交互:区分短按长按(菜单"短按切换、长按确认"就是这么做的)。做法不复杂——在第五步的非阻塞骨架上,记下按下的时刻,松手时算一下按了多久

#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
#include "esp_log.h"

#define BTN GPIO_NUM_0
#define DEBOUNCE_US   20000           // 去抖 20ms
#define LONGPRESS_US  1000000         // 长按阈值 1s(微秒)

static const char *TAG = "button";

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

    int last_reading = 1, btn_state = 1;
    int64_t last_change = 0;
    int64_t press_start = 0;          // 记下按下的那一刻

    while (1) {
        int reading = gpio_get_level(BTN);
        int64_t now = esp_timer_get_time();

        if (reading != last_reading) {
            last_change = now;
        }

        if (now - last_change > DEBOUNCE_US && reading != btn_state) {
            btn_state = reading;
            if (btn_state == 0) {                 // 按下:只记起点,先不下结论
                press_start = now;
            } else {                              // 松手:看按了多久
                int64_t held = now - press_start;
                if (held >= LONGPRESS_US) {
                    ESP_LOGI(TAG, "长按触发 (%lld ms)", held / 1000);
                } else {
                    ESP_LOGI(TAG, "短按 (%lld ms)", held / 1000);
                }
            }
        }

        last_reading = reading;
        vTaskDelay(pdMS_TO_TICKS(2));
    }
}

判定挪到了松手那一刻:按下时只记 press_start,松手时用 now - press_start 算出按了多久,超过 1 秒(LONGPRESS_US)就是长按,否则是短按。%lldint64_t 打印别写错格式符,不然日志会出乱码。

那社区里那些现成的按键库呢? ESP-IDF 的组件管理器(idf.py add-dependency)里能搜到 espressif/button 之类的组件,它把去抖、单击/双击/长按的识别都封好了。但先理解原理、再用组件:库是黑盒,能跑但你不知道它怎么跑的,等某个按键就是识别不灵、双击老漏判,你得能掀开盖子看里头的去抖逻辑、调它的时间参数。顺序反了,遇到坑就只能干瞪眼。

💡 提示

现在每轮 2ms 去 gpio_get_level 轮询一根脚,简单够用。但脚一多、或者要求很灵敏,轮询就显得笨。更聪明的做法是让引脚电平一变就主动来打断你——那就是中断。下一节正好讲这个。


故障排查:按键不对劲,按这个顺序查

现象 最可能的原因 怎么办
日志完全没反应 接线错 / 引脚号填错 / monitor 波特率不对 确认按键一端接 GPIO、一端接 GND;核对 #define BTN 的号;monitor 默认 115200,对不上可加 -b 115200
按一下触发好几次 没去抖,或去抖窗口太短 用第四/五步的去抖代码;把去抖窗口从 20000 调到 3000050000(30~50ms)再试
读反了(不按时一直触发,按下反而停) 接法和上下拉模式不匹配 你大概开了内部上拉却把按键另一端接到了 3.3V;接 GND,或改用 GPIO_PULLDOWN_ENABLE
这根脚怎么配都飘 用到了没有内部上下拉的脚 ESP32-S3 的纯输入脚(如 GPIO46)和部分 strapping 脚没内部上下拉,换一根普通脚,或焊外部电阻
程序跑着跑着重启 while 里裸空转占死了 CPU 等松手/轮询的循环里一定要 vTaskDelay 让出时间片,别留死循环把看门狗喂超时
偶尔漏掉一次按下 用了阻塞 vTaskDelay 且任务里别的代码太慢 改用第五步的 esp_timer 非阻塞去抖

动手挑战

光看不练记不住,挑一个改出来:

  1. 稳定的计数器:用第五步的非阻塞去抖,做一个"每按一下 count 加 1"、且绝不乱跳的计数器,把 count 用 ESP_LOGI 打到日志。
  2. 短按/长按分流:在第六步基础上再加一档——长按超过 2 秒打印"超长按(复位)",1~2 秒打印"长按",1 秒以内打印"短按"。这就是很多设备"短按切换、长按确认、超长按恢复出厂"交互的雏形。

卡住了?把你的代码和"期望的现象 vs 实际的现象"一起发给 AI,让它帮你定位——描述得越具体,它改得越准。


小结 · 你现在掌握了什么

  • 你能用 gpio_config 开内部上拉、用 gpio_get_level 稳定读出一个按键的状态,也搞懂了"按下 = 低电平(0)"背后的上拉原理。
  • 你知道抖动是机械触点弹跳造成的物理现象,每个机械按键都有,读按键必须去抖。
  • 你手里有三套去抖方案:朴素的延时确认(入门)、esp_timer 时间戳非阻塞(实战首选)、再加一段时长判断认出长按(交互雏形)。

按键是输入世界的"Hello World"。把它吃透,后面读旋钮、读传感器、做人机交互,"读取—去抖—判定事件"这套思路全通用。

下一步:第五步那种"每 2ms 轮询一遍"的笨办法,可以让引脚一变化就主动通知你——学会GPIO 中断,把 CPU 从死等里解放出来。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明