本课是基础补充:用一个任务同时扫描 BOOT、切换模式、定时闪灯。任务在两次闪灯之间仍会检查按键,无需暂停整个业务来等待下一次闪灯。
配套工程:examples/09-event-state-machine。当前代码已在默认配置的隔离副本中通过 ESP-IDF v5.5.5 编译,本次未烧录;按键、消抖与实际响应延迟尚待实板验收。
一图看懂关键调用
实线表示同一执行路径上的顺序,虚线表示任务调度、回调触发或消息传递;它们不表示跨任务的直接函数调用。
%%{init: {"flowchart": {"nodeSpacing": 20, "rankSpacing": 24, "padding": 10, "subGraphTitleMargin": {"top": 8, "bottom": 16}}}}%%
flowchart TB
A["app_main()<br/>led_init()、key_init()<br/>wake = xTaskGetTickCount()"]
A --> B["每轮读取 now = esp_timer_get_time()<br/>key_poll(now):时间戳消抖,不等待"]
B --> C{"返回一次稳定按下?"}
C -- "是" --> M["mode = (mode + 1) % 3<br/>entered_at = now"]
C -- "否" --> O["按 mode 和 now - entered_at 算输出<br/>OFF / ON / 每 500 ms 翻转"]
M --> O
O --> L["只在目标变化时 led_set(on)<br/>vTaskDelayUntil(&wake,<br/>pdMS_TO_TICKS(10))"]
L --> B所有节点都在同一个 app_main() 任务中执行,没有队列或额外任务。key_poll() 只更新采样状态并立即返回;真正的等待只发生在每轮末尾的短扫描节拍中,闪烁相位按微秒时间计算。
1. 从“按顺序等完”变成“每次推进一点”
如果主循环先点灯、等 500 ms、熄灯、再等 500 ms,按键可能需要等待接近 1 秒才被扫描。即使 vTaskDelay() 已经把 CPU 让给其他任务,本任务内的后续业务仍要等它返回。
解决办法是保存状态和进入状态的时间。每轮检查有没有新输入、当前应该输出什么,处理后便进入下一轮。
这种“记住当前处于哪一步,再来事件就换一步”的写法就是状态机。本课标题里的“状态机”指的就是这个手段,它首先是一种省掉等待的办法:因为“下一步该做什么”完全由保存的状态和经过的时间算出来,任务就不需要停在原地等某件事发生。
下面这张图是状态机常见的画法,但图只是表象——真正让程序不必等待的,是图背后那三个变量(当前状态、进入时刻、上一轮输出)在每轮循环里被重新计算一次。只画图不存状态,代码照样会退回到 vTaskDelay() 排队等待。
模式顺序是:熄灭 → 常亮 → 闪烁 → 熄灭。每次稳定按下只转换一次;长按不连续跳转。进入闪烁时立即点亮,500 ms 后熄灭,再过 500 ms 点亮。
2. 接线与电平


DNESP32S3 V1.2:BOOT 按下使 GPIO0 变低,程序启用内部上拉;GPIO1 低电平点亮 LED。无需外设。BOOT 也是启动配置引脚,实验时先正常启动,再按键,不要按住 BOOT 复位来测试业务。
3. 状态、事件和输出分别是什么
| 名称 | 本例内容 | 保存在哪里 |
|---|---|---|
| 状态 | OFF、ON、BLINK | mode |
| 输入事件 | 一次稳定按下 | key_poll() 返回值 |
| 时间基准 | 进入当前模式的时刻 | entered_at |
| 输出 | 此时 LED 应该亮还是灭 | on |
这张表就是状态机的三要素加一个输出,和第 1 节那张转换图是同一件事的两种写法:图描述“什么事件让状态怎么变”,表描述“这些信息在代码里各存在哪个变量”。写状态机时先填这张表,再照表写 if,比直接照着图写代码更不容易漏掉时间基准。
本例只有一个业务任务,可以直接处理事件,无需增加队列。变量由一个任务独占,状态转换也在同一处完成。第 12 课有多个生产者时再使用队列。
1// 枚举比散落在各处的 0、1、2 更容易表达业务含义。
2typedef enum { MODE_OFF, MODE_ON, MODE_BLINK } mode_t;
3
4if (key_poll(now)) {
5 mode = (mode + 1) % 3; // 一次事件只前进一个状态。
6 entered_at = now; // 每次进入新状态重新建立时间基准。
7}
4. 消抖也要分成多次检查
第 03 课的消抖函数通过短暂 vTaskDelay() 再次确认电平。本课把等待改成时间戳,KEY BSP 提供:
1// now_us 来自单调时钟。函数立即返回,不在内部等待触点稳定。
2bool key_poll(int64_t now_us);
它保存原始电平、已经确认的稳定电平,以及原始电平最近变化的时间。完整函数如下,代码按“六步”直线阅读:
1bool key_poll(int64_t now_us)
2{
3 int raw = gpio_get_level(BOOT_KEY_GPIO);
4
5 /* 第 1 步:电平刚变化,重新开始 20 ms 计时。 */
6 if (raw != s_raw) {
7 s_raw = raw;
8 s_changed_at = now_us;
9 }
10
11 /* 第 2 步:这个电平还没稳定满 20 ms,本轮不产生事件。 */
12 if (now_us - s_changed_at < DEBOUNCE_US) return false;
13
14 /* 第 3 步:读到稳定的高电平,说明键已松开,重新允许下一次按下。 */
15 if (s_raw == 1) s_press_allowed = true;
16
17 /* 第 4 步:稳定值和上次确认的一样,没有新事件。 */
18 if (s_raw == s_stable) return false;
19
20 /* 第 5 步:稳定值变了,记录新状态。 */
21 s_stable = s_raw;
22
23 /* 第 6 步:新状态是按下,且当前允许报事件,就报一次并禁止连发。 */
24 if (s_stable == 0 && s_press_allowed) {
25 s_press_allowed = false;
26 return true;
27 }
28 return false;
29}
| 步骤 | 一句话 |
|---|---|
| 1 | 电平刚变化,重新计时 |
| 2 | 没稳够 20 ms,本轮不算数 |
| 3 | 读到稳定高电平,说明键已松开,重新允许按下 |
| 4 | 和上次确认的稳定值一样,没有新事件 |
| 5 | 稳定值变了,记录新状态 |
| 6 | 新状态是按下且允许报事件,就报一次并禁止连发 |
其中 BOOT_KEY_GPIO 是 GPIO_NUM_0 的宏名,DEBOUNCE_US 为 20000 微秒(即 20 ms)。完整定义见 BSP 的 key.c。
轮询连续读到稳定电平达到 20 ms 后,才更新稳定状态。稳定下降沿产生一次按下事件;稳定松开后才允许下一次按下。这样,松开时的抖动不会立即让程序接受下一次按下。代码还避免把上电时已经按住的键当成新按下。
key_poll() 必须定期调用,不能两秒才检查一次。它的内部状态由扫描任务独占,不允许多个任务同时调用。
5. 按经过时间计算闪烁相位
1// now 和 entered_at 的单位都是微秒。
2bool on = false;
3if (mode == MODE_ON) {
4 on = true;
5} else if (mode == MODE_BLINK) {
6 int64_t elapsed = now - entered_at; // 进入 BLINK 后经过的微秒数
7 int64_t half_periods = elapsed / 500000; // 第几个 500 ms
8 on = (half_periods % 2 == 0); // 偶数段点亮,奇数段熄灭
9}
10// MODE_OFF 不进入任何分支,on 保持 false。
三种模式各自走一条分支:OFF 什么都不做,LED 保持熄灭;ON 直接把 on 置为 true,LED 常亮;BLINK 才去算相位。
相位怎么算:进入 BLINK 后经过 0~499999 us,整数除法结果是 0,LED 点亮;经过 500000~999999 us,结果是 1,LED 熄灭;下一段结果变成 2,又点亮。
如果任务因别的工作迟到 20 ms,下次仍按真实经过时间计算,不会每次把延迟都叠加进周期。若系统迟到得太久,部分闪烁会被跳过;状态机不能恢复没有实际输出的历史边沿。
只有目标输出变化时才调用 led_set(),避免每轮重复写相同电平。
6. “非阻塞业务”为什么还有 delay
1// 相对上一轮计划唤醒时间,保持约 10 ms 的扫描节拍。
2vTaskDelayUntil(&wake, pdMS_TO_TICKS(10));
这里阻塞当前任务 10 ms 左右是主动让出 CPU。业务没有为了等待闪灯暂停 500 ms,按键检查一直按短节拍进行。这就是本课所说的非阻塞程序。
本工程设置 CONFIG_FREERTOS_HZ=1000,所以 10 ms 对应 10 个 tick。pdMS_TO_TICKS() 是换算宏,不是延时函数;真正阻塞的是 vTaskDelayUntil()。
若每轮业务长期超过 10 ms,固定周期循环可能赶不上计划,需要减少每轮工作量或把慢 I/O 交给其他任务。不能把此调用理解为保证任何负载下都能准时响应。
7. BSP、应用与工程注册
components/BSP/KEY 只输出按键事件,components/BSP/LED 只转换逻辑亮灭与 GPIO 电平。OFF/ON/BLINK 和 500 ms 间隔属于应用,保存在 main.c。
1# main 的依赖包括 BSP、单调时钟和任务 API。
2idf_component_register(SRCS "main.c" INCLUDE_DIRS "."
3 PRIV_REQUIRES BSP esp_timer freertos)
这里使用 esp_timer_get_time() 读取时间,没有创建 esp_timer 定时回调。读取单调时钟与申请一个周期定时器是两种不同操作。
当前完整应用循环如下,它直接运行在 app_main() 任务中:
1void app_main(void)
2{
3 led_init();
4 key_init();
5 mode_t mode = MODE_OFF;
6 int64_t entered_at = esp_timer_get_time();
7 bool previous_on = false;
8 TickType_t wake = xTaskGetTickCount();
9 while (1) {
10 int64_t now = esp_timer_get_time();
11 /* 按键事件触发状态转移;长按只算一次,不会连续切换。 */
12 if (key_poll(now)) {
13 mode = (mode + 1) % 3;
14 entered_at = now;
15 ESP_LOGI("state", "mode=%d (0=OFF, 1=ON, 2=BLINK)", mode);
16 }
17 /* 每 500 ms 变一次状态但不阻塞 500 ms;漏拍时按实际时间求相位。 */
18 bool on = false;
19 if (mode == MODE_ON) {
20 on = true;
21 } else if (mode == MODE_BLINK) {
22 int64_t elapsed = now - entered_at;
23 int64_t half_periods = elapsed / 500000;
24 on = (half_periods % 2 == 0);
25 }
26 if (on != previous_on) {
27 led_set(on);
28 previous_on = on;
29 }
30 /* 10 ms 是输入采样节拍,阻塞期间 CPU 可以让给其他任务。 */
31 vTaskDelayUntil(&wake, pdMS_TO_TICKS(10));
32 }
33}
用到的时间与任务调度函数查询表
名称链接到 ESP-IDF v5.5.5 官方文档或该版本源码。表中的调用环境按本课用法标注;带 FromISR 的接口用于中断,普通任务接口不能直接搬进 ISR。
| 函数或宏 | 本课用途 | 调用环境 |
|---|---|---|
esp_timer_get_time() | 读取单调时间,单位微秒 | 本课在任务中读取;也可在 ISR 中读取 |
xTaskGetTickCount() | 读取系统 tick 计数 | 普通任务,不能在 ISR 中调用 |
vTaskDelayUntil() | 相对计划唤醒时间维持扫描节拍 | 普通任务,不能在 ISR 中调用 |
pdMS_TO_TICKS() | 毫秒换算为 tick 数 | 计算宏,不会延时 |
读取时间与保存扫描基准
esp_timer_get_time() 无参数,返回 int64_t 微秒时间。本课把它传给 key_poll(now),并与 entered_at 相减计算闪烁相位;这里只读时钟,没有创建定时器回调。
xTaskGetTickCount() 无参数,返回系统 tick 计数,类型是 TickType_t。它用来初始化扫描节拍变量 wake,不能与微秒变量直接相减。
固定节拍的两个参数
vTaskDelayUntil(&wake, pdMS_TO_TICKS(10)) 的第一个参数指向保存计划唤醒时间的变量,第二个参数是每轮间隔 tick 数。先用 xTaskGetTickCount() 初始化 wake,后续调用会更新它,不要在每轮重新置为当前时间。
vTaskDelayUntil() 没有返回值。它在本版本中调用 xTaskDelayUntil() 并忽略返回值;后者返回 pdTRUE 表示本轮发生了延时,返回 pdFALSE 表示计划时刻已到、无需再阻塞。两者都不能保证超负载循环按时完成。
pdMS_TO_TICKS(10) 是换算宏,返回 10 ms 对应的 tick 数。本工程配置 CONFIG_FREERTOS_HZ=1000,结果为 10 tick;换用其他配置时不能默认仍为 10。
BSP 函数不是系统 API
key_init()、key_poll(now_us)、led_init() 和 led_set(on) 是本工程的 BSP 接口。key_poll() 接收微秒时间戳,立即返回是否出现一次稳定按下;led_set() 接收逻辑亮灭值,由 BSP 转换 GPIO 电平。这些接口没有 FreeRTOS 官方文档,应对照配套工程的头文件阅读。
正文中的 vTaskDelay() 用于与旧消抖方式作比较;当前工程使用 vTaskDelayUntil() 维持短扫描节拍,不在消抖函数内部延时。
当前代码的接口与错误处理
| 本工程接口 | 用途与条件 | 返回类型 |
|---|---|---|
key_init() / led_init() | 初始化板载按键和 LED | void |
key_poll(now_us) | 使用微秒时间戳消抖;稳定按下返回 true,其他情况返回 false | bool |
led_set(on) | 设置当前逻辑亮灭状态 | void |
这些接口属于配套工程 BSP 或业务代码,不是同名的 ESP-IDF 底层 API。返回 void 的接口没有可检查的返回值,不能直接放进 ESP_ERROR_CHECK()。
当前教学版省略了多项初始化、资源创建和驱动调用的返回值检查。查询表保留底层 API 的成功与失败返回值,表示接口本身的约定;不代表当前源码已经检查。若要补齐,应在 BSP 内处理底层错误、按需返回 esp_err_t,并单独判断 FreeRTOS 创建结果。
8. 构建与实板验收
1# 激活环境后进入本课根目录。
2source ~/.espressif/tools/activate_idf_v5.5.5.sh
3cd ~/mycode/esp32-blog/examples/09-event-state-machine
4idf.py build
5# 替换为真实开发板端口。
6idf.py -p <实际串口> flash monitor
依次按 BOOT,日志应该显示 mode=1、mode=2、mode=0。观察常亮、500 ms 翻转和熄灭是否对应;按住两秒只切换一次;在闪烁刚开始和即将结束时按键,模式都应及时切换。
消抖时间 20 ms,加上约 10 ms 扫描粒度与调度延迟,响应不是零延迟,也不能仅靠这些配置声称实测响应一定小于某个数值。
进一步练习:增加“快闪”状态,修改状态转换和相位计算,不要增加长时间等待。判断自己有没有做对的标准很简单——新状态只需要往枚举和相位计算里各加一行,不需要新增任何 vTaskDelay()。如果发现自己在为“快闪”补一个等待,说明进度还没有真正交给状态变量保存。
本课为基础补充,没有一一对应的官方实验源码。它沿用前面课程的 GPIO 约定,由一个任务保存和修改状态,避免跨任务共享这些变量。