2. GPIO基础与LED控制:数字输出、PWM调光、按键输入与消抖、中断编程
各位同学,欢迎来到第二章。这一章我们直接上手硬件控制。说实话,GPIO(通用输入输出)是嵌入式开发最基础也最核心的操作。你想想看,不管多复杂的IoT设备,最终都要跟物理世界打交道——要么输出信号控制外设,要么读取传感器数据。而这一切,都从GPIO开始。
我个人习惯把GPIO比作芯片的「手脚」。ESP32有34个GPIO引脚,每个都可以独立配置成输入或输出。今天我们就用LED和按键这两个最经典的元件,把GPIO的四大核心操作彻底讲透:数字输出、PWM调光、按键输入与消抖、中断编程。
本章核心知识点一览
- 数字输出:点亮/熄灭LED,理解高低电平
- PWM调光:用脉宽调制实现呼吸灯效果
- 按键输入:读取电平状态,处理机械抖动
- 中断编程:用事件驱动替代轮询,提升效率
2.1 数字输出:点亮你的第一颗LED
先来个最简单的——让LED亮起来。在ESP32上控制LED,本质上就是控制某个GPIO引脚输出高电平(3.3V)或低电平(0V)。
硬件连接很简单:LED正极接GPIO2(开发板通常已内置),负极通过一个220Ω电阻接地。为什么用220Ω?嗯,这里要注意——ESP32的GPIO最大输出电流约12mA,3.3V除以12mA约275Ω,所以220Ω到330Ω都安全。
来看代码。我用的是ESP-IDF框架,这是乐鑫官方推荐的开发环境。
#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_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);
while (1) {
gpio_set_level(LED_GPIO, 1); // 点亮
vTaskDelay(1000 / portTICK_PERIOD_MS);
gpio_set_level(LED_GPIO, 0); // 熄灭
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
我的经验之谈
我在项目中遇到过好几次LED不亮的情况,最后发现都是GPIO配置问题。记住:pin_bit_mask用的是位掩码,(1ULL << GPIO_NUM)这个写法最保险。另外,gpio_config()会一次性设置所有参数,比逐个调用老API更清晰。
2.2 PWM调光:让LED呼吸起来
数字输出只能让LED要么全亮要么全灭。那怎么实现渐亮渐暗的呼吸效果呢?这就用到PWM(脉宽调制)了。
说白了,PWM就是快速切换高低电平,通过改变高电平占一个周期的时间比例(占空比)来调节平均电压。人眼有视觉暂留效应,只要频率够高(通常1kHz以上),看到的就不是闪烁,而是亮度变化。
ESP32的LEDC(LED控制)模块专门做PWM,最多16个通道,独立配置频率和分辨率。
| 参数 | 说明 | 推荐值 |
|---|---|---|
| 频率 | PWM波形的周期频率 | 1kHz ~ 5kHz(LED用) |
| 分辨率 | 占空比的调节步数(位宽) | 8位(0~255)或10位(0~1023) |
| 通道 | LEDC的硬件通道编号 | 0~7(高速)或8~15(低速) |
| 定时器 | 为通道提供时钟基准 | LEDC_TIMER_0 ~ LEDC_TIMER_3 |
#include "driver/ledc.h"
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_RESOLUTION LEDC_TIMER_10_BIT // 10位分辨率,0~1023
#define LEDC_FREQUENCY 5000 // 5kHz
void pwm_init(void)
{
// 配置定时器
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_HIGH_SPEED_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_RESOLUTION,
.freq_hz = LEDC_FREQUENCY,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&timer_conf);
// 配置通道
ledc_channel_config_t ch_conf = {
.gpio_num = LED_GPIO,
.speed_mode = LEDC_HIGH_SPEED_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&ch_conf);
}
void app_main(void)
{
pwm_init();
while (1) {
// 渐亮:占空比从0增加到1023
for (int duty = 0; duty < 1024; duty += 10) {
ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
vTaskDelay(10 / portTICK_PERIOD_MS);
}
// 渐灭:占空比从1023减小到0
for (int duty = 1023; duty >= 0; duty -= 10) {
ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
}
注意!
调用ledc_set_duty()后必须调用ledc_update_duty(),否则占空比不会生效。我曾经在这个坑里浪费了半天——代码看起来没问题,LED就是不呼吸,最后发现忘了update。嗯,这种细节最容易被忽略。
2.3 按键输入与消抖
现在换个方向——从输出变成输入。按键是最常见的输入设备,原理很简单:按下时引脚接地(低电平),松开时被上拉电阻拉到高电平。
但问题来了:机械按键在按下和松开的瞬间,触点会弹跳几次,产生一连串的脉冲信号。如果不处理,一次按键可能被误判成多次。
为什么会这样?因为金属触点碰撞时会有微小的回弹,持续约5~20ms。这就是「抖动」现象。
消抖有两种主流方法:
- 硬件消抖:在按键两端并联一个0.1μF电容,利用电容充放电平滑信号
- 软件消抖:检测到电平变化后,延时10~20ms再读取一次,确认状态稳定
我个人更推荐软件消抖,因为不增加硬件成本,而且灵活性高。来看代码:
#define BUTTON_GPIO GPIO_NUM_0 // 开发板上的BOOT按键
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 last_state = 1; // 上一次稳定状态
int current_state;
while (1) {
current_state = gpio_get_level(BUTTON_GPIO);
if (current_state != last_state) {
// 检测到变化,延时消抖
vTaskDelay(20 / portTICK_PERIOD_MS);
current_state = gpio_get_level(BUTTON_GPIO);
if (current_state != last_state) {
if (current_state == 0) {
printf("按键按下!\n");
} else {
printf("按键松开!\n");
}
last_state = current_state;
}
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
避坑指南
我曾经在工业控制项目里只用10ms延时消抖,结果现场有强电磁干扰,按键频繁误触发。后来我把延时加到25ms,同时做了连续三次采样取多数值的「投票消抖」算法,问题才解决。所以,消抖时间要根据实际环境调整,不要死板套用教科书上的20ms。
2.4 中断编程:告别轮询
上面按键检测用的是轮询——主循环不断读取GPIO状态。这样做有两个问题:
- 浪费CPU资源,大部分时间都在空转
- 响应延迟取决于循环周期,可能错过短暂的电平变化
中断就是解决这个问题的。你可以把中断理解成「硬件主动通知CPU」——按键一按下,CPU立刻暂停当前任务,跳转到中断服务函数(ISR)执行处理,处理完再回来继续干活。
ESP32的中断编程有几个要点:
- ISR必须短小精悍,不能做延时、打印等耗时操作
- ISR内使用专用API(带FromISR后缀的)
- 中断触发类型可配置:上升沿、下降沿、低电平、高电平
#include "esp_intr_alloc.h"
static QueueHandle_t gpio_evt_queue = NULL;
// 中断服务函数
static void IRAM_ATTR gpio_isr_handler(void* arg)
{
uint32_t gpio_num = (uint32_t) arg;
xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}
// 任务:处理中断事件
void gpio_task_example(void* arg)
{
uint32_t io_num;
while (1) {
if (xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {
printf("GPIO %d 触发中断!\n", io_num);
// 在这里做实际处理,比如切换LED状态
}
}
}
void app_main(void)
{
// 创建队列,用于ISR和任务之间通信
gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));
// 配置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_NEGEDGE // 下降沿触发
};
gpio_config(&io_conf);
// 安装GPIO中断服务
gpio_install_isr_service(0);
// 为指定GPIO添加中断处理
gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler, (void*) BUTTON_GPIO);
// 创建任务处理中断事件
xTaskCreate(gpio_task_example, "gpio_task_example", 2048, NULL, 10, NULL);
}
中断 vs 轮询 对比
| 特性 | 轮询 | 中断 |
|---|---|---|
| CPU占用 | 高(持续检查) | 低(事件触发) |
| 响应延迟 | 取决于循环周期 | 微秒级 |
| 代码复杂度 | 简单 | 中等(需处理ISR与任务通信) |
| 适用场景 | 低速、非实时 | 高速、实时响应 |
中断编程红线
ISR里绝对不能调用printf()、vTaskDelay()、malloc()等阻塞或非可重入函数。我见过有人直接在ISR里打印调试信息,结果系统频繁崩溃。正确的做法是用队列或信号量把事件「扔」给一个普通任务去处理——上面代码展示的就是这种模式。
2.5 综合实战:按键控制呼吸灯
把今天学的知识串起来——做一个按键控制呼吸灯的小项目:
- 按一下按键,LED开始呼吸
- 再按一下,LED停止呼吸并熄灭
- 使用中断检测按键,PWM实现呼吸
这个项目虽然简单,但涵盖了数字输出、PWM、按键输入、中断四个核心知识点。你可以在开发板上试试,代码我已经放在前面了,组合起来就能跑。
嗯,到这里第二章的核心内容就讲完了。GPIO看似简单,但它是所有外设驱动的基础。把这一章吃透,后面操作I2C、SPI、UART等总线时,你会发现很多思路是相通的。