2. ESP32 基础外设控制:GPIO 数字输入输出、PWM 调光、ADC 模拟量采集、中断机制与回调函数

好,咱们直接进入正题。ESP32 的外设控制,是后续所有交互和通信的基础。你想想看,无论是读取传感器、控制电机,还是驱动 LED 灯带,最终都要落到这些基础操作上。

我个人习惯把这一章叫做「硬件的敲门砖」。搞懂了 GPIO、PWM、ADC 和中断,你基本就能和物理世界对话了。咱们一个一个来。

2.1 GPIO 数字输入输出:最基础的「0」和「1」

GPIO 就是通用输入输出引脚。说白了,就是让芯片的某个引脚输出高电平(3.3V)或低电平(0V),或者读取外部信号是高还是低。

在 ESP32 上,大部分引脚都可以作为 GPIO 使用。但要注意,有些引脚有特殊功能(比如接 Flash 的引脚),不能乱用。

⚠️ 避坑指南: 我曾经在项目里直接用 GPIO6~GPIO11 做普通 IO,结果程序死活跑不起来。后来查手册才发现,这些引脚内部接了 Flash,不能当普通 GPIO 用。切记,GPIO6-11 是禁区。

2.1.1 输出模式:点亮一个 LED

先来个最简单的,点亮板载 LED。ESP32 开发板上通常有一个 GPIO2 连接的 LED。

// 配置 GPIO2 为输出模式
pinMode(2, OUTPUT);

// 点亮 LED
digitalWrite(2, HIGH);

// 熄灭 LED
digitalWrite(2, LOW);

就这么简单。但实际项目中,我建议你养成一个好习惯:用宏定义给引脚起个名字。

#define LED_BUILTIN 2

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}

2.1.2 输入模式:读取按键状态

读取按键输入,需要配置为 INPUT 模式。但这里有个坑:浮空输入。

如果引脚什么都没接,读取到的电平是随机的。所以,我们需要上拉或下拉电阻。

ESP32 内部有上拉电阻,可以直接启用:

#define BUTTON_PIN 0

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

void loop() {
  int val = digitalRead(BUTTON_PIN);
  Serial.println(val);
  delay(100);
}

注意,启用内部上拉后,按键没按下时读到的是 HIGH,按下后是 LOW。逻辑是反的,写代码时心里要有数。

💡 我的经验: 如果按键接长线,外部干扰大,内部上拉可能不够稳。我一般会外接一个 10kΩ 的上拉电阻,更可靠。

2.2 PWM 调光:用数字信号模拟模拟量

PWM,脉冲宽度调制。说白了,就是快速开关引脚,通过调整高电平占整个周期的比例(占空比),来模拟不同的电压。

ESP32 的 PWM 比 Arduino 强多了。它有独立的 LEDC 控制器,可以设置频率、分辨率,还能绑定到任意 GPIO。

2.2.1 配置 PWM 通道

ESP32 的 PWM 分三步:配置通道、绑定引脚、设置占空比。

#define LED_PIN 2
#define PWM_CH 0
#define PWM_FREQ 5000
#define PWM_RES 8

void setup() {
  ledcSetup(PWM_CH, PWM_FREQ, PWM_RES);
  ledcAttachPin(LED_PIN, PWM_CH);
}

void loop() {
  // 从暗到亮
  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(PWM_CH, duty);
    delay(10);
  }
}

这里的分辨率 8 位,意味着占空比范围是 0~255。频率 5000Hz,对人眼来说已经看不到闪烁了。

参数 说明 常用值
频率 PWM 信号的开关速度 LED 调光用 1kHz~5kHz
分辨率 占空比的精细程度 8 位(0~255)或 10 位(0~1023)
通道数 ESP32 最多 16 个独立通道 够用
📌 关键点: 不同频率的 PWM 不能共用同一个通道。比如你要控制舵机(50Hz)和 LED(5kHz),得用两个通道。

2.3 ADC 模拟量采集:读取真实世界的电压

ADC 把模拟电压转换成数字值。ESP32 有两个 ADC 模块,ADC1 和 ADC2。ADC2 在 Wi-Fi 开启时会被占用,所以一般都用 ADC1。

2.3.1 基本读取

#define ADC_PIN 34

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

void loop() {
  int val = analogRead(ADC_PIN);
  float voltage = val * (3.3 / 4095.0);
  Serial.print("ADC Value: ");
  Serial.print(val);
  Serial.print(" Voltage: ");
  Serial.println(voltage);
  delay(100);
}

12 位分辨率,范围 0~4095。对应电压 0~3.3V。

⚠️ 注意: ESP32 的 ADC 线性度不太好。尤其是靠近 0V 和 3.3V 的两端,误差比较大。我曾经用 ADC 测电池电压,发现低电量时读数偏大,差点导致设备没电了还没报警。后来加了软件校准才解决。

2.3.2 衰减模式

如果你要测超过 3.3V 的电压,可以用衰减模式。ESP32 支持 0dB、2.5dB、6dB、11dB 四档衰减。

analogSetAttenuation(ADC_11db);  // 最大可测约 3.6V

但说实话,衰减模式会降低精度。我建议还是用分压电阻把电压降到 3.3V 以内。

2.4 中断机制与回调函数:让系统响应更及时

轮询(Polling)是傻等,中断(Interrupt)是通知。你想想看,如果 CPU 一直在循环里检查按键是否按下,那它什么都干不了。

中断就是:让 CPU 该干嘛干嘛,一旦有事件发生,立刻跳转去处理。

2.4.1 GPIO 中断

ESP32 支持所有 GPIO 的中断。可以配置上升沿、下降沿、或者电平触发。

volatile int interruptCounter = 0;

void IRAM_ATTR handleInterrupt() {
  interruptCounter++;
}

void setup() {
  pinMode(0, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(0), handleInterrupt, FALLING);
  Serial.begin(115200);
}

void loop() {
  if (interruptCounter > 0) {
    noInterrupts();
    int count = interruptCounter;
    interruptCounter = 0;
    interrupts();
    Serial.print("Interrupt count: ");
    Serial.println(count);
  }
}

这里有几个关键点:

  • IRAM_ATTR:把中断函数放到 IRAM 中,保证执行速度。
  • volatile:告诉编译器这个变量可能被中断修改,不要优化掉。
  • noInterrupts()/interrupts():在读取共享变量时关中断,防止数据竞争。
💡 我的习惯: 中断函数里只做最少的操作,比如设置一个标志位或计数器。具体的处理逻辑放到 loop() 里做。中断里做 delay() 或者 Serial.print() 是大忌,会导致系统卡死。

2.4.2 定时器中断

除了 GPIO 中断,定时器中断也很常用。比如你要精确控制采样频率。

hw_timer_t *timer = NULL;
volatile bool timerFlag = false;

void IRAM_ATTR onTimer() {
  timerFlag = true;
}

void setup() {
  timer = timerBegin(0, 80, true);  // 定时器0,预分频80,向上计数
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000000, true);  // 1秒中断一次
  timerAlarmEnable(timer);
}

void loop() {
  if (timerFlag) {
    timerFlag = false;
    Serial.println("Timer fired!");
  }
}

预分频 80,是因为 ESP32 的主频是 80MHz。80 分频后,计数器每 1 微秒加 1。设置报警值 1000000,就是 1 秒。

2.5 本章知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。

ESP32 基础外设控制知识体系 ESP32 外设控制 GPIO 数字 I/O PWM 调光 ADC 模拟采集 中断与回调 pinMode() digitalWrite() digitalRead() INPUT_PULLUP ledcSetup() ledcAttachPin() ledcWrite() 频率/分辨率 analogRead() analogReadResolution() analogSetAttenuation() 电压换算 attachInterrupt() IRAM_ATTR volatile 定时器中断 💡 核心原则:中断里只做标记,主循环里做处理

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

最后,咱们把今天学的知识串起来。做一个呼吸灯,用按键切换呼吸速度。

#define LED_PIN 2
#define BTN_PIN 0
#define PWM_CH 0
#define PWM_FREQ 5000
#define PWM_RES 8

volatile bool speedToggle = false;

void IRAM_ATTR btnISR() {
  speedToggle = !speedToggle;
}

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

void loop() {
  int delayTime = speedToggle ? 5 : 20;

  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(PWM_CH, duty);
    delay(delayTime);
  }
  for (int duty = 255; duty >= 0; duty--) {
    ledcWrite(PWM_CH, duty);
    delay(delayTime);
  }
}

这个例子把 GPIO 中断、PWM 调光、以及主循环逻辑都结合起来了。你按下按键,呼吸速度就会变化。

🎯 课后思考: 如果按键按下时,呼吸灯正好在变化中,会出现什么现象?怎么优化?嗯,这个问题留给你自己琢磨。