【Zephyr|ESP32-S3】基础学习:GPIO中断控灯
【Zephyr|ESP32-S3】基础学习:GPIO中断控灯
哈喽,我是余火,一个普通的牛马打工人,目前正在学如何使用 Zephyr RTOS。
上一篇让 WS2812 循环跑颜色,这回换个玩法——按一下板载 BOOT 按键,切一种颜色。从"自动跑"变成"手动控",核心就是 GPIO 中断。
改了哪些东西
在上一篇 WS2812 工程基础上直接改,WS2812 部分不动,只新增 GPIO 输入中断相关的内容:
| 文件 | 改了什么 |
|---|---|
prj.conf |
新增 CONFIG_GPIO=y,启用 GPIO 子系统 |
boards/xxx.overlay |
新增 BOOT 按键节点 button0 和别名 sw0 |
src/main.c |
新增 GPIO 中断回调函数 + atomic 标志轮询消费逻辑 |
src/main.c(头文件) |
新增 <zephyr/drivers/gpio.h> 和 <zephyr/sys/atomic.h> |
GPIO中断原理
在写代码之前,先搞清楚 GPIO 中断到底是什么。
GPIO 中断(General Purpose Input/Output Interrupt)是指当某个 GPIO 引脚的电平发生变化时,硬件自动触发一个中断信号,CPU 暂停当前正在执行的任务,转去执行预先注册的中断回调函数(ISR)。处理完之后,CPU 再回到之前被打断的地方继续执行。
这种"事件驱动"的方式相比"轮询检测"有两个巨大优势:
- 响应速度快——引脚电平变化的瞬间就会触发,不需要等到下一次轮询周期
- 节省 CPU 资源——没有事件时 CPU 可以做别的事或者休眠,不需要空转查询引脚状态
中断触发模式
Zephyr 的 GPIO 中断支持多种触发模式,不同场景需要选择不同模式:
| 触发模式 | 宏定义 | 触发条件 | 适用场景 | 说明 |
|---|---|---|---|---|
| 下降沿触发 | GPIO_INT_EDGE_FALLING |
高→低跳变瞬间 | 低电平有效的按键 | 只在电平下降时触发一次 |
| 上升沿触发 | GPIO_INT_EDGE_RISING |
低→高跳变瞬间 | 高电平有效的按键/传感器 | 只在电平上升时触发一次 |
| 双边沿触发 | GPIO_INT_EDGE_BOTH |
任意电平跳变 | 旋转编码器 | 按下和松开各触发一次 |
| 变到active | GPIO_INT_EDGE_TO_ACTIVE |
电平变为active | 通用按键(本工程) | 根据设备树极性自动适配,推荐用法 |
| 低电平触发 | GPIO_INT_LEVEL_LOW |
持续为低电平 | 电平型传感器 | 只要为低就持续触发,需在 ISR 中清除源 |
| 高电平触发 | GPIO_INT_LEVEL_HIGH |
持续为高电平 | 电平型传感器 | 只要为高就持续触发,需在 ISR 中清除源 |
💡 本工程使用
GPIO_INT_EDGE_TO_ACTIVE,它会根据设备树中声明的极性(GPIO_ACTIVE_LOW/GPIO_ACTIVE_HIGH)自动选择下降沿或上升沿。这样代码不依赖物理极性方向,换一块 active-high 的板子也不用改代码——这就是 Zephyr 设备树抽象的威力。
Kconfig配置:启用GPIO子系统
prj.conf 加上 GPIO 配置:
Kconfig 启用 GPIO:
# 启用 GPIO 子系统
# ESP32-S3 SoC 的 Kconfig 默认已 select CONFIG_GPIO,
# 显式写出来便于移植到其他 SoC(某些厂商 HAL 默认未开启)
CONFIG_GPIO=y
实际上 ESP32-S3 的 soc Kconfig 文件(soc/espressif/esp32s3/Kconfig.soc)默认已经 select 了 CONFIG_GPIO,所以你不写这一行也能编译通过。但显式写出来有两个好处:
- 一目了然——读者一眼就能看到"要用 GPIO 中断,必须确保
CONFIG_GPIO=y" - 可移植性——移植到其他 SoC 时,某些厂商 HAL 默认并未 select
CONFIG_GPIO,此时必须显式开启
设备树配置:声明BOOT按键
overlay 里 WS2812 部分不动,追加按键配置:
设备树按键节点:
#include <zephyr/dt-bindings/gpio/gpio.h>
/* BOOT 按键:GPIO0 的第 0 引脚,内部上拉,低电平有效 */
&button0 {
gpios = <&gpio0 0 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>;
};
/ {
aliases {
sw0 = &button0;
};
};
gpios 三元组详解
gpios 属性是 GPIO 设备树绑定的核心,它是一个三元组,包含三个字段:
| 字段 | 值示例 | 含义 |
|---|---|---|
| GPIO 控制器 | &gpio0 |
指向 SoC 的 GPIO 控制器节点,ESP32-S3 只有一个 gpio0 |
| 引脚号 | 0 |
控制器上的引脚编号,BOOT 键在 GPIO0 的第 0 号引脚 |
| 电学标志 | GPIO_PULL_UP | GPIO_ACTIVE_LOW |
上拉 + 低电平有效的组合标志 |
GPIO_PULL_UP:启用 SoC 内部上拉电阻,按键悬空时引脚被拉到高电平GPIO_ACTIVE_LOW:声明"按下"事件对应低电平(按下接地 = active),松开为高电平(inactive)
代码中通过 GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios) 一次性取出这三个字段,封装成 struct gpio_dt_spec 结构体,后续所有 GPIO 操作都基于这个结构体。
💡 实际上 ESP32-S3 DevKitC 的板级 DTS 里已经预置了
button0节点和sw0别名,不写也能用。这里显式写出来是为了方便对照或者做移植。Zephyr 设备树编译器会按"同名合并"规则把 overlay 里写的属性与板级已有节点合并,不会产生"重复定义"错误。
业务代码:GPIO中断回调函数
WS2812 初始化和颜色推送上篇已讲,这次只看 GPIO 中断部分。先来看需要的头文件和全局变量定义。
头文件与全局定义:
#include <zephyr/drivers/gpio.h> /* GPIO 中断 API + gpio_dt_spec 结构体 */
#include <zephyr/sys/atomic.h> /* atomic_t / atomic_set / atomic_cas */
/* 从 sw0 别名获取按键 GPIO 规格:(控制器, 引脚号, flags) 三元组 */
static const struct gpio_dt_spec button =
GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios);
/* 每根引脚的回调需要一个独立的 gpio_callback 实例 */
static struct gpio_callback button_cb_data;
/* ISR → 主线程的标志,atomic_t 保证读/写不会被撕裂 */
static atomic_t button_pressed;
gpio_dt_spec:Zephyr 把 GPIO 的"控制器 + 引脚号 + 标志"打包成一个结构体,方便统一传递gpio_callback:回调注册结构体,每个引脚必须有一个独立实例atomic_t:原子类型变量,在 ISR 和线程之间安全传递标志,不会被 CPU 指令重排或调度打断导致"读一半"
GPIO 中断回调函数(ISR 上下文):
/*
* GPIO 中断回调(ISR 上下文)
*
* 当 BOOT 键按下(引脚变低 = active),硬件中断触发,
* Zephyr GPIO 驱动遍历回调链 → 调用本函数。
*
* ISR 安全规则——只能做极轻量操作:
* ✗ printf / LOG_xxx — 内部可能拿互斥锁
* ✗ k_msleep / k_yield — 会触发线程调度
* ✗ k_mutex_lock — 锁已被占用时会阻塞
* ✓ atomic_set — 纯原子写,无锁、不调度
*/
static void button_pressed_cb(
const struct device *dev, struct gpio_callback *cb, uint32_t pins)
{
ARG_UNUSED(dev);
ARG_UNUSED(cb);
ARG_UNUSED(pins);
atomic_set(&button_pressed, 1); /* ISR 只做标志置位 */
}
回调函数的三个参数:
dev:触发中断的 GPIO 控制器设备(即&gpio0),本例中不需要使用cb:指向触发本次调用的gpio_callback结构体,用于区分多个回调pins:位掩码,表示哪些引脚触发了中断。一个回调可以监听多根引脚,通过pins区分来源
💡 ISR 里只干一件事:把
button_pressed置 1。真正的颜色切换放到主线程去做。这种 “中断置标志、主线程消费” 的模式就是经典的**“中断下半部”**设计思想——上半部(ISR)做最少的事情来快速响应硬件,下半部(线程)做耗时的实际业务。这样做的好处是 ISR 执行时间极短,不会阻塞其他中断,系统实时性有保障。
ISR上下文安全规则
ISR 运行在中断上下文,优先级高于所有线程,因此对能调用的 API 有严格限制:
| 类别 | API 示例 | 能否在 ISR 中使用 | 原因 |
|---|---|---|---|
| 原子操作 | atomic_set / atomic_cas / atomic_add |
✅ 可以 | 纯 CPU 指令,无锁、不调度 |
| 信号量释放 | k_sem_give |
✅ 可以 | 唤醒等待线程,不会阻塞当前上下文 |
| 定时器启动 | k_timer_start |
✅ 可以 | 仅设置硬件定时器,立即返回 |
| 工作队列提交 | k_work_submit |
✅ 可以 | 把任务丢到工作队列线程去执行 |
| 日志打印 | LOG_INF / LOG_DBG |
❌ 禁止 | 内部可能拿互斥锁,会导致死锁 |
| 延时函数 | k_msleep / k_sleep |
❌ 禁止 | 会触发线程调度,ISR 中不能调度 |
| 互斥锁 | k_mutex_lock |
❌ 禁止 | 如果锁已被占用会阻塞当前上下文 |
| 标准输出 | printf |
❌ 禁止 | 底层可能使用互斥锁保护串口设备 |
业务代码:按键配置与回调注册
有了回调函数,还需要在 main() 中完成三步配置。
GPIO 中断三步配置:
/* ---- 第一步:配置引脚方向为输入 ---- */
/* 上拉由设备树 GPIO_PULL_UP 声明,配置为输入时自动生效 */
int ret = gpio_pin_configure_dt(&button, GPIO_INPUT);
if (ret != 0) {
LOG_ERR("Failed to configure button GPIO (%d)", ret);
return 0;
}
/* ---- 第二步:配置中断触发沿 ---- */
/*
* GPIO_INT_EDGE_TO_ACTIVE 含义:引脚电平切换到"active"状态时触发。
* 设备树里 GPIO_ACTIVE_LOW,所以 active = 低电平 = 按键按下。
* 使用 TO_ACTIVE 而非 EDGE_FALLING 可以让代码不依赖极性物理方向,
* 板子换成 active-high 的按键也不需要改代码。
*/
ret = gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE);
if (ret != 0) {
LOG_ERR("Failed to configure button interrupt (%d)", ret);
return 0;
}
/* ---- 第三步:注册中断回调 ---- */
/*
* 1) gpio_init_callback 把 handler 与 pin-bit-mask 绑定到一个 cb 结构
* 2) gpio_add_callback 把该 cb 挂到 gpio 控制器的回调链上
*
* 注意:一个 cb 可以覆盖多根 pin(通过 BIT(pin) 或运算),
* 但每根独立引脚必须使用不同的 struct gpio_callback 实例。
*/
gpio_init_callback(&button_cb_data, button_pressed_cb, BIT(button.pin));
gpio_add_callback(button.port, &button_cb_data);
Zephyr GPIO 中断配置三步骤:
- 配置方向——
gpio_pin_configure_dt()设置为输入模式 - 配置触发沿——
gpio_pin_interrupt_configure_dt()指定触发条件 - 注册回调——
gpio_init_callback()+gpio_add_callback()绑定处理函数
这三步缺一不可,顺序也建议按 1→2→3 执行。
业务代码:主循环消费标志
回调只负责置标志,真正的颜色切换在主循环中完成:
主循环标志消费与消抖:
LOG_INF("Press BOOT button to cycle colors");
/* 初始点亮第一种颜色 */
size_t color = 0;
push_color(color);
while (1) {
/*
* atomic_cas:flag 为 1 则清零为 0 并处理(消费型读取)
* 这是一个原子操作——要么读到旧值 1 并成功清零,
* 要么读到 0 说明已被消费或无事件,什么都不做。
* 即使多个中断快速连续到达,也只有一个能成功 cas 到 1→0。
*/
if (atomic_cas(&button_pressed, 1, 0)) {
color = (color + 1) % ARRAY_SIZE(colors);
push_color(color);
LOG_INF("Color -> %s", color_names[color]);
/*
* 简单软件消抖:切换后 sleep 100ms,
* 机械按键抖动期(通常 5~20ms)内产生的重复中断会被自然跳过
*/
k_msleep(100);
}
/* 没事件时让出 CPU,避免空转 100% 占核 */
k_msleep(10);
}
atomic_cas(&button_pressed, 1, 0) 是一个原子比较并交换操作:如果当前值为 1 就设为 0 并返回 true,否则返回 false。这保证了即使中断连续到来,也只有第一个能成功消费,后续的 cas 会发现值已经是 0 而跳过——再加上 100ms 的软件消抖延时,双保险确保按一次只切一次颜色。
编译烧录效果
编译烧录后,你会看到:
- 板载 WS2812 初始亮红色
- 每按一次 BOOT 键,WS2812 依次切换:红 → 绿 → 蓝 → 黄 → 青 → 品红 → 白 → 灭 → 红…
- 串口日志打印每次切换的颜色名称:
Color -> green、Color -> blue…
常见问题
Q1:编译报错 undefined reference to gpio_pin_interrupt_configure_dt
- 原因:
prj.conf中漏了CONFIG_GPIO=y,GPIO 中断 API 没有被编译进去 - 解决:在
prj.conf中添加CONFIG_GPIO=y,重新编译
Q2:按键没反应
- 原因:设备树极性配置与触发沿不匹配。
GPIO_ACTIVE_LOW配合GPIO_INT_EDGE_TO_ACTIVE是正确组合,如果错写成了GPIO_ACTIVE_HIGH但实际按键是低电平有效,就永远不会触发 - 解决:确认
GPIO_PULL_UP+GPIO_ACTIVE_LOW+GPIO_INT_EDGE_TO_ACTIVE三者一致。按下 = 拉低 = active = 触发
Q3:按一次键切了多个颜色
- 原因:机械按键在按下和松开的瞬间会有 5~20ms 的触点抖动,导致短时间内触发多次中断
- 解决:在主循环中处理完一次后调用
k_msleep(100)做软件消抖,让抖动期内的重复中断自然跳过。100ms 远大于抖动时间,足够安全
总结
Zephyr GPIO 中断的核心就三步:配置方向 → 配置触发沿 → 注册回调。再配合"ISR 置标志、主线程消费"的上下半部设计,就能覆盖按键、传感器等大多数事件驱动场景。
希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇
更多推荐
所有评论(0)