2. GPIO基础与LED控制:GPIO模式配置、数字输出、LED闪烁实验、按键输入与消抖

各位同学,欢迎来到第二章。上一章我们把开发环境搭好了,也跑通了第一个Hello World程序。但说真的,光看串口打印没什么意思,做嵌入式开发,总得跟硬件打交道吧?这一章我们就来点实在的——控制GPIO,让LED亮起来,再让按键把灯灭掉。

GPIO,全称是General-Purpose Input/Output,通用输入输出口。说白了,它就是芯片上那些可以编程控制的引脚。你可以让它输出高电平或低电平,也可以读取外部信号是0还是1。ESP32有34个GPIO引脚,但不是所有引脚都能随便用,这个后面我会讲到。

本章核心目标: 掌握ESP32的GPIO模式配置,实现LED闪烁,并完成带消抖的按键输入检测。

2.1 GPIO模式配置——你得告诉引脚该干什么

在ESP-IDF里,操作GPIO之前必须先配置。你不能上来就直接写电平,得先告诉这个引脚:你是要输出,还是要输入?要不要上拉?要不要下拉?

我个人习惯用 gpio_config_t 结构体来完成配置,清晰又不容易漏项。来看一个典型的输出配置:

#include "driver/gpio.h"

#define LED_GPIO_PIN  GPIO_NUM_2

void led_init(void)
{
    gpio_config_t io_conf = {};
    // 关闭中断
    io_conf.intr_type = GPIO_INTR_DISABLE;
    // 设置为输出模式
    io_conf.mode = GPIO_MODE_OUTPUT;
    // 需要控制的引脚,用位掩码表示
    io_conf.pin_bit_mask = (1ULL << LED_GPIO_PIN);
    // 不启用下拉
    io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
    // 不启用上拉
    io_conf.pull_up_en = GPIO_PULLUP_DISABLE;
    // 应用配置
    gpio_config(&io_conf);
}

嗯,这里要注意一点:pin_bit_mask 用的是 1ULL,不是 1。为什么?因为ESP32的引脚编号最大到39,如果用32位整数,高位的引脚就表示不了了。我之前见过有同事用 1 << GPIO_NUM_32,结果编译没问题,运行死活没反应——就是因为溢出了。

配置项 可选值 说明
intr_type GPIO_INTR_DISABLE / GPIO_INTR_POSEDGE / ... 中断触发类型,输出模式一般禁用
mode GPIO_MODE_OUTPUT / GPIO_MODE_INPUT / GPIO_MODE_OUTPUT_OD 输入、输出、开漏输出等
pull_up_en GPIO_PULLUP_ENABLE / GPIO_PULLUP_DISABLE 内部上拉电阻,输入模式常用
pull_down_en GPIO_PULLDOWN_ENABLE / GPIO_PULLDOWN_DISABLE 内部下拉电阻,很少用

2.2 数字输出——让LED亮起来

配置好之后,控制输出就简单了。ESP-IDF提供了两个函数:

  • gpio_set_level(pin, level) —— 设置引脚电平,level为0或1
  • gpio_get_level(pin) —— 读取引脚电平,返回0或1

你想想看,要让LED亮,其实就是把引脚拉高(或者拉低,取决于你的电路接法)。大多数开发板上的LED是低电平点亮,也就是引脚输出0时LED亮。但为了通用性,我建议你在代码里用宏定义来抽象:

#define LED_ON  0   // 低电平点亮
#define LED_OFF 1

gpio_set_level(LED_GPIO_PIN, LED_ON);   // 点亮
gpio_set_level(LED_GPIO_PIN, LED_OFF);  // 熄灭

我在项目中遇到过好几次这样的问题:硬件工程师把LED接法改了,但软件没同步更新,结果灯死活不亮。后来我养成了一个习惯——在头文件里用宏定义好电平逻辑,硬件变更时只改宏,不动业务代码。

2.3 LED闪烁实验——第一个看得见的程序

闪烁,说白了就是亮一会儿,灭一会儿,循环往复。我们用 vTaskDelay 来实现延时,单位是毫秒。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void)
{
    led_init();  // 初始化GPIO

    while (1) {
        gpio_set_level(LED_GPIO_PIN, LED_ON);
        vTaskDelay(500 / portTICK_PERIOD_MS);  // 延时500ms

        gpio_set_level(LED_GPIO_PIN, LED_OFF);
        vTaskDelay(500 / portTICK_PERIOD_MS);
    }
}

这里有个小细节:portTICK_PERIOD_MS 是FreeRTOS的系统时钟节拍周期,默认是10ms(取决于配置)。用 vTaskDelay 而不是 delay() 的好处是——它不会阻塞CPU,在延时期间系统可以调度其他任务。虽然现在只有一个任务,但养成好习惯总没错。

小技巧: 如果你想让闪烁频率可调,可以把延时时间定义成宏,或者用变量传参。我经常在调试时把延时改成100ms,快速闪烁用来观察GPIO波形。

2.4 按键输入与消抖——别让抖动坑了你

按键输入比输出麻烦一点。麻烦在哪?抖动。

你按下一个机械按键,触点不会瞬间稳定接触,而是会在几毫秒内反复弹跳。如果不做处理,一次按键可能会被检测成几十次。我刚开始做项目时就吃过这个亏——按一下按键,LED切换了五六次状态,还以为程序写错了。

为什么会这样?看下面的图就明白了:

按键按下时的电平抖动示意图 理想信号: 高电平 低电平 实际信号: 抖动区 抖动区 消抖后: 延时后采样 理想 实际 消抖后

消抖的方法有很多种,硬件上用RC滤波,软件上最常见的就是延时消抖。原理很简单:检测到电平变化后,等10~20ms再读一次,如果电平没变,就确认是有效按键。

#define BUTTON_GPIO_PIN  GPIO_NUM_0
#define DEBOUNCE_MS      20

void button_init(void)
{
    gpio_config_t io_conf = {};
    io_conf.intr_type = GPIO_INTR_DISABLE;
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pin_bit_mask = (1ULL << BUTTON_GPIO_PIN);
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;   // 启用上拉,按键按下为低电平
    io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
    gpio_config(&io_conf);
}

int button_read(void)
{
    if (gpio_get_level(BUTTON_GPIO_PIN) == 0) {  // 检测到按下
        vTaskDelay(DEBOUNCE_MS / portTICK_PERIOD_MS);  // 延时消抖
        if (gpio_get_level(BUTTON_GPIO_PIN) == 0) {    // 再次确认
            return 1;  // 有效按下
        }
    }
    return 0;
}
注意: 延时消抖虽然简单,但会阻塞当前任务。如果在生产项目中,建议用定时器或状态机来做非阻塞消抖。不过对于初学者,先掌握这种基础方法就够了。

2.5 综合实验:按键控制LED

把上面的代码组合起来,做一个按键控制LED的实验:按一下LED亮,再按一下LED灭。

void app_main(void)
{
    led_init();
    button_init();

    int led_state = 0;
    gpio_set_level(LED_GPIO_PIN, LED_OFF);

    while (1) {
        if (button_read()) {
            led_state = !led_state;
            gpio_set_level(LED_GPIO_PIN, led_state ? LED_ON : LED_OFF);
            // 防止一次按下触发多次,等待按键释放
            while (gpio_get_level(BUTTON_GPIO_PIN) == 0) {
                vTaskDelay(10 / portTICK_PERIOD_MS);
            }
        }
        vTaskDelay(10 / portTICK_PERIOD_MS);
    }
}

这里我加了一个等待释放的循环。如果不加,你按一次按键,程序可能会在 button_read() 返回1之后,紧接着又读到一次按下——因为你的手还没松开。嗯,这个小细节,很多新手会忽略。

2.6 避坑指南——我踩过的那些坑

  • 引脚选择: ESP32的GPIO 6~11被用于连接Flash,不能用作普通IO。GPIO 34~39只能输入,不能输出。我刚开始用GPIO 34做LED输出,折腾了半天才发现。
  • 上电默认电平: ESP32上电时,部分引脚会输出短暂的高电平。如果你用这些引脚控制继电器或电机,可能会造成误动作。建议在电路设计时加上反向逻辑或延时启动。
  • 按键抖动时间: 不同按键的抖动时间不一样,便宜的按键可能抖动20~30ms,质量好的5~10ms。我建议消抖延时设在15~20ms,兼顾可靠性和响应速度。
  • 不要用 delay(): ESP-IDF里没有标准的 delay() 函数,FreeRTOS下请用 vTaskDelay。用 for 循环做空转延时是极其不专业的做法。
我的习惯: 每次写GPIO代码前,先翻一下ESP32的引脚功能表,确认哪些引脚能用、哪些有特殊功能。花两分钟看文档,省两小时查bug。