3、ESP32 基础外设控制:GPIO 控制 LED 灯、PWM 调光、按键输入检测、中断编程基础

好,咱们开始动手了。前面两章我们把开发环境搭好,也跑了个点灯程序。但说实话,那个点灯只是让你感受一下「我能控制硬件了」的快感。真正的嵌入式开发,你得学会跟 GPIO 打交道——输入、输出、模拟输出、中断响应。这一章,我把这些基础操作一次性讲透。

3.1 GPIO 输出:控制 LED 灯

GPIO 输出是最简单的。你把引脚拉高,灯就亮;拉低,灯就灭。但这里有个坑——你得搞清楚你的 LED 是「高电平点亮」还是「低电平点亮」。我刚开始做项目时,照着网上的电路图焊板子,结果灯死活不亮。查了半天,原来是 LED 正负极接反了,而且那个板子用的是低电平驱动。

来看代码。用 Arduino 框架控制 GPIO 输出,其实就三步:

// 定义引脚
#define LED_PIN 2

void setup() {
  // 1. 设置引脚为输出模式
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  // 2. 拉高电平,点亮 LED
  digitalWrite(LED_PIN, HIGH);
  delay(1000);
  
  // 3. 拉低电平,熄灭 LED
  digitalWrite(LED_PIN, LOW);
  delay(1000);
}

嗯,这里要注意:pinMode() 一定要在 setup() 里调用。我见过有人把它写在 loop() 里,结果每循环一次就重新配置一次引脚,虽然也能工作,但效率极低。

个人习惯:我一般把引脚定义放在文件最前面,用宏定义或者 const 变量。这样后期改引脚时,只需要改一个地方,不用满代码找。

3.2 PWM 调光:让 LED 呼吸起来

数字输出只有 0 和 1,但很多时候我们需要「中间状态」。比如让 LED 半亮,或者让电机转得慢一点。这时候就要用 PWM(脉冲宽度调制)。

说白了,PWM 就是快速地在高电平和低电平之间切换。你想想看,如果切换频率足够快(人眼看不出来),那 LED 的亮度就取决于高电平占整个周期的比例——也就是占空比。

ESP32 的 PWM 比 Arduino Uno 灵活得多。它有独立的 LEDC(LED Control)外设,可以设置频率、分辨率、通道。我个人习惯用 5000 Hz 的频率,13 位的分辨率(0-8191)。

#define LED_PIN 2
#define PWM_CH 0      // PWM 通道
#define PWM_FREQ 5000 // 频率 5KHz
#define PWM_RES 13    // 分辨率 13 位

void setup() {
  // 配置 PWM
  ledcSetup(PWM_CH, PWM_FREQ, PWM_RES);
  // 将引脚绑定到 PWM 通道
  ledcAttachPin(LED_PIN, PWM_CH);
}

void loop() {
  // 呼吸灯效果:从暗到亮
  for (int duty = 0; duty <= 8191; duty++) {
    ledcWrite(PWM_CH, duty);
    delay(1);
  }
  // 从亮到暗
  for (int duty = 8191; duty >= 0; duty--) {
    ledcWrite(PWM_CH, duty);
    delay(1);
  }
}

为什么用 13 位分辨率?其实 8 位(0-255)也够用,但 13 位能让亮度变化更平滑。我在做智能灯带项目时,客户要求调光不能有「阶梯感」,所以分辨率越高越好。当然,分辨率越高,PWM 频率就得适当降低,否则 ESP32 的时钟跟不上。

关键点:ESP32 有 16 个 PWM 通道,但每个通道只能绑定一个引脚。如果你要控制多个 LED,记得分配不同的通道。

3.3 按键输入检测:读取外部信号

输出讲完了,咱们来看看输入。按键是最常见的输入设备。但按键有个烦人的问题——抖动。你按下去的一瞬间,机械触点会弹跳几次,导致电平在短时间内反复变化。如果不处理,程序可能会误判为按了多次。

我曾经在一个门禁项目里吃过这个亏。按键按下一次,门开了又关、关了又开,反复三次。后来加了消抖才解决。

消抖有两种方式:硬件消抖(加电容)和软件消抖(延时判断)。这里我讲软件消抖,因为不需要额外元件。

#define BUTTON_PIN 0  // 注意:ESP32 开发板上的 BOOT 按钮通常接 GPIO 0

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP); // 启用内部上拉电阻
  Serial.begin(115200);
}

void loop() {
  // 检测按键是否按下(低电平有效)
  if (digitalRead(BUTTON_PIN) == LOW) {
    delay(50); // 延时 50ms 跳过抖动
    if (digitalRead(BUTTON_PIN) == LOW) {
      Serial.println("按键按下!");
      // 等待按键释放
      while (digitalRead(BUTTON_PIN) == LOW);
    }
  }
}

这里用了 INPUT_PULLUP 模式。什么意思呢?就是 ESP32 内部有一个上拉电阻,默认把引脚拉高到 3.3V。按键按下时,引脚接地,变成低电平。这样你就不用外接上拉电阻了,省一个元件。

注意:延时消抖虽然简单,但会阻塞程序。如果你的程序还要做其他事情(比如同时控制 LED),这种写法就不太合适。后面我们会用中断来解决这个问题。

3.4 中断编程基础:让 CPU 别傻等

刚才的按键检测有个问题——while 循环一直在轮询,CPU 啥也干不了。这就好比你在门口等着快递员,什么事都做不了。中断就不一样了:你该干嘛干嘛,快递员来了按门铃,你再去开门。

ESP32 支持两种中断:GPIO 中断和定时器中断。这里先讲 GPIO 中断。

#define BUTTON_PIN 0
#define LED_PIN 2

volatile bool ledState = false; // volatile 关键字很重要!

void IRAM_ATTR buttonISR() {
  // 中断服务函数:翻转 LED 状态
  ledState = !ledState;
  digitalWrite(LED_PIN, ledState);
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  
  // 配置中断:下降沿触发(按键按下时电平从高变低)
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}

void loop() {
  // 主循环可以干别的事情,比如串口打印
  Serial.println("主循环正在运行...");
  delay(1000);
}

这里有几个要点:

  • volatile 关键字:告诉编译器,这个变量可能在中断中被修改,不要优化掉。我见过有人忘了加,结果中断里改了值,主循环死活读不到新值。
  • IRAM_ATTR:把中断函数放到 IRAM(指令 RAM)中,保证执行速度。如果不加,中断可能会因为 Flash 读取延迟而响应变慢。
  • 中断函数要短小精悍:不要在中断里做复杂操作,比如 delay()Serial.print()。最好只设置标志位,具体处理放到主循环里。
避坑指南:我曾经在中断里用了 Serial.println(),结果程序频繁崩溃。后来查资料才知道,串口输出依赖中断,在中断里调用串口函数会导致递归中断,栈溢出。所以,中断里只做最基础的操作。

3.5 综合实战:按键控制呼吸灯

把上面的知识串起来,做一个综合实验:按键按下一次,LED 开始呼吸;再按一次,LED 停止呼吸。

#define LED_PIN 2
#define BUTTON_PIN 0
#define PWM_CH 0
#define PWM_FREQ 5000
#define PWM_RES 13

volatile bool breathing = false;
volatile unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 50;

void IRAM_ATTR buttonISR() {
  unsigned long now = millis();
  if (now - lastDebounceTime > debounceDelay) {
    breathing = !breathing;
    lastDebounceTime = now;
  }
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  ledcSetup(PWM_CH, PWM_FREQ, PWM_RES);
  ledcAttachPin(LED_PIN, PWM_CH);
  
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}

void loop() {
  if (breathing) {
    for (int duty = 0; duty <= 8191; duty++) {
      ledcWrite(PWM_CH, duty);
      delay(1);
      if (!breathing) break; // 如果按键再次按下,立即退出
    }
    for (int duty = 8191; duty >= 0; duty--) {
      ledcWrite(PWM_CH, duty);
      delay(1);
      if (!breathing) break;
    }
  } else {
    ledcWrite(PWM_CH, 0); // 关闭 LED
    delay(10);
  }
}

这个例子把中断、消抖、PWM 都结合起来了。注意看,我在中断里做了软件消抖——用 millis() 判断两次中断的时间间隔。这样既避免了阻塞,又解决了抖动问题。

3.6 本章小结

这一章的内容,说白了就是嵌入式开发的「四则运算」:

  • GPIO 输出:用 digitalWrite() 控制高低电平
  • PWM 调光:用 ledcWrite() 实现模拟输出
  • 按键输入:用 digitalRead() 读取电平,配合消抖
  • 中断编程:用 attachInterrupt() 实现事件驱动

这些基础操作,在后面的章节里会反复用到。比如下一章讲传感器,就要用 GPIO 输入读取数据;讲电机控制,就要用 PWM 调速。所以,这一章的内容一定要亲手敲一遍代码,别光看。

嗯,我记得有个学员跟我说,他看完这章后,把家里的台灯改成了手机控制的呼吸灯。虽然只是个小项目,但那种「把代码变成现实」的感觉,真的很棒。你也试试?