新手完整版 · 每个名词先解释再使用,每段代码逐行说明,照着做就能看到效果。
!NOTE
本课是 ESP32 教程的第 3 课。第 1 课装好了环境、跑通了 Hello World,这一课我们真正开始"控制硬件":先点亮一颗 LED,再让它闪起来,最后用定时器实现"两个灯各闪各的、互不干扰"。
全程只需要一块开发板、一个 LED 模块、三根杜邦线。看不太懂的地方不要紧,先照着做,第 2 遍再回来读解释。
0. 读前必看
0.1 学完这一课,你会做这几件事
- 知道 GPIO 引脚 是什么,高电平 / 低电平到底是什么意思。
- 亲手点亮一颗 LED,再让它按节奏闪起来。
- 明白
delay()为什么会"堵车",学会用esp_timer定时器做非阻塞闪烁。 - 让两个 LED 用不同频率同时闪,谁都不影响谁。
- 能读懂、能修改一份完整的 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 课的环境:
- 打开 VS Code,点左侧的 ESP-IDF 图标,确认扩展已加载。
- 打开第 1 课创建的
hello_world工程(或者任意一个能编译的工程)。 - 底部工具栏里确认三项:串口(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 课的方法新建一个 Hello World 工程,项目名可以叫
LED_Blink(用英文,不要有空格和中文)。 - 使用课程提供的
LED_Blink工程压缩包,解压到纯英文路径 下,用 VS Code 直接打开工程文件夹也可以。(LED_Blink项目下载) - 在左侧资源管理器里打开
main文件夹,双击hello_world_main.c(新建工程时文件名可能是main.c或led_blink.c,都可以)。 - 按
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 编译、烧录、看效果
- 把 USB 线插到开发板的 FUSB 口(调试 / 下载口,不是 HUSB),另一端插电脑。
- 看 VS Code 底部工具栏:③ 位置选择正确的 COM 口(插拔一次看哪个出现的通常就是它);④ 位置确认为
esp32p4。 - 点击底部的 ⚒ 编译(Build) ,等待右下角提示
Build complete。第一次编译要花几分钟。 - 点击 ⚡ 烧录(Flash) ,弹出烧录方式时选 UART ,等待
Flash done。 - 看板子:红色 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 定时器到底是什么
定时器 = 一个会自动看表的助手。
你告诉它两件事:
- 多久叫我一次(周期,比如 500 毫秒);
- 到点了干什么事 (要执行的函数,专业叫法是"回调函数 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 回调函数的纪律(很重要)
定时器的回调是在系统的一个公共任务里执行的,所以:
- 绝对不要在回调里写
vTaskDelay()、while长时间循环、printf大量日志------你一卡,其他定时器就都跟着卡。 - 回调要短、快,一般几行代码搞定。它该做的是"记个标志位"或"翻转一下引脚",而不是"干重活"。
- 需要在回调外做耗时的工作,就在回调里设一个变量(标志位),让
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 编译与烧录
- 把完整代码全部替换到
main目录下的.c文件里,Ctrl + S保存。 - 确认底部工具栏的 COM 口是开发板对应的那个,芯片选
esp32p4。 - 点 ⚒ 编译 ,等到
Build complete。 - 点 ⚡ 烧录 ,方式选 UART ,等到
Flash done。 - 按一下复位键(可选),观察板子。
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 三句话总结
- GPIO 是引脚开关,输出高电平(3.3V)就有电流,配合 GND 就能点亮 LED。
vTaskDelay会阻塞,等的时候什么都干不了,所以多个节奏不能靠它。esp_timer是非阻塞的核心:设定周期 + 写个短回调,到点自动执行,多个定时器各管各的。
8.2 术语表
| 术语 | 一句话解释 |
|---|---|
| GPIO | 芯片上可编程的输入输出引脚 |
| 高电平 / 低电平 | 引脚输出 3.3V / 0V |
| 阻塞 | 程序停下来等,期间不干别的 |
| 非阻塞 | 不等待,把"到点做什么"交给定时器 |
| 回调函数 callback | 满足条件时由系统自动调用的函数 |
| 句柄 handle | 操作某个对象的"遥控器",如 t1 |
| 周期 period | 每隔多久触发一次 |
| 微秒 μs | 百万分之一秒;1 ms = 1000 μs |
9. 参考文档
- ESP-IDF 编程指南(中文)
- GPIO 驱动 API 说明
- esp_timer 定时器 API 说明
- 官方示例:
esp-idf/examples/peripherals/gpio/generic_gpio、esp-idf/examples/system/esp_timer