3、GPIO基础与LED控制:ESP32的GPIO引脚功能、数字输出控制LED、PWM调光、按键输入检测

好,咱们进入第三章。这一章是动手实践的第一步,也是嵌入式开发最基础、最核心的内容——GPIO。

说白了,GPIO就是芯片和外界打交道的「手脚」。你让它输出高电平,它就输出3.3V;你让它检测输入,它就能知道外面是0V还是3.3V。ESP32有34个GPIO引脚,但不是所有引脚都能随便用。嗯,这里要注意,有些引脚有特殊功能,比如接Flash的、接晶振的,你乱用的话程序可能跑不起来。

3.1 ESP32的GPIO引脚功能概览

ESP32的GPIO引脚,大部分是「多功能复用」的。什么意思?就是同一个引脚,你可以把它配置成普通的数字输入输出,也可以配置成ADC模拟输入、触摸传感器、PWM输出、I2C、SPI、UART等等。

我个人习惯,在设计电路板之前,先把引脚功能表打印出来贴在墙上。因为一旦焊好板子发现引脚冲突,那真是欲哭无泪。

下面这张表,是我整理的最常用的GPIO引脚及其注意事项:

GPIO编号 功能说明 注意事项
GPIO0 数字I/O、ADC2_CH1、触摸 上电时拉低会进入下载模式,慎用
GPIO2 数字I/O、ADC2_CH2、触摸 上电时拉低会影响启动,我踩过这个坑
GPIO4 数字I/O、ADC2_CH0、触摸 比较安全,常用
GPIO5 数字I/O、VSPI_CS 上电时拉低会输出高电平,注意
GPIO12 数字I/O、ADC2_CH5、触摸 上电时电平决定Flash电压,别乱动
GPIO13 数字I/O、ADC2_CH4、触摸 比较安全,常用
GPIO14 数字I/O、ADC2_CH6、触摸 比较安全,常用
GPIO15 数字I/O、ADC2_CH3、触摸 上电时拉低会输出配置信息,注意
GPIO16/17 数字I/O 常用于RTC、PSRAM,没问题
GPIO18/19/21/22/23 数字I/O 非常安全,随便用
GPIO25/26/27 数字I/O、DAC、ADC 比较安全,常用
GPIO32/33 数字I/O、ADC1 非常安全,推荐用于按键
GPIO34/35/36/39 仅输入、ADC1 不能输出!只能读输入,切记
⚠️ 重要警告: GPIO34、35、36、39 这四个引脚是「纯输入」引脚,没有输出驱动能力。我曾经有个学生,把LED接在GPIO34上,折腾了一下午灯都不亮,最后发现是引脚选错了。你想想看,这得多郁闷。

3.2 数字输出控制LED——点亮你的第一盏灯

好了,理论说完了,咱们直接上手。点亮一个LED,是嵌入式界的「Hello World」。你想想看,当那个小灯亮起来的时候,那种成就感是无可替代的。

硬件连接很简单:LED正极接一个220Ω限流电阻,电阻另一端接GPIO2,LED负极接GND。

为什么用220Ω?ESP32的GPIO输出高电平是3.3V,LED正向压降大约2V,电流控制在5~10mA比较安全。220Ω算下来电流大约(3.3-2)/220 ≈ 6mA,刚刚好。

代码怎么写?看下面:

#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)
{
    // 配置GPIO为输出模式
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);

    while (1) {
        // 输出高电平,点亮LED
        gpio_set_level(LED_GPIO, 1);
        printf("LED ON\n");
        vTaskDelay(1000 / portTICK_PERIOD_MS);  // 延时1秒

        // 输出低电平,熄灭LED
        gpio_set_level(LED_GPIO, 0);
        printf("LED OFF\n");
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

这段代码逻辑很直白:配置引脚、循环翻转电平。你编译下载后,应该能看到LED以1秒间隔闪烁。

💡 我的小技巧: 调试时别光看灯,记得加printf打印日志。有时候灯不亮可能是硬件问题(比如电阻焊错了),但日志能帮你确认代码确实在跑。我在项目中遇到过好几次,灯不亮结果是线断了,但日志还在打印,一下子就定位到了。

3.3 PWM调光——让LED呼吸起来

单纯的亮灭太单调了。咱们来点高级的——PWM调光。PWM就是脉冲宽度调制,说白了就是快速开关LED,通过调整「开」的时间比例(占空比)来改变亮度。

ESP32的PWM是通过LEDC(LED控制)外设实现的。它支持16个通道,频率和分辨率都可调。

我个人习惯,调光频率设在1kHz左右,分辨率8位(0~255)。频率太低会看到闪烁,频率太高又浪费CPU资源。

来看一个呼吸灯的例子:

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

#define LED_GPIO        GPIO_NUM_2
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_DUTY_RES   LEDC_TIMER_8_BIT   // 8位分辨率
#define LEDC_FREQUENCY  1000                // 1kHz

void app_main(void)
{
    // 配置定时器
    ledc_timer_config_t timer_conf = {
        .speed_mode = LEDC_MODE,
        .timer_num = LEDC_TIMER,
        .duty_resolution = LEDC_DUTY_RES,
        .freq_hz = LEDC_FREQUENCY,
        .clk_cfg = LEDC_AUTO_CLK
    };
    ledc_timer_config(&timer_conf);

    // 配置通道
    ledc_channel_config_t channel_conf = {
        .gpio_num = LED_GPIO,
        .speed_mode = LEDC_MODE,
        .channel = LEDC_CHANNEL,
        .timer_sel = LEDC_TIMER,
        .duty = 0,
        .hpoint = 0
    };
    ledc_channel_config(&channel_conf);

    int duty = 0;
    int step = 5;

    while (1) {
        // 渐亮
        for (duty = 0; duty <= 255; duty += step) {
            ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
            vTaskDelay(10 / portTICK_PERIOD_MS);
        }
        // 渐暗
        for (duty = 255; duty >= 0; duty -= step) {
            ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
            vTaskDelay(10 / portTICK_PERIOD_MS);
        }
    }
}

这段代码会让LED从暗到亮、再从亮到暗,循环往复,就像呼吸一样自然。

🔑 核心要点: PWM调光的本质是「人眼视觉暂留」。频率够高时,人眼就分辨不出闪烁,只感觉到平均亮度。ESP32的LEDC外设是硬件PWM,不占用CPU资源,非常高效。

3.4 按键输入检测——让ESP32「听」你的指令

光会输出不行,还得会输入。按键是最简单的输入设备。

硬件连接:按键一端接GPIO32,另一端接GND。同时GPIO32需要接一个10kΩ上拉电阻到3.3V。这样按键没按下时,GPIO32读到高电平;按下时,读到低电平。

为什么需要上拉电阻?因为GPIO在悬空状态时电平不确定,外界一点干扰就会误触发。上拉电阻给它一个确定的默认状态。

ESP32内部有上拉电阻,可以省掉外部电阻。但说实话,我建议外部还是加上,因为内部上拉电阻值大约45kΩ,抗干扰能力不如外部10kΩ强。

来看代码:

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

#define BUTTON_GPIO     GPIO_NUM_32
#define LED_GPIO        GPIO_NUM_2

void app_main(void)
{
    // 配置按键引脚为输入,使能内部上拉
    gpio_set_direction(BUTTON_GPIO, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_GPIO, GPIO_PULLUP_ONLY);

    // 配置LED引脚为输出
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);

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

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

        // 检测下降沿:从高电平变为低电平
        if (last_button_state == 1 && button_state == 0) {
            // 延时20ms消抖
            vTaskDelay(20 / portTICK_PERIOD_MS);
            button_state = gpio_get_level(BUTTON_GPIO);
            if (button_state == 0) {
                // 确认按键按下,翻转LED状态
                led_state = !led_state;
                gpio_set_level(LED_GPIO, led_state);
                printf("Button pressed! LED state: %d\n", led_state);
            }
        }

        last_button_state = button_state;
        vTaskDelay(10 / portTICK_PERIOD_MS);
    }
}

这里有个关键点——消抖。机械按键在按下和释放的瞬间,会产生多次抖动,如果不处理,一次按下可能会被误判成多次。我用的方法是:检测到下降沿后,等20ms再读一次,如果还是低电平,才确认是有效按下。

⚠️ 避坑指南: 我曾经在一个项目中,按键没做消抖,结果用户按一下,灯闪了五六次。用户投诉说产品有「鬼影」。后来加了20ms延时消抖,问题就解决了。记住,机械按键必有抖动,消抖不是可选项,是必选项。

3.5 综合实战:按键控制LED亮度

把上面的知识串起来,做一个实用的小项目:按一下按键,LED亮度增加一档,共4档(0%、33%、66%、100%),循环切换。

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

#define BUTTON_GPIO     GPIO_NUM_32
#define LED_GPIO        GPIO_NUM_2
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_DUTY_RES   LEDC_TIMER_8_BIT
#define LEDC_FREQUENCY  1000

// 4档亮度对应的占空比
const int duty_levels[4] = {0, 85, 170, 255};

void app_main(void)
{
    // 配置LEDC
    ledc_timer_config_t timer_conf = {
        .speed_mode = LEDC_MODE,
        .timer_num = LEDC_TIMER,
        .duty_resolution = LEDC_DUTY_RES,
        .freq_hz = LEDC_FREQUENCY,
        .clk_cfg = LEDC_AUTO_CLK
    };
    ledc_timer_config(&timer_conf);

    ledc_channel_config_t channel_conf = {
        .gpio_num = LED_GPIO,
        .speed_mode = LEDC_MODE,
        .channel = LEDC_CHANNEL,
        .timer_sel = LEDC_TIMER,
        .duty = 0,
        .hpoint = 0
    };
    ledc_channel_config(&channel_conf);

    // 配置按键
    gpio_set_direction(BUTTON_GPIO, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_GPIO, GPIO_PULLUP_ONLY);

    int level_index = 0;
    int last_button_state = 1;

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

        if (last_button_state == 1 && button_state == 0) {
            vTaskDelay(20 / portTICK_PERIOD_MS);
            button_state = gpio_get_level(BUTTON_GPIO);
            if (button_state == 0) {
                // 切换到下一档
                level_index = (level_index + 1) % 4;
                ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty_levels[level_index]);
                ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
                printf("Brightness level: %d/3\n", level_index);
            }
        }

        last_button_state = button_state;
        vTaskDelay(10 / portTICK_PERIOD_MS);
    }
}

这个项目虽然简单,但包含了嵌入式开发的三个核心要素:输入检测、输出控制、状态管理。你想想看,从点亮一盏灯,到用按键控制灯的亮度,这中间其实已经走过了嵌入式开发的完整流程。

🎯 本章总结:
  • ESP32的GPIO引脚功能丰富,但要注意特殊引脚的限制
  • 数字输出用 gpio_set_level(),控制LED亮灭
  • PWM调光用LEDC外设,实现呼吸灯效果
  • 按键输入要消抖,20ms延时是常用方法
  • 综合起来,就能做出实用的交互控制

下一章,我们会深入ADC和传感器,让ESP32能「感知」模拟世界的温度、光照、声音。准备好了吗?咱们继续。