3、GPIO基础与LED控制

各位同学,今天我们来聊聊ESP32上最基础、也最实用的东西——GPIO控制。说白了,就是让芯片的引脚听话,该高就高,该低就低。

我记得刚入行那会儿,总觉得GPIO太简单,不就是输出个0和1嘛。直到有一次做量产项目,几百块板子点不亮LED,排查了三天才发现是GPIO初始化顺序搞错了。嗯,从那以后我再也不敢小看这玩意儿了。

3.1 GPIO寄存器模型

ESP32的GPIO,底层是靠寄存器来控制的。你想想看,芯片内部就是一堆开关和触发器,我们写代码其实就是去拨弄这些开关。

每个GPIO引脚对应一组寄存器,主要分三类:

  • 方向寄存器:决定引脚是输入还是输出
  • 输出寄存器:决定引脚输出高电平还是低电平
  • 输入寄存器:读取引脚当前的电平状态

在ESP32的HAL库中,这些寄存器被封装成了函数。比如 gpio_set_direction() 就是设置方向,gpio_set_level() 就是设置输出值。我个人习惯直接调用这些封装好的API,除非做底层驱动优化,否则没必要直接操作寄存器地址。

核心概念:GPIO的寄存器模型可以理解为「方向+电平」两个维度。方向决定你是说话还是听话,电平决定你说的是0还是1。

3.2 数字输出模式配置

配置一个GPIO为输出模式,其实就三步。我在项目中教新人时,经常说「三步走,不会丢」:

  1. 选择引脚:确定你要用哪个GPIO,比如GPIO2
  2. 设置方向:告诉芯片这个引脚要输出
  3. 设置电平:输出高或低

代码写出来是这样的:

#include <driver/gpio.h>

#define LED_GPIO  GPIO_NUM_2

void led_init(void) {
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(LED_GPIO, 0);  // 初始化为低电平,灯灭
}

这里有个坑,我曾经踩过——初始化时一定要先设置方向,再设置电平。顺序反了,有些芯片会输出不确定的电平,导致LED闪一下才灭。嗯,量产时这种问题最头疼。

小技巧:如果你想让LED默认熄灭,初始电平设为0。如果想让LED默认点亮,设为1。但多数场景下,我们习惯低电平灭、高电平亮。

3.3 LED闪烁程序(Blink)

Blink是嵌入式界的「Hello World」。但你别小看它,这里面藏着延时和时序的基本功。

最简单的写法,用 delay()

void loop() {
    gpio_set_level(LED_GPIO, 1);  // 亮
    delay(1000);                  // 等1秒
    gpio_set_level(LED_GPIO, 0);  // 灭
    delay(1000);                  // 等1秒
}

这段代码能跑,但问题很大。你想想看,delay() 期间CPU在干嘛?在空转!它什么事都干不了,按键检测、传感器读取全部停摆。我在做智能家居网关时,就因为这个原因导致设备响应延迟,用户按了开关要等两秒才有反应。

警告delay() 是阻塞的,它会暂停整个程序。在真实项目中,除非是调试阶段,否则尽量别用。

3.4 使用millis()实现非阻塞延时

那怎么办?用 millis()。这个函数返回芯片上电以来经过的毫秒数,我们可以用它来「计时」,而不是「等待」。

核心思路是这样的:

  • 记录上一次切换LED的时间
  • 每次循环检查当前时间是否到了切换点
  • 到了就切换,没到就继续干别的事

代码实现:

unsigned long previousMillis = 0;
const long interval = 1000;  // 1秒间隔

void loop() {
    unsigned long currentMillis = millis();
    
    if (currentMillis - previousMillis >= interval) {
        previousMillis = currentMillis;
        
        // 翻转LED状态
        int level = gpio_get_level(LED_GPIO);
        gpio_set_level(LED_GPIO, !level);
    }
    
    // 这里可以放其他任务,比如按键检测
    // check_button();
}

你看,CPU不再空转了。每次循环只是做个减法比较,几乎不花时间。剩下的算力可以去做其他事情。说白了,这就是「时间分片」的思想。

关键点millis() 返回的是 unsigned long 类型,大约49天会溢出归零。但不用担心,减法比较在溢出时依然正确——这是无符号整数的特性。

3.5 多LED流水灯效果

掌握了非阻塞延时,流水灯就简单了。流水灯的本质就是「多个LED按顺序亮灭」,每个LED的亮灭时间错开。

我做过一个智能灯带项目,用了8个LED模拟跑马灯效果。核心逻辑是这样的:

  1. 定义一个LED引脚数组
  2. 用一个变量记录当前亮着的LED索引
  3. 每隔固定时间,熄灭当前LED,点亮下一个

代码示例:

#define LED_COUNT 4
const gpio_num_t led_pins[LED_COUNT] = {GPIO_NUM_2, GPIO_NUM_4, GPIO_NUM_5, GPIO_NUM_18};

int current_led = 0;
unsigned long last_toggle = 0;
const int interval = 200;  // 200毫秒切换一次

void setup() {
    for (int i = 0; i < LED_COUNT; i++) {
        gpio_set_direction(led_pins[i], GPIO_MODE_OUTPUT);
        gpio_set_level(led_pins[i], 0);
    }
}

void loop() {
    unsigned long now = millis();
    
    if (now - last_toggle >= interval) {
        last_toggle = now;
        
        // 熄灭当前LED
        gpio_set_level(led_pins[current_led], 0);
        
        // 切换到下一个
        current_led = (current_led + 1) % LED_COUNT;
        
        // 点亮新的LED
        gpio_set_level(led_pins[current_led], 1);
    }
}

这里用 % 取模运算实现循环,当索引走到头自动回到0。嗯,这个技巧在很多场景都能用,比如循环队列、环形缓冲区。

扩展思路:你可以把间隔时间改成变量,用按键调节速度。或者让LED从两端向中间亮,效果更炫酷。我在产品演示时经常用这种效果吸引客户眼球。

好了,这一章的内容就到这里。GPIO控制看似简单,但它是所有外设控制的基础。你想想看,从LED到继电器,从蜂鸣器到电机,底层都是这套「方向+电平」的逻辑。把基础打牢,后面学I2C、SPI这些复杂协议时,你会轻松很多。