【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 再回到之前被打断的地方继续执行。

这种"事件驱动"的方式相比"轮询检测"有两个巨大优势:

  1. 响应速度快——引脚电平变化的瞬间就会触发,不需要等到下一次轮询周期
  2. 节省 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)默认已经 selectCONFIG_GPIO,所以你不写这一行也能编译通过。但显式写出来有两个好处:

  1. 一目了然——读者一眼就能看到"要用 GPIO 中断,必须确保 CONFIG_GPIO=y"
  2. 可移植性——移植到其他 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 中断配置三步骤:

  1. 配置方向——gpio_pin_configure_dt() 设置为输入模式
  2. 配置触发沿——gpio_pin_interrupt_configure_dt() 指定触发条件
  3. 注册回调——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 的软件消抖延时,双保险确保按一次只切一次颜色。

编译烧录效果

编译烧录后,你会看到:

  1. 板载 WS2812 初始亮红色
  2. 每按一次 BOOT 键,WS2812 依次切换:红 → 绿 → 蓝 → 黄 → 青 → 品红 → 白 → 灭 → 红…
  3. 串口日志打印每次切换的颜色名称:Color -> greenColor -> 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 置标志、主线程消费"的上下半部设计,就能覆盖按键、传感器等大多数事件驱动场景。


希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐