ESP32-S3 IDF编程第四课:中断

上一节教程里,我们用轮询的方式读取按键状态,虽然能用,但有个挺烦人的问题——CPU得一直空转等着,白白浪费了单片机的性能。

有没有更聪明的办法?当然有,这就是咱们今天的主角:中断

什么是中断?

打个生活中的比方你就懂了:

  • 轮询:就像你每隔5秒抬头看看门口有没有人,哪怕根本没人来,你也得一直抬头、低头、抬头……累不累?
  • 中断:就像门铃。平时你该看书看书、该喝茶喝茶,门铃“叮咚”一响,你再去开门。

ESP32-S3的中断就是这个意思:按键按下的瞬间,会产生一个电信号,CPU会立刻暂停手头的活儿,跑去执行一段专门处理按键的代码(这叫中断服务函数),处理完了再回来接着干刚才的事。效率直接拉满。

硬件接线

接法和上一课一模一样,不用改:

  • 按键一脚接 3.3V
  • 另一脚接 GPIO2
  • 重要提醒:最好给GPIO2接个下拉电阻(比如10kΩ到GND),或者像我们代码里做的那样,直接启用内部下拉。不然引脚悬空,电平飘忽不定,可能会瞎触发中断。

上代码,直接看

下面就是完整的中断实现,别被代码长度吓到,核心就那几步,我慢慢拆解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.h"

// 创建一个队列,专门负责把中断消息传给任务
static QueueHandle_t gpio_evt_queue = NULL;

/*
* 中断服务函数 (ISR)
* 划重点:必须加 IRAM_ATTR,让代码待在内部RAM里,保证响应速度
* 中断里千万别干重活!别用 printf!只做个简单的标记就行
*/
static void IRAM_ATTR button_isr_handler(void *arg)
{
uint32_t gpio_num = (uint32_t)arg;
// 把是哪个引脚触发了中断这件事,通过队列告诉外面的任务
xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}

/*
* 任务函数:踏踏实实处理按键事件
* 队列里有消息了,它才会动,平时就歇着,完全不占CPU
*/
static void button_task(void *arg)
{
uint32_t io_num;
while (1) {
// 眼巴巴等着队列来消息,没消息就睡觉(阻塞)
if (xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {
printf("中断触发!按键按下了,GPIO: %ld\n", io_num);
}
}
}

void app_main(void)
{
// 第一步:配置GPIO2为输入,并设定为下降沿触发中断
gpio_config_t io_conf = {
.intr_type = GPIO_INTR_NEGEDGE, // 下降沿触发,就是电平从高变低的那一刻
.mode = GPIO_MODE_INPUT, // 输入模式
.pin_bit_mask = (1ULL << 2), // 选中GPIO2
.pull_down_en = 1, // 启用内部下拉,防止悬空捣乱
.pull_up_en = 0
};
gpio_config(&io_conf);

// 第二步:创建队列,长度10,每个消息存一个引脚号
gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));

// 第三步:创建专门的任务来处理按键
xTaskCreate(button_task, "button_task", 2048, NULL, 10, NULL);

// 第四步:安装GPIO中断服务(整个程序只需要调一次)
gpio_install_isr_service(0);

// 第五步:把咱们写的中断函数和GPIO2绑定起来
gpio_isr_handler_add(2, button_isr_handler, (void *)2);

printf("中断例程已启动,坐等按键按下...\n");
}

核心要点掰开揉碎

1. 神奇的 IRAM_ATTR

中断服务函数前面那个 IRAM_ATTR 绝对不能省。ESP32的代码通常放在外部Flash里,读取有延迟。IRAM_ATTR 就是告诉编译器:“这代码很重要,给我塞进内部RAM里,随叫随到!” 这样中断响应才够快。

2. 中断到底触发了个啥?

咱们代码里用的是 GPIO_INTR_NEGEDGE,也就是下降沿触发。结合咱们的电路(按键接3.3V,GPIO2有下拉电阻),它的工作流程是这样的:

  • 没按按键时:GPIO2被下拉电阻拽到低电平(0)。
  • 按下瞬间:GPIO2接通3.3V,电平从低变高(上升沿)。
  • 松开瞬间:GPIO2断开3.3V,被下拉电阻重新拽回低电平(下降沿)。

所以,我们抓的就是“松开”或者“按下后弹起”的那个瞬间。如果你想在“按下”的瞬间就触发,把 GPIO_INTR_NEGEDGE 改成 GPIO_INTR_POSEDGE 就行了。

关于中断类型的更多选项,可以看这个表:

  • GPIO_INTR_DISABLE:关了中断,纯轮询用。
  • GPIO_INTR_POSEDGE:上升沿触发(电平从0变1)。
  • GPIO_INTR_NEGEDGE:下降沿触发(电平从1变0)。
  • GPIO_INTR_ANYEDGE:不管上升还是下降,都触发。
  • GPIO_INTR_LOW_LEVEL / HIGH_LEVEL:电平触发,会一直不停触发,新手慎用!

3. ISR里的“禁忌”

button_isr_handler 这个中断服务函数里,有三件事千万别干

  • ❌ 别用 printf 打印东西(太慢了!)。
  • ❌ 别用 vTaskDelay 之类的延时函数。
  • ❌ 别执行任何耗时的计算。

那我想打印怎么办?标准做法就是咱们代码里演示的——用队列 (xQueueSendFromISR) 把消息传给外面的任务,让任务去慢慢处理。专业术语叫“将中断处理推迟到任务中”。

4. 中断配置三步走,记牢了

  1. gpio_config:设置引脚的 intr_type,告诉它你要用中断了。
  2. gpio_install_isr_service:给整个芯片的中断系统“开机”(整个程序只开一次)。
  3. gpio_isr_handler_add:把你的中断服务函数和具体的引脚“拴”在一起。

等等!出问题了!怎么打印了一大堆?

当你兴冲冲编译烧录,按下按键,串口输出可能是这样的:

1
2
3
4
5
6
中断触发!按键按下了,GPIO: 2
中断触发!按键按下了,GPIO: 2
中断触发!按键按下了,GPIO: 2
中断触发!按键按下了,GPIO: 2
中断触发!按键按下了,GPIO: 2
...(疯狂刷屏)

哇,我就轻轻按了一下,它怎么跟疯了一样?

原因很简单:按键抖动。

我们理想中的按键波形是方方正正的:
理想波形

但实际上,机械按键的触点碰撞,波形是这样的“毛刺”:
实际抖动波形

那一堆密密麻麻的毛刺,每一个都可能被我们的中断当成一次“按下”,所以才会疯狂触发。这就是传说中的抖动

怎么治这个“多动症”?

有两种药方:软件消抖硬件消抖

方法一:软件消抖(加个状态机)

在代码里加一个简单的状态机,忽略掉那些短时间内的重复触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
typedef enum {
BUTTON_IDLE, // 空闲,等人按
BUTTON_PRESSED, // 有人按了,但我得看看是不是真的
} button_state_t;

static button_state_t button_state = BUTTON_IDLE;
static uint32_t last_debounce_time = 0;

static void button_task(void *arg)
{
uint32_t io_num;
while (1) {
// 等队列消息,但最多只等10ms,防止错过其他事
if (xQueueReceive(gpio_evt_queue, &io_num, pdMS_TO_TICKS(10))) {
switch(button_state) {
case BUTTON_IDLE:
// 第一次收到中断,记录时间,进入“待确认”状态
button_state = BUTTON_PRESSED;
last_debounce_time = xTaskGetTickCount();
break;

case BUTTON_PRESSED:
// 又收到中断了,检查一下距离上次过了多久
if ((xTaskGetTickCount() - last_debounce_time) > pdMS_TO_TICKS(20)) {
// 超过20ms了,说明是真的按下了,不是抖动
// 这里可以再次确认一下电平,万无一失
if (gpio_get_level(io_num) == 0) {
printf("确认!按键真的按下了,GPIO: %ld\n", io_num);
}
// 处理完,回到空闲状态,准备迎接下一次
button_state = BUTTON_IDLE;
}
// 如果时间没超过20ms,直接忽略,就当是抖动
break;
}
}
}
}

逻辑解释:第一次中断进来,我们记个时间。如果在20毫秒内又来了一堆中断,我们全部忽略。直到消停20毫秒后,才认定是一次“有效按下”。

方法二:硬件消抖(最简单粗暴)

在按键两端并联一个0.1uF到1uF的小电容,利用电容的充放电特性,把那些毛刺给“吸”平。硬件一劳永逸,但需要你动动烙铁。


轮询 vs 中断,终极PK

对比项 轮询 中断
CPU占用 一直占着,空转烧功耗 几乎为0,随叫随到
响应速度 看你轮询的勤快程度 微秒级,飞快
代码难度 简单直接 稍微复杂一点点
适合场合 状态需要持续监测 事件驱动的活儿(比如按键)

下课小结

这节课我们成功给按键插上了“中断”的翅膀,让CPU彻底解放了。总结一下今天的干货:

  1. 中断原理:CPU不用空转,事件来了再处理。
  2. IRAM_ATTR:让中断函数跑在快车道上。
  3. ISR与队列:中断里只传消息,重活扔给任务干。
  4. 中断三步走:配置、安装、绑定。
  5. 按键抖动:知道了为什么按一下会触发好多次,以及怎么用软件状态机或硬件电容干掉它。

下一节课,咱们可以玩玩更有意思的,比如用定时器来做一个更优雅的消抖,或者用编码器旋钮。你觉得呢?

如果文章对你有用,欢迎随意赞赏~有问题评论区见!