第三课 点亮 LED 与定时器非阻塞闪烁

新手完整版 · 每个名词先解释再使用,每段代码逐行说明,照着做就能看到效果。

!NOTE

本课是 ESP32 教程的第 3 课。第 1 课装好了环境、跑通了 Hello World,这一课我们真正开始"控制硬件":先点亮一颗 LED,再让它闪起来,最后用定时器实现"两个灯各闪各的、互不干扰"。

全程只需要一块开发板、一个 LED 模块、三根杜邦线。看不太懂的地方不要紧,先照着做,第 2 遍再回来读解释。

0. 读前必看

0.1 学完这一课,你会做这几件事

  1. 知道 GPIO 引脚 是什么,高电平 / 低电平到底是什么意思。
  2. 亲手点亮一颗 LED,再让它按节奏闪起来。
  3. 明白 delay() 为什么会"堵车",学会用 esp_timer 定时器做非阻塞闪烁。
  4. 让两个 LED 用不同频率同时闪,谁都不影响谁。
  5. 能读懂、能修改一份完整的 ESP-IDF 工程代码。

0.2 建议的学习方法(三遍法)

第几遍 做什么 目标
第一遍 照着教程抄代码、抄步骤,跑出效果 先建立信心,不纠结细节
第二遍 回来读"这段代码在做什么" 弄懂为什么这样写
第三遍 做第 6 章的练习题,改参数看变化 把知识变成自己的

0.3 开始之前,请确认

  • 已完成第 1 课,VS Code 里 ESP-IDF 扩展已经装好,Hello World 能编译、能烧录、串口能看到输出。
  • 电脑上装有 VS Code,ESP-IDF 版本为 v6.1.0(其他 v5.x 版本也能跑,界面略有差别)。
  • 手上有 ESP32-P4 开发板(本教程使用 WT01P461-S1 模块的开发板)、三色 LED 模块、杜邦线、USB 数据线。
  • 心态准备:第一次编译要几分钟,属于正常现象,不是卡死了。

!WARNING

接线、拔线请先拔掉 USB 供电,再动手。GPIO 引脚不能直接接 3.3V 或 5V,否则可能烧坏芯片。

1. 实验准备

1.1 硬件清单

名称 数量 说明
ESP32-P4 开发板 1 块 本教程使用 GPIO4、GPIO5 两个引脚
三色 LED 模块 1 个 4 个引脚:G、Y、R、GND(丝印顺序可能不同,认字母即可)
杜邦线(母对母) 3 根 红、黄、黑各一根,方便对照接线表
USB Type-C 数据线 1 根 必须是能传数据的线,只能充电的线烧录会失败
电脑 1 台 Windows 10 / 11,已装好 ESP-IDF 与 VS Code

!TIP

三色 LED 模块内部已经自带限流电阻,不用自己再串电阻。如果你用的是单独一颗裸 LED,就必须串一个 220Ω~1kΩ 的电阻,否则会烧坏 LED 或引脚。

1.2 软件准备

不需要新装任何东西,直接复用第 1 课的环境:

  1. 打开 VS Code,点左侧的 ESP-IDF 图标,确认扩展已加载。
  2. 打开第 1 课创建的 hello_world 工程(或者任意一个能编译的工程)。
  3. 底部工具栏里确认三项:串口(COM 号) 、目标芯片(esp32p4) 、烧录方式(UART)。

1.3 三个必须懂的小概念

先花三分钟把这三个词搞明白,后面的代码就都是"人话"了。

1.3.1 GPIO 是什么

GPIO = General Purpose Input/Output,通用输入输出引脚。

把它想成芯片身上的一个个小水龙头:

  • 你可以让它出水(输出高电平,3.3V)------这就是"输出模式";
  • 也可以让它关水(输出低电平,0V);
  • 还可以反过来感受外面有没有水压(输入模式,比如读按键)。

ESP32-P4 有几十个这样的引脚,开发板边缘的丝印 GPIO4、GPIO5、GPIO0 就是它们的名字。本课我们只用到最简单的用法:让引脚输出电压,去点亮 LED。

1.3.2 高电平、低电平与 LED 亮灭

LED 是发光二极管,有正极(长脚 / 模块上标注的那一端)和负极 。电流只能从正极流向负极,一旦流过去,它就发光。

所以让 LED 亮起来的完整条件是------电流要走一个圈:

text 复制代码
ESP32 的 GPIO4(输出 3.3V)──→ LED 正极 ──→ LED 负极 ──→ GND(0V,地)
  • 高电平(1) :引脚输出 3.3V,和 GND 之间有电压差,电流流过,灯亮;
  • 低电平(0) :引脚输出 0V,两端没有电压差,没有电流,灯灭。

!IMPORTANT

这就是为什么接线表里必须有一根黑色线接 GND。没有 GND,电流回不去,灯怎么都不会亮,这也是新手最常犯的错误。

市面上也有"低电平点亮"的模块(模块内部做了反相)。判断方法很简单:写 1 不亮就写 0 试试,看第 7 章 FAQ 第 1 条。

1.3.3 阻塞与非阻塞:本课最重要的概念

假设你要煮面,需要等 3 分钟。

  • 阻塞式的等法 :你站在锅边死死盯着计时器,3 分钟里什么都不干,后面的活全得排队等。这 3 分钟你"被占住了"------这就是 delay()。
  • 非阻塞式的等法 :你定一个闹钟,然后去扫地、回消息,闹钟响了再回来关火。等待期间你照样能干别的------这就是定时器。

放到程序里:

对比项 阻塞式 delay() / vTaskDelay() 非阻塞式定时器 esp_timer
程序往下走吗 停在等待这一行,等够了才继续 立刻往下走,一秒都不等
等待期间能读按键 / 收串口吗 不能(你的代码停住了) 能
两个灯不同频率同时闪 很难做到 很轻松
代码直观程度 简单 稍微复杂一点

!TIP

一句话记住:delay() 是"我不动了,大家等我";定时器是"我先去忙,到点叫我"。

后面你会发现,凡是需要"同时做几件事"的项目(按键 + 传感器 + 屏幕 + 联网),都必须走非阻塞这条路。

2. 实验一:点亮一颗 LED(常亮)

先别急着闪,第一步只做一件事:让灯亮起来。灯亮了,说明接线、端口、烧录全都正常,后面再出问题就只可能是代码。

2.1 接线

断电(拔掉 USB)状态下接线:

图中线路颜色 接到开发板 接到 LED 模块 作用
红色线 GPIO4 R(红色 LED) 给红灯供电
黄色线 GPIO5 Y(黄色 LED) 给黄灯供电
黑色线 GND GND 共地,电流回路必需

本实验一暂时只用红色线 (GPIO4 → R)和黑色线 (GND)。黄色线(Y)这一课不用,先放着。

!NOTE

图上 LED 模块的丝印从上到下是 G / Y / R / GND ,不同批次模块的丝印顺序可能不一样,以字母为准:R 对应红色灯,Y 对应黄色灯,GND 接地。

2.2 新建(或复用)工程

  1. 用第 1 课的方法新建一个 Hello World 工程,项目名可以叫 LED_Blink(用英文,不要有空格和中文)。
  2. 使用课程提供的 LED_Blink 工程压缩包,解压到纯英文路径 下,用 VS Code 直接打开工程文件夹也可以。(LED_Blink项目下载)
  3. 在左侧资源管理器里打开 main 文件夹,双击 hello_world_main.c(新建工程时文件名可能是 main.c 或 led_blink.c,都可以)。
  4. 按 Ctrl + A 全选,按 Delete 删掉模板代码,把下面这段代码粘贴进去,然后 Ctrl + S 保存。

2.3 第一份代码:十几行点亮一颗灯

c 复制代码
#include <driver/gpio.h>

#define LED1_GPIO GPIO_NUM_4        // 给 GPIO4 起个外号,叫 LED1_GPIO

void app_main(void)                 // 程序的入口,上电后从这里开始执行
{
    gpio_config_t io_conf = {       // 先填一张"配置表"
        .pin_bit_mask = (1ULL << LED1_GPIO),   // 选择要配置哪个引脚
        .mode = GPIO_MODE_OUTPUT,              // 设为输出模式(要往外送电)
        .pull_up_en = GPIO_PULLUP_DISABLE,     // 不用内部上拉
        .pull_down_en = GPIO_PULLDOWN_DISABLE, // 不用内部下拉
        .intr_type = GPIO_INTR_DISABLE,        // 不用中断
    };
    gpio_config(&io_conf);          // 提交配置表,引脚就设置好了

    gpio_set_level(LED1_GPIO, 1);   // 输出高电平 3.3V,灯亮!
}

粘贴完代码后,打开main文件夹下面的CMakeLists.txt,增加PRIV_REQUIRES esp_driver_gpio,否则代码就会报错,如图:

(在旧版 ESP-IDF 中,driver 组件曾被作为"公共依赖"自动添加到所有组件中,因此即使不声明也能编译链接。从 ESP-IDF v5.0 开始,这一默认行为已被移除,所有组件都必须显式声明自己的依赖。特别是 GPIO 驱动,现在已从旧的 driver 组件中独立为 esp_driver_gpio 组件。因此,必须通过 PRIV_REQUIRES esp_driver_gpio 来明确告诉构建系统:"我的组件需要这个独立的 GPIO 驱动。")

逐行解释

代码 它在做什么 小白提示
#include <driver/gpio.h> 引入 GPIO 驱动,这样才认识 gpio_config 等函数 相当于"调用工具前先拿工具"
#define LED1_GPIO GPIO_NUM_4 给编号 4 的引脚起别名 以后想换引脚,只改这一行
void app_main(void) ESP-IDF 的程序入口 相当于电脑程序的 main(),不要改名
gpio_config_t io_conf = { ... } 定义一个结构体变量,用来装配置 结构体就是一串"配置项"的打包
.pin_bit_mask = (1ULL << 4) 把第 4 位设为 1,表示"配置 4 号引脚" 1ULL 是 64 位无符号数,<< 是左移;想同时配 4 和 5 就写成 `(1ULL << 4)
.mode = GPIO_MODE_OUTPUT 设置成输出模式 要驱动 LED 必须选输出
pull_up_en / pull_down_en 内部上拉、下拉电阻开关 只有做输入(读按键)时才需要打开
.intr_type = GPIO_INTR_DISABLE 关掉引脚中断 只输出电平时用不到中断
gpio_config(&io_conf) 把配置真正写入硬件 注意 &:把变量地址交给函数
gpio_set_level(LED1_GPIO, 1) 让该引脚输出高电平 第二个参数 1 是高电平,0 是低电平

2.4 编译、烧录、看效果

  1. 把 USB 线插到开发板的 FUSB 口(调试 / 下载口,不是 HUSB),另一端插电脑。
  2. 看 VS Code 底部工具栏:③ 位置选择正确的 COM 口(插拔一次看哪个出现的通常就是它);④ 位置确认为 esp32p4。
  3. 点击底部的 ⚒ 编译(Build) ,等待右下角提示 Build complete。第一次编译要花几分钟。
  4. 点击 ⚡ 烧录(Flash) ,弹出烧录方式时选 UART ,等待 Flash done。
  5. 看板子:红色 LED 常亮 。灯亮了,实验一成功。

如果想让板子立刻运行新程序,烧录完成后按一下板子上的 RST / EN 复位键(多数情况下烧录会自动复位)。

2.5 灯不亮?按顺序排查三步

现象 最可能的原因 怎么处理
完全不亮 模块是"低电平点亮"型 把 gpio_set_level(LED1_GPIO, 1) 改成 0 再烧录一次
完全不亮 GND 线没接 / 接错引脚 检查黑线是否接在模块 GND 与开发板 GND 上
完全不亮 烧录没成功,板子还在跑旧程序 点 🖥 监视(Monitor) 看有没有输出,重新烧录
一直亮、改代码没反应 改完没重新编译烧录 一定先 ⚒ 编译再 ⚡ 烧录
只亮一下就灭 程序跑完退出了 本课后面都写在 while(1) 或定时器里,不会自己结束

3. 实验二:用 delay() 让它闪起来

灯常亮没意思,我们让它亮 0.5 秒、灭 0.5 秒,闪起来。

3.1 代码

c 复制代码
#include <driver/gpio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>          // vTaskDelay 在这个头文件里

#define LED1_GPIO GPIO_NUM_4

void app_main(void)
{
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << LED1_GPIO),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    gpio_config(&io_conf);

    while (1) {                                  // 死循环,让程序永远不结束
        gpio_set_level(LED1_GPIO, 1);            // 亮
        vTaskDelay(pdMS_TO_TICKS(500));          // 等 500 毫秒
        gpio_set_level(LED1_GPIO, 0);            // 灭
        vTaskDelay(pdMS_TO_TICKS(500));          // 再等 500 毫秒
    }
}

3.2 新出现的三样东西

代码 它在做什么 小白提示
while (1) { ... } 无限循环,反复执行大括号里的内容 嵌入式程序通常"一辈子不退出"
vTaskDelay(...) 让当前任务暂停指定的时间 在这段时间里,这个任务什么都不做(这就是阻塞)
pdMS_TO_TICKS(500) 把 500 毫秒换算成系统节拍数 ESP-IDF 默认 1 秒 = 1000 个节拍,所以 500 ms = 500 节拍

!NOTE

为什么不用 delay(500)?因为 delay 是 Arduino 的写法。ESP-IDF 里用的是 FreeRTOS,正确写法是 vTaskDelay(),单位是"节拍",所以配一个 pdMS_TO_TICKS() 做换算,这也是官方示例的标准写法。

3.3 现象

红灯亮 0.5 秒、灭 0.5 秒,一秒一个完整循环,看起来就是"闪一下、停一下"。

3.4 问题来了:我想让两个灯同时闪,怎么办?

黄灯也要闪,而且要和红灯不一样 的频率(比如 0.2 秒闪一次)。用 delay() 的写法,你只能这样:

c 复制代码
// 错误示范:这样写,两个灯根本做不到"各闪各的"
while (1) {
    gpio_set_level(LED1_GPIO, 1);
    vTaskDelay(pdMS_TO_TICKS(500));   // 这 500 ms 里,黄灯代码根本没机会执行
    gpio_set_level(LED1_GPIO, 0);
    vTaskDelay(pdMS_TO_TICKS(500));
    // 黄灯的代码挤不进来:必须等上面两份 delay 走完才轮到它
}

卡在哪里? vTaskDelay 期间,这段代码是"停在原地"的。红灯的 500 ms 里,黄灯要闪两次半,可程序根本走不到黄灯的代码。你可能会想"那我把两段代码拆成很碎的 if 判断"------那就等于自己手写一个简陋的定时器,又难读又容易出错。

正确的做法:把"什么时候该翻转"这件事交给定时器,程序主体一秒都不用等。这就是实验三。

4. 实验三:非阻塞闪烁(esp_timer 定时器)

4.1 定时器到底是什么

定时器 = 一个会自动看表的助手。

你告诉它两件事:

  1. 多久叫我一次(周期,比如 500 毫秒);
  2. 到点了干什么事 (要执行的函数,专业叫法是"回调函数 callback")。

之后你就可以去写别的代码了。时间一到,助手自动跑过去执行你交代的那段代码,执行完再继续计时。

ESP32 的 esp_timer 内部用一个硬件计时器 + 一个系统任务来管理,精度到微秒 ,比 vTaskDelay 精细得多,而且不占用你的代码时间。

对比项 vTaskDelay(阻塞) esp_timer(非阻塞)
谁是"看表的人" 你的代码自己盯着 系统定时器服务
等待时你的代码 停住不动 继续往下跑
同时管几个节奏 一个一个排队 每个定时器各管各的,互不干扰
适合场景 只是单纯想等一下 多任务、按键扫描、周期性采集

4.2 分四步写代码

我们把实验三拆成四小步,每一步都能看懂再往下走。

第一步:定义"一个灯"的数据
c 复制代码
typedef struct {
    gpio_num_t gpio;    // 这盏灯接在哪个引脚
    int level;          // 这盏灯当前的状态:0=灭,1=亮
} led_t;
代码 意思
typedef struct { ... } led_t; 定义一种"自定义数据类型",名字叫 led_t(t 表示 type)
gpio_num_t gpio; 存引脚号,比如 GPIO_NUM_4
int level; 存当前电平,0 或 1

为什么要定义一个结构体? 因为定时器的回调函数只能接收一个 参数(void *arg)。要把"接在哪个引脚"和"当前亮不亮"两件事一起交给回调,就必须把它们打包成一个整体。以后加第三盏、第四盏灯,也只需要多写一行 led_t。

第二步:创建两盏灯,并写好"到点要做的事"
c 复制代码
static led_t led1 = { .gpio = LED1_GPIO, .level = 0 };   // 红灯:GPIO4,初始为灭
static led_t led2 = { .gpio = LED2_GPIO, .level = 0 };   // 黄灯:GPIO5,初始为灭

static void led_timer_callback(void *arg)   // 到点后自动调用这个函数
{
    led_t *led = (led_t *)arg;              // 把参数还原成"灯的指针"
    led->level = !led->level;               // 状态取反:亮变灭,灭变亮
    gpio_set_level(led->gpio, led->level);  // 让这盏灯的引脚输出新状态
}
代码 它在做什么 小白提示
static led_t led1 = { ... } 定义一盏灯并初始化 static 让它常驻内存,函数结束了也不会被销毁
.gpio = LED1_GPIO 指定引脚 这就是"指定字段初始化"的写法,比按顺序赋值清楚
.level = 0 初始状态为灭 想让它先亮就写 1
static void led_timer_callback(void *arg) 回调函数的固定长相 返回 void,参数一个 void *,名字可以自己起
led_t *led = (led_t *)arg; 把 void * 强转回 led_t * void * 是"万能指针",用之前要还原类型,否则编译器不知道 arg 里装的是什么
led->level = !led->level; ! 是逻辑取反:0→1、1→0 -> 表示"通过指针访问成员",等价于 (*led).level
gpio_set_level(led->gpio, led->level) 输出电平 这一行真正让灯亮 / 灭
第三步:告诉系统"我要一个定时器"
c 复制代码
const esp_timer_create_args_t t1_args = {
    .callback = &led_timer_callback,   // 到点执行哪个函数
    .arg = &led1,                      // 把这个参数传给它(红灯)
    .name = "led1"                     // 定时器名字,调试时方便区分
};
参数 含义 说明
.callback 回调函数地址 就是第二步写的函数,注意前面有 &
.arg 传给回调的参数 传 &led1,回调里的 arg 就指向红灯
.name 定时器名称 只是给调试看的,最长 16 个字符
.dispatch_method 回调在哪个上下文执行 一般不用写,默认在系统定时器任务中执行

然后创建它,拿到"句柄":

c 复制代码
esp_timer_handle_t t1, t2;                        // 句柄:以后用它控制这个定时器
ESP_ERROR_CHECK(esp_timer_create(&t1_args, &t1)); // 创建 1 号定时器(红灯)
ESP_ERROR_CHECK(esp_timer_create(&t2_args, &t2)); // 创建 2 号定时器(黄灯)

!TIP

**句柄(handle)**就像遥控器:定时器本身在系统里,你手上拿着 t1 才能启动它、停止它、删除它。ESP_ERROR_CHECK() 是个保险:创建失败就直接报错停机,免得你继续往下跑却不知道为什么没反应。

第四步:启动定时器,设置周期
c 复制代码
ESP_ERROR_CHECK(esp_timer_start_periodic(t1, 500000));  // 红灯每 500 ms 翻转一次
ESP_ERROR_CHECK(esp_timer_start_periodic(t2, 200000));  // 黄灯每 200 ms 翻转一次

这里最容易踩坑:单位是微秒(μs),不是毫秒!

想表达的周期 要写的数字 换算
1 秒 1000000 1 s = 1 000 ms = 1 000 000 μs
0.5 秒(500 毫秒) 500000 500 × 1000
0.2 秒(200 毫秒) 200000 200 × 1000
0.05 秒(50 毫秒) 50000 50 × 1000

!WARNING

把 500 毫秒写成 500,实际周期是 0.5 毫秒,人眼看起来就是"常亮"或"微微暗一点"。这是新手最常见的 bug,出现"灯不闪"先检查这里。

esp_timer_start_periodic 的意思是"周期性地 启动":每过这么久就调用一次回调,永不停歇。对应的还有 esp_timer_start_once(只执行一次)。

注意"翻转周期"和"闪烁一次"不是一回事:

  • 每 500 ms 翻转一次 :亮 500 ms、灭 500 ms,一个完整循环是 1 秒(因为翻转两次才算一个完整周期)。第一次看这个数字容易绕晕,记住"翻转"就是"开关互换"就行。

4.3 为什么两个灯可以各闪各的

想想生活里的两个闹钟:

  • 闹钟 A 每 30 分钟响一次,闹钟 B 每 12 分钟响一次;
  • 它们在墙上各自走各自的表,互不干扰;
  • 响的时候你去执行"该做的事",做完继续做原来在做的事。

esp_timer 就是这样:t1 和 t2 是两个独立的定时器,各自计时、各自触发回调。回调函数执行得非常快(只有三行,微秒级),执行完立刻把 CPU 还给你的主程序,所以看起来就是"两个灯同时以不同频率闪"。

这正是**单核芯片也能"并发"**的诀窍:不是 CPU 同时干两件事,而是谁都不许长时间占着 CPU。

4.4 回调函数的纪律(很重要)

定时器的回调是在系统的一个公共任务里执行的,所以:

  1. 绝对不要在回调里写 vTaskDelay()、while 长时间循环、printf 大量日志------你一卡,其他定时器就都跟着卡。
  2. 回调要短、快,一般几行代码搞定。它该做的是"记个标志位"或"翻转一下引脚",而不是"干重活"。
  3. 需要在回调外做耗时的工作,就在回调里设一个变量(标志位),让 app_main 里的循环去处理。

!NOTE

这和"中断服务函数要尽可能短"是同一个道理。记住一句话:回调里只做"很快能做完的事"。

5. 完整代码与运行结果

5.1 完整代码(可直接复制)

c 复制代码
#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <driver/gpio.h>
#include <esp_timer.h>

/* ---------- 1. 引脚定义:想换引脚只改这里 ---------- */
#define LED1_GPIO GPIO_NUM_4
#define LED2_GPIO GPIO_NUM_5

/* ---------- 2. 闪光周期:单位是微秒(1 秒 = 1000000 微秒)---------- */
#define LED1_PERIOD_US 500000   // 红灯 500 ms 翻转一次
#define LED2_PERIOD_US 200000   // 黄灯 200 ms 翻转一次

/* ---------- 3. 描述"一盏灯" ---------- */
typedef struct {
    gpio_num_t gpio;    // 引脚号
    int level;          // 当前电平:0=灭,1=亮
} led_t;

static led_t led1 = { .gpio = LED1_GPIO, .level = 0 };
static led_t led2 = { .gpio = LED2_GPIO, .level = 0 };

/* ---------- 4. 定时器到点后执行的回调 ---------- */
static void led_timer_callback(void *arg)
{
    led_t *led = (led_t *)arg;
    led->level = !led->level;               // 亮变灭、灭变亮
    gpio_set_level(led->gpio, led->level);  // 输出到引脚
}

void app_main(void)
{
    /* ---------- 5. 把两个引脚配置成输出模式 ---------- */
    gpio_config_t io_conf = {
        .intr_type = GPIO_INTR_DISABLE,                          // 不用中断
        .mode = GPIO_MODE_OUTPUT,                                // 输出模式
        .pin_bit_mask = (1ULL << LED1_GPIO) | (1ULL << LED2_GPIO), // 同时选中两个引脚
        .pull_down_en = GPIO_PULLDOWN_DISABLE,                   // 不用内部下拉
        .pull_up_en = GPIO_PULLUP_DISABLE,                       // 不用内部上拉
    };
    gpio_config(&io_conf);

    /* ---------- 6. 描述两个定时器的参数 ---------- */
    const esp_timer_create_args_t t1_args = {
        .callback = &led_timer_callback,   // 到点执行哪个函数
        .arg = &led1,                      // 传红灯给它
        .name = "led1"
    };
    const esp_timer_create_args_t t2_args = {
        .callback = &led_timer_callback,   // 同一个回调函数,也能服务第二盏灯
        .arg = &led2,                      // 传黄灯给它
        .name = "led2"
    };

    /* ---------- 7. 创建定时器 ---------- */
    static esp_timer_handle_t t1, t2;   // static:句柄要"活得久",别随函数结束消失
    ESP_ERROR_CHECK(esp_timer_create(&t1_args, &t1));
    ESP_ERROR_CHECK(esp_timer_create(&t2_args, &t2));

    /* ---------- 8. 周期启动:单位微秒 ---------- */
    ESP_ERROR_CHECK(esp_timer_start_periodic(t1, LED1_PERIOD_US));
    ESP_ERROR_CHECK(esp_timer_start_periodic(t2, LED2_PERIOD_US));

    /* 到这里 app_main 就可以结束了。
       定时器已经在系统里独立运行,两个灯会一直按各自节奏闪。 */
}

同样,这里我们也需要在CMakeLists.txt增加PRIV_REQUIRES esp_timer,如图:

5.2 编译与烧录

  1. 把完整代码全部替换到 main 目录下的 .c 文件里,Ctrl + S 保存。
  2. 确认底部工具栏的 COM 口是开发板对应的那个,芯片选 esp32p4。
  3. 点 ⚒ 编译 ,等到 Build complete。
  4. 点 ⚡ 烧录 ,方式选 UART ,等到 Flash done。
  5. 按一下复位键(可选),观察板子。

5.3 应该看到的现象

  • 红色 LED:亮 0.5 秒、灭 0.5 秒(完整周期 1 秒),慢悠悠地闪。
  • 黄色 LED:亮 0.2 秒、灭 0.2 秒(完整周期 0.4 秒),明显比红灯快一倍多。
  • 两颗灯互不等待,频率不同也不会"打架"。
灯 引脚 翻转周期 亮 灭 完整周期
红灯 GPIO4 500 ms 0.5 s 0.5 s 1.0 s
黄灯 GPIO5 200 ms 0.2 s 0.2 s 0.4 s

!NOTE

想再直观一点,可以打开 🖥 监视(Monitor) ,在回调里加一句 printf 打印,就能看到每个定时器的触发时间戳(记得别刷太快)。

5.4 现象不对?对照这张表

现象 原因 解决
灯常亮,几乎不闪 周期写成了 500(0.5 ms) 改成 500000
两颗灯闪得一样快 两个定时器用了同一个周期变量 检查 LED1_PERIOD_US 和 LED2_PERIOD_US
一颗闪一颗不亮 引脚没加进 pin_bit_mask,或线没接 检查 mask 里的 `
完全没反应 烧录失败 / 板子还在跑旧程序 重新编译烧录,或看 Monitor 输出
编译报错 undefined reference to esp_timer_create 工程里没启用 esp_timer 正常工程默认启用;确认 #include <esp_timer.h> 已写
灯是"半亮"的 模块低电平点亮,或闪烁太快 改 level 初始值,或调大周期

6. 动手改一改(练习题)

学完不动手,等于没学。下面 5 道题由易到难,建议至少做前 3 道。

练习 1:让红灯慢下来

把红灯改成 1 秒翻转一次(亮 1 秒、灭 1 秒)。

!TIP

把 LED1_PERIOD_US 改成 1000000。

练习 2:消灭"魔法数字"

把两个周期改成语义更清楚的宏,比如 #define BLINK_SLOW_US (1 * 1000000)。

!TIP

C 语言里括号中的算式在编译时就算好了,不会影响性能,可读性却好很多。

练习 3:加第三颗灯

把模块的绿灯(G)接到 GPIO6,让它每 100 ms 翻转一次。

!TIP

照抄红灯的写法,一共要改 4 处------加 #define LED3_GPIO GPIO_NUM_6、加 LED3_PERIOD_US、加 static led_t led3、加一个 t3_args 和 t3。

练习 4:让两个灯"反着闪"

红灯亮的时候黄灯灭,红灯灭的时候黄灯亮。

!TIP

把 led2 的初始 level 改成 1,周期也设成一样。

练习 5(进阶):做个"暂停 / 继续"

按开发板上的 BOOT 键(GPIO0),让两个灯全部停止闪烁,再按一次恢复。

!TIP

需要读按键(把 GPIO0 配成 GPIO_MODE_INPUT + GPIO_PULLUP_ENABLE),然后调用 esp_timer_stop(t1) 和 esp_timer_start_periodic(t1, LED1_PERIOD_US)。这一题会用到第 4 课的知识,做不出来很正常,先想想思路。

7. 常见问题 FAQ

Q1:写高电平灯不亮,是不是代码错了?

A:大概率不是。LED 模块分"高电平点亮"和"低电平点亮"两种。如果写 1 没反应,把 gpio_set_level(...) 的第二个参数改成 0 再试一次。用闪烁的方式测试最直观:两种电平都能看到变化,就说明引脚配置没问题,只是有效电平反了。

Q2:delay() 和 vTaskDelay() 有什么区别?

A:delay() 是 Arduino 的函数,在 ESP-IDF 里没有(除非你用了兼容层)。ESP-IDF 基于 FreeRTOS,标准写法是 vTaskDelay(pdMS_TO_TICKS(ms))。两者的共同点是都会阻塞,本课的重点就是摆脱这种阻塞。

Q3:esp_timer_start_periodic 的单位到底是什么?

A:微秒 。1 秒 = 1 000 000 微秒。所以要 500 毫秒就写 500000。这是本课最容易出错的地方。

Q4:为什么回调函数前面要加 static?

A:static 让函数只在当前文件可见,避免和其他文件里的同名函数冲突。这几乎是嵌入式代码的习惯写法,不加也能编过,但养成习惯更好。

Q5:为什么 t1、t2 要写成 static?

A:句柄必须在定时器存活期间一直有效。如果它们是 app_main 里的普通局部变量,而 app_main 结束了,变量就没了,虽然定时器还在跑,但你以后想 esp_timer_stop 就找不到遥控器了。写 static 最省心。

Q6:两个定时器同时到点,会不会冲突?

A:不会。esp_timer 的回调是排队串行执行 的,一个执行完再执行下一个。前提是你的回调足够短------回调里千万别 vTaskDelay 或死等,否则后面的定时器全被拖慢。

Q7:烧录时提示 "Failed to connect to ESP32" 怎么办?

A:按顺序检查:① USB 线是否插在 FUSB 口,且是数据线;② 是否选对了 COM 口;③ 关掉其他占用串口的软件(串口助手、另一个 Monitor 窗口);④ 按住板上的 BOOT 键再点烧录,出现 Connecting... 后松开。

Q8:串口监视器里是乱码?

A:多为波特率不对。ESP-IDF 默认 115200,在 Monitor 里按提示设置;另外烧录后按一次复位键再打开 Monitor,能看到完整启动日志。

Q9:GPIO 引脚可以随便选吗?

A:不行 。有些引脚是 Flash / PSRAM 专用(接了会死机),有些是启动配置引脚(上电时电平不对就无法启动)。初学者最安全的做法:照着教程用 GPIO4、GPIO5,以后换引脚之前先查开发板原理图和 ESP-IDF 的 GPIO 说明。

Q10:灯的亮度不对 / 是半亮的?

A:三色 LED 模块内部已经限流,如果亮度明显偏暗,通常是因为翻转太快(人眼看到的是平均亮度),或者模块是低电平点亮、你给的极性反了。把周期调大到 1000000 观察一次就能分清。

Q11:这个实验能不能只用 millis() 那种"查时间"的做法?

A:可以。Arduino 的思路是每次循环判断"现在时间 - 上次时间 > 1000?"。ESP-IDF 里对应的函数是 esp_timer_get_time()(返回微秒),也可以用 xTaskGetTickCount()(返回节拍)。定时器的好处是连"判断"都不用写,系统替你判断,代码更清爽。两种做法都属于非阻塞。

8. 本课小结

8.1 三句话总结

  1. GPIO 是引脚开关,输出高电平(3.3V)就有电流,配合 GND 就能点亮 LED。
  2. vTaskDelay 会阻塞,等的时候什么都干不了,所以多个节奏不能靠它。
  3. esp_timer 是非阻塞的核心:设定周期 + 写个短回调,到点自动执行,多个定时器各管各的。

8.2 术语表

术语 一句话解释
GPIO 芯片上可编程的输入输出引脚
高电平 / 低电平 引脚输出 3.3V / 0V
阻塞 程序停下来等,期间不干别的
非阻塞 不等待,把"到点做什么"交给定时器
回调函数 callback 满足条件时由系统自动调用的函数
句柄 handle 操作某个对象的"遥控器",如 t1
周期 period 每隔多久触发一次
微秒 μs 百万分之一秒;1 ms = 1000 μs

9. 参考文档

相关推荐
Chery11401 小时前
低功耗无线传感器节点设计与射频芯片选型指南:BLE、Sub-1GHz与LoRa方案对比
物联网·射频工程
注塑机数据采集联网1 小时前
注塑机数据采集网关读取注塑机工艺/SPC数据计算原理
大数据·网络·数据仓库·物联网·智能路由器·制造
辛苦才能1 小时前
C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7
开发语言·数据结构·c++·算法
miller-tsunami2 小时前
STL简介&string相关知识点
开发语言·c++·string
Chery11402 小时前
ESP32-C3FH8X技术解析:8MB Flash低功耗Wi-Fi与Bluetooth LE物联网SoC
单片机·物联网
JasmineX-12 小时前
数据结构(笔记)——顺序栈
c语言·数据结构·笔记
2401_862880822 小时前
嵌入式Linux系统移植与驱动开发完整指南
linux·c语言·驱动开发
坤坤子吖2 小时前
Linux 线程同步与互斥——互斥锁
linux·服务器·c++·笔记·学习
TDTC_XQ2 小时前
Linux网络编程accept的讲解
linux·服务器·c++