3、GPIO与基础外设:GPIO输入输出、按键消抖、PWM呼吸灯、ADC模拟量采集
好,咱们进入第三讲。这一章是嵌入式开发的「基本功」,说白了就是让芯片跟外界打交道的最底层手段。GPIO、PWM、ADC,这三个东西你玩熟了,智能家居里一大半的传感器和执行器你都能搞定。
我个人习惯把这一章叫做「感官与肌肉」——GPIO是手指头,PWM是呼吸,ADC是眼睛。嗯,咱们一个一个来拆解。
3.1 GPIO输入输出:最基础的数字信号
GPIO,全称General Purpose Input Output,通用输入输出口。每个引脚可以独立配置成输入或输出模式。输出就是写高低电平,输入就是读高低电平。
在ESP32上,大部分引脚都支持GPIO功能。但要注意,有些引脚有特殊用途(比如接Flash的引脚),不能随便用。我刚开始做项目时,就踩过这个坑——把GPIO6当普通IO用,结果程序死活跑不起来。
来看一个最简单的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);
}
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这三个基础外设,是嵌入式开发的「三件套」。下一章我们会把这些东西跟无线通信结合起来,真正开始搭建智能家居网关。