3、GPIO与基础外设:GPIO输入输出、按键消抖、PWM呼吸灯、ADC模拟量采集

好,咱们进入第三讲。这一章是嵌入式开发的「基本功」,说白了就是让芯片跟外界打交道的最底层手段。GPIO、PWM、ADC,这三个东西你玩熟了,智能家居里一大半的传感器和执行器你都能搞定。

我个人习惯把这一章叫做「感官与肌肉」——GPIO是手指头,PWM是呼吸,ADC是眼睛。嗯,咱们一个一个来拆解。

3.1 GPIO输入输出:最基础的数字信号

GPIO,全称General Purpose Input Output,通用输入输出口。每个引脚可以独立配置成输入或输出模式。输出就是写高低电平,输入就是读高低电平。

在ESP32上,大部分引脚都支持GPIO功能。但要注意,有些引脚有特殊用途(比如接Flash的引脚),不能随便用。我刚开始做项目时,就踩过这个坑——把GPIO6当普通IO用,结果程序死活跑不起来。

⚠️ 避坑指南: ESP32的GPIO6-11默认用于连接外部Flash,不要作为普通GPIO使用。否则会导致程序无法正常启动。

来看一个最简单的LED闪烁代码:

// 定义LED引脚
#define LED_PIN 2

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

void loop() {
  digitalWrite(LED_PIN, HIGH);  // 点亮
  delay(500);                   // 保持500ms
  digitalWrite(LED_PIN, LOW);   // 熄灭
  delay(500);                   // 保持500ms
}

这段代码很简单,但有个细节:pinMode()必须在setup()里调用,否则引脚默认是输入模式,你写高低电平是没用的。嗯,这个我当年也犯过。

3.2 按键消抖:别让一次按下变成十次触发

按键输入是GPIO最典型的应用之一。但机械按键有个天生的毛病——抖动。你按下去的一瞬间,金属触点会弹跳几次,导致电平在几毫秒内反复变化。如果不处理,一次按下可能被误判成多次。

我见过一个项目,工程师没做消抖,结果用户按一下「开灯」,灯闪了三次才稳定。用户体验极差。

消抖有两种主流方式:

  • 硬件消抖:在按键两端并联一个0.1μF电容,利用电容的充放电特性滤除抖动。简单可靠,但增加BOM成本。
  • 软件消抖:检测到电平变化后,延时10-20ms再读取一次。如果状态一致,才认为是有效按下。零成本,但需要占用CPU时间。

我个人更推荐软件消抖,因为灵活。来看代码:

#define BUTTON_PIN 0  // 按键引脚(ESP32开发板上的BOOT按键)

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

void loop() {
  static int lastState = HIGH;
  static unsigned long lastDebounceTime = 0;
  const unsigned long debounceDelay = 20;  // 20ms消抖

  int currentState = digitalRead(BUTTON_PIN);

  if (currentState != lastState) {
    lastDebounceTime = millis();  // 记录变化时间
  }

  if ((millis() - lastDebounceTime) > debounceDelay) {
    // 稳定后的状态
    if (currentState == LOW) {
      Serial.println("按键按下");
    }
  }

  lastState = currentState;
}
💡 小技巧: 使用INPUT_PULLUP可以省去外部上拉电阻。ESP32内部有约45kΩ的上拉电阻,对于大多数按键场景足够了。

为什么用millis()而不是delay()?因为delay()会阻塞整个程序,你想想看,如果按键消抖期间有其他任务(比如读取传感器),程序就卡死了。millis()是非阻塞的,这才是专业写法。

3.3 PWM呼吸灯:用数字信号模拟模拟效果

PWM,脉冲宽度调制。说白了就是快速开关引脚,通过调整高电平占空比来模拟不同的电压。人眼有视觉暂留效应,只要频率够高(通常1kHz以上),看到的就是一个稳定的亮度。

ESP32的LEDC外设专门用于PWM输出,支持16个通道,频率和分辨率都可调。我习惯用8位分辨率(0-255),这样亮度变化比较平滑。

呼吸灯的效果就是让LED从暗到亮,再从亮到暗,循环往复。代码实现如下:

#define LED_PIN 2
#define PWM_CHANNEL 0
#define PWM_FREQ 5000    // 5kHz
#define PWM_RESOLUTION 8 // 8位分辨率

void setup() {
  ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RESOLUTION);
  ledcAttachPin(LED_PIN, PWM_CHANNEL);
}

void loop() {
  // 渐亮
  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(PWM_CHANNEL, duty);
    delay(10);
  }
  // 渐暗
  for (int duty = 255; duty >= 0; duty--) {
    ledcWrite(PWM_CHANNEL, duty);
    delay(10);
  }
}

这里有个小坑:ledcSetup()的第三个参数是分辨率位数,不是数值。8位分辨率对应的占空比范围是0-255,10位就是0-1023。分辨率越高,亮度变化越细腻,但PWM频率会降低。ESP32的LEDC时钟是80MHz,分辨率每增加1位,频率减半。

分辨率(位) 占空比范围 最大PWM频率 适用场景
8 0-255 312.5 kHz LED调光、电机调速
10 0-1023 78.1 kHz 高精度调光
12 0-4095 19.5 kHz 音频输出
16 0-65535 1.2 kHz 伺服电机控制

对于呼吸灯,8位分辨率足够了。频率选5kHz,人眼完全感觉不到闪烁。

3.4 ADC模拟量采集:把物理世界变成数字

ADC,模数转换器。把连续的模拟电压转换成离散的数字值。ESP32有两个12位的SAR ADC,理论上可以检测0-3.3V的电压,输出0-4095的数字值。

但注意,ESP32的ADC线性度不太好,尤其是靠近0V和3.3V的两端。我在项目中遇到过,读取光敏电阻时,暗处数值跳得厉害。后来加了软件滤波才稳定下来。

来看一个读取电位器的例子:

#define ADC_PIN 34  // ADC1通道6

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);  // 设置12位分辨率
}

void loop() {
  int raw = analogRead(ADC_PIN);          // 原始值 0-4095
  float voltage = raw * (3.3 / 4095.0);   // 转换为电压

  Serial.print("原始值: ");
  Serial.print(raw);
  Serial.print("  电压: ");
  Serial.print(voltage);
  Serial.println(" V");

  delay(500);
}
🔑 关键点: ESP32的ADC输入电压范围是0-3.3V。如果输入超过3.3V,会烧坏芯片。需要测量更高电压时,必须使用分压电阻。

ADC采集的数值往往有噪声,尤其是电源不干净的时候。我常用的软件滤波方法有:

  • 均值滤波:连续采样10次,取平均值。适合缓慢变化的信号。
  • 中值滤波:采样3-5次,取中间值。能有效去除毛刺。
  • 滑动平均:维护一个队列,每次新数据入队,旧数据出队,计算队列平均值。实时性好。

举个例子,用滑动平均滤波:

#define FILTER_SIZE 10
int filterBuffer[FILTER_SIZE];
int filterIndex = 0;

int adcFilteredRead(int pin) {
  filterBuffer[filterIndex] = analogRead(pin);
  filterIndex = (filterIndex + 1) % FILTER_SIZE;

  long sum = 0;
  for (int i = 0; i < FILTER_SIZE; i++) {
    sum += filterBuffer[i];
  }
  return sum / FILTER_SIZE;
}

嗯,这里要注意:analogRead()本身需要约100μs的转换时间,如果频繁调用会影响程序响应。我一般把ADC读取放在定时中断里,或者用millis()控制采样间隔。

3.5 综合实战:智能灯控节点

把今天学的串起来,做一个简单的智能灯控节点:

  • 按键控制开关(带消抖)
  • 电位器调节亮度(PWM呼吸灯)
  • 光敏电阻检测环境光(ADC采集)

代码框架如下:

#define LED_PIN 2
#define BUTTON_PIN 0
#define POT_PIN 34
#define LIGHT_PIN 35

bool ledState = false;
int brightness = 0;

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  ledcSetup(0, 5000, 8);
  ledcAttachPin(LED_PIN, 0);
  Serial.begin(115200);
}

void loop() {
  // 按键检测(消抖)
  if (debounceButton() == PRESSED) {
    ledState = !ledState;
    digitalWrite(LED_PIN, ledState ? HIGH : LOW);
  }

  // 读取电位器,映射到PWM占空比
  int potValue = analogRead(POT_PIN);
  brightness = map(potValue, 0, 4095, 0, 255);
  ledcWrite(0, brightness);

  // 读取环境光
  int lightLevel = analogRead(LIGHT_PIN);
  Serial.print("环境光: ");
  Serial.println(lightLevel);

  delay(50);
}

这个例子虽然简单,但已经具备了智能家居节点的基本要素:输入(按键、传感器)、输出(LED)、控制逻辑。你想想看,如果把LED换成继电器,把光敏电阻换成温湿度传感器,这不就是一个智能开关面板的原型吗?

好了,这一章的内容就到这里。GPIO、PWM、ADC这三个基础外设,是嵌入式开发的「三件套」。下一章我们会把这些东西跟无线通信结合起来,真正开始搭建智能家居网关。