【Zephyr|ESP32-S3】基础学习:定时器消抖

哈喽,我是余火,一个普通的牛马打工人,目前正在学如何使用Zephyr RTOS。

上篇把灯控逻辑拆成按键和灯效两个线程,用信号量替代轮询。末尾提了"按键抖动可以用定时器消抖"——这次来填坑。除了消抖,上篇灯效线程颜色切换用的 k_msleep 也一并换成定时器驱动。

改了哪些东西

在上一篇多线程工程的 prj.confoverlay 保持不变,改动全部集中在 src/main.c

文件 改了什么
src/main.c 新增两个 k_timer(灯效 + 消抖),ISR 改为启动消抖定时器,灯效线程改为定时器标志驱动

为什么要用定时器

你在按机械按键的时候,金属触点并不会瞬间稳稳接触。实际物理过程中,触点会在"接通"和"断开"之间来回弹跳多次,产生一串密集的脉冲信号。这个抖动期通常在 10~50ms 左右,如果不对信号做处理,你的 MCU 会把一次按键识别成多次。

💡 机械按键抖动是硬件的固有特性,无法消除,只能通过软件或硬件手段"滤除"多余脉冲,只保留最后一次稳定状态。

上篇的做法是 ISR 里直接 k_sem_give(),抖动期间每个脉冲都会释放一次信号量,虽然二值信号量上限为 1 不会重复累积,但按键线程会被多次唤醒,导致一次物理按键被处理成多次模式切换。更关键的是,上篇灯效线程用 k_msleep(50) 阻塞等待下次刷新,虽然间隔很短,但颜色切换的节奏完全靠 sleep 控制,不够灵活。

定时器能解决这两个问题:消抖定时器把抖动"延迟"掉,灯效定时器让颜色切换不再阻塞线程。

消抖方案对比

方案 原理 CPU占用 实时性 Zephyr实现
软件延时 检测到按键后 k_msleep(20~50ms) 再读取稳定值 ,延时期间线程阻塞 ,延时阻塞当前线程 k_msleep()
定时器 检测到按键后启动定时器,到期回调才处理 ,定时器不占线程 ,线程可同时处理其他任务 k_timer_start()
硬件RC滤波 在按键电路上并联电容,滤除高频抖动 (纯硬件) 最好,不涉及软件 外围电路设计

三种方案各有适用场景。在 RTOS 里,定时器消抖是最推荐的做法——不阻塞线程、CPU 占用低、代码量也不大。硬件 RC 滤波虽好但需要改板子,软件延时最简单但会阻塞线程。定时器方案在灵活性上胜出:消抖时间可以随时调整,而且定时器回调还能做更多事情。

定时器定义

Zephyr 提供 K_TIMER_DEFINE 宏在编译期静态创建内核定时器,无需手动初始化。

名称 用途 首次到期 重复周期 回调函数
effect_timer 常亮模式下定时切换颜色 500ms 500ms effect_timer_expiry_fn
debounce_timer 按键消抖,延迟释放信号量 50ms 不重复 debounce_timer_expiry_fn

K_TIMER_DEFINE 参数说明:

/*
 * K_TIMER_DEFINE(name, expiry_fn, stop_fn)
 *   name      — 定时器变量名
 *   expiry_fn — 定时器到期时调用的回调函数
 *   stop_fn   — 定时器被 k_timer_stop() 停止时调用(不需要传 NULL)
 */
K_TIMER_DEFINE(effect_timer, effect_timer_expiry_fn, NULL);
K_TIMER_DEFINE(debounce_timer, debounce_timer_expiry_fn, NULL);

这里还引入了 atomic_t 类型的 color_changed 标志,用于定时器回调和灯效线程之间的通信:

/*
 * 颜色切换标志:effect_timer 到期时置 1,effect_thread 读取后清 0
 * 使用 atomic_t 配合 atomic API,无需互斥锁即可安全地
 * 在定时器回调和线程间传递标志。
 */
static atomic_t color_changed;

💡 atomic_t 是 Zephyr 对原子操作的一层封装,底层对应编译器的内建原子指令。 两个执行上下文(定时器回调 vs 线程)只要保证对同一变量的操作是原子的,就不需要互斥锁。这里 color_changed 只有"置 1"和"读后清 0"两种操作,atomic_set / atomic_get 足以覆盖。

ISR 改造:定时器消抖

上篇 ISR 里直接调 k_sem_give(),本篇改成启动消抖定时器:

/*
 * button_pressed_cb — GPIO 中断回调函数(ISR 上下文)
 *
 * 按键按下时启动消抖定时器(50ms),ISR 安全。
 * k_timer_start() 是 ISR-safe API:不拿锁、不调度,
 * 仅设置定时器到期时间,ISR 里可以放心调用。
 */
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);

    /* 启动消抖定时器:50ms 后触发回调,滤除机械抖动 */
    k_timer_start(&debounce_timer, K_MSEC(50), K_NO_WAIT);
}

关键点k_timer_start() 是 ISR-safe API,在 ISR 里调用没有任何问题。50ms 的消抖延时和 K_NO_WAIT(不重复周期)意味着定时器只触发一次,到期后自动停止。

💡 k_timer_start 对已运行的定时器会重新设定过期时间——这就是消抖的核心原理。 按键抖动期间,每次中断都会调用 k_timer_start(&debounce_timer, K_MSEC(50), K_NO_WAIT),把到期时间重置为"从现在起 50ms 后"。只有最后一次真正稳定的按下事件,50ms 内不再有新中断,定时器才会真正到期。回调函数里的 k_sem_give() 因此只会被调用一次。

定时器回调函数

消抖回调

消抖定时器到期后,在回调中释放信号量:

/*
 * debounce_timer_expiry_fn — 消抖定时器到期回调
 *
 * 在系统 work queue 上下文中执行(非 ISR),
 * 可以安全调用 k_sem_give()。
 * 50ms 消抖延迟结束,释放信号量通知按键线程。
 */
static void debounce_timer_expiry_fn(struct k_timer *timer)
{
    ARG_UNUSED(timer);
    k_sem_give(&button_sem);
}

灯效回调

灯效定时器到期时,推进颜色索引并设置标志:

/*
 * effect_timer_expiry_fn — 灯效定时器到期回调
 *
 * 在系统 work queue 上下文中执行。
 * 每次到期推进颜色索引并设置 atomic 标志,
 * effect_thread 检测到标志后刷新灯效。
 */
static void effect_timer_expiry_fn(struct k_timer *timer)
{
    ARG_UNUSED(timer);
    color_idx = (color_idx + 1) % ARRAY_SIZE(colors);
    atomic_set(&color_changed, 1);
}

定时器回调的执行上下文

Zephyr 的定时器回调运行在系统 work queue 中,既不是 ISR,也不是用户线程。 三种执行上下文的对比如下:

特性 ISR 系统work queue 用户线程
能否阻塞 ❌ 绝对不行 ❌ 不建议 ✅ 可以
能否调用 LOG ❌ 不行 ✅ 可以 ✅ 可以
能否调 k_sem_give ✅ ISR-safe ✅ 可以 ✅ 可以
能否调 k_msleep ❌ 绝对不行 ❌ 不建议 ✅ 可以
优先级 最高 较低(系统级) 用户自定义
能否调 k_mutex_lock ❌ 不行 ✅ 可以 ✅ 可以

work queue 是 Zephyr 内核维护的一个后台线程,专门执行延迟工作。定时器到期时,内核把回调提交到这个队列,由 work queue 线程统一执行。这意味着回调里可以调用 LOG 打印日志,可以做信号量操作,但不能做阻塞调用(否则会阻塞整个 work queue,影响其他延迟工作的执行)。

💡 定时器是 RTOS 里非常实用的工具,应用场景远不止消抖: 超时检测(通信超时判断)、周期采样(定时读取传感器数据)、心跳包(定时发送 keep-alive)、软件 PWM(周期性翻转 GPIO)都可以用定时器实现。理解了它的回调上下文,你就能在各种场景下安全地使用它。

按键线程:定时器的启停控制

按键线程除了处理模式切换,还负责控制灯效定时器的启停:

/*
 * button_thread — 按键处理线程
 *
 * 等待消抖定时器释放的信号量 → 切换灯效模式 → 控制定时器启停
 */
void button_thread(void *p1, void *p2, void *p3)
{
    ARG_UNUSED(p1); ARG_UNUSED(p2); ARG_UNUSED(p3);

    while (1) {
        /* 阻塞等待消抖定时器释放的信号量 */
        k_sem_take(&button_sem, K_FOREVER);

        /* 加锁保护 led_mode 的读写 */
        k_mutex_lock(&mode_mutex, K_FOREVER);
        led_mode = (led_mode + 1) % 2;
        int current_mode = led_mode;
        k_mutex_unlock(&mode_mutex);

        LOG_INF("Mode -> %s", mode_names[current_mode]);

        if (current_mode == MODE_BREATHE) {
            brightness = 0;
            direction = 1;
            /* 呼吸模式不需要定时器换色,停止 effect_timer */
            k_timer_stop(&effect_timer);
        } else {
            /* 切回常亮模式时重启定时器,恢复自动换色
             * 首次 500ms 后到期,之后每 500ms 重复 */
            k_timer_start(&effect_timer, K_MSEC(500), K_MSEC(500));
        }
    }
}

呼吸模式时调用 k_timer_stop(&effect_timer) 停止灯效定时器,呼吸效果由线程自身的 k_msleep(20) 步进驱动。切回常亮模式时重新启动定时器,第一个参数是首次到期时间,第二个参数是重复周期。

灯效线程:定时器标志驱动

灯效线程的常亮模式改为检查定时器设置的 color_changed 标志:

/*
 * effect_thread — 灯效渲染线程
 *
 * 常亮模式:由 effect_timer 设置 color_changed 标志,
 *           线程检测到标志后刷新灯效(不再靠 k_msleep 阻塞)
 * 呼吸模式:不变,仍由线程内部 k_msleep(20) 步进驱动
 */
void effect_thread(void *p1, void *p2, void *p3)
{
    ARG_UNUSED(p1); ARG_UNUSED(p2); ARG_UNUSED(p3);

    while (1) {
        k_mutex_lock(&mode_mutex, K_FOREVER);
        int current_mode = led_mode;
        k_mutex_unlock(&mode_mutex);

        if (current_mode == MODE_STEADY) {
            /* 常亮模式:检查 color_changed 标志 */
            if (atomic_get(&color_changed)) {
                atomic_set(&color_changed, 0);
                push_color(color_idx);
            }
            /* 短休眠降低 CPU 占用,实际节奏由定时器 500ms 控制 */
            k_msleep(10);
        } else {
            /* 呼吸模式:亮度渐变,逻辑不变 */
            push_color_bright(color_idx, brightness);

            int tmp = (int)brightness + 4 * direction;
            if (tmp >= 255) {
                brightness = 255; direction = -1;
            } else if (tmp <= 0) {
                brightness = 0; direction = 1;
            } else {
                brightness = (uint8_t)tmp;
            }
            k_msleep(20);
        }
    }
}

常亮模式下线程用 k_msleep(10) 做短休眠(降低 CPU 占用),但真正的颜色切换节奏由定时器的 500ms 周期控制。这样线程不会长时间阻塞,可以更快响应模式切换。

main 函数:启动定时器

主函数增加了一行定时器启动代码:

int main(void)
{
    /* 设备就绪检查和 GPIO 配置(与上篇相同)... */

    /* 初始点亮第一种颜色 */
    push_color(color_idx);

    /* 启动灯效定时器:首次 500ms 到期,之后每 500ms 重复 */
    k_timer_start(&effect_timer, K_MSEC(500), K_MSEC(500));

    LOG_INF("Press BOOT button to toggle mode (steady/breathe)");
    return 0;
}

编译烧录与效果

编译烧录后的效果:

  1. 上电后:WS2812 从红色开始,每 500ms 自动切换颜色(红→绿→蓝→黄→青→品红→白→灭→红…),由 effect_timer 驱动
  2. 按下 BOOT 键:经过 50ms 消抖后切换到呼吸模式,effect_timer 停止,亮度在 0~255 之间渐变
  3. 再按一次:切回常亮模式,effect_timer 重新启动,颜色继续自动切换
  4. 快速连按:消抖定时器会重置,只有最后一次稳定的按下才会触发模式切换,不会出现"按一下切两次"的情况

打开串口日志可以看到每次模式切换的记录:

[00:00:00.123] <inf> main: WS2812 strip: 1 pixel(s)
[00:00:00.125] <inf> main: Press BOOT button to toggle mode (steady/breathe)
[00:00:02.340] <inf> main: Mode -> breathe
[00:00:05.120] <inf> main: Mode -> steady

常见问题

Q:k_timer_start 对已经运行的定时器会怎样?

A:会重新设定过期时间。 这正是消抖的关键——抖动期间每次中断都重置定时器的 50ms 倒计时,只有最后一次稳定后定时器才真正到期。不需要手动先 stop 再 start。

Q:定时器回调里能调用 LOG 吗?

A:可以。 Zephyr 的定时器回调运行在系统 work queue 线程中,不是 ISR 上下文。work queue 线程可以安全地调用 LOG、k_sem_give、k_mutex_lock 等大部分内核 API。但不能做阻塞操作(k_msleep、k_sem_take),否则会阻塞整个 work queue。

Q:为什么消抖不用 k_msleep 而要用定时器?

A:k_msleep 会阻塞当前线程。如果在线程里 k_msleep(50) 消抖,这 50ms 内该线程什么都干不了。而定时器是异步的——ISR 里启动定时器后立即返回,线程继续做自己的事情,50ms 后回调自动执行。在 RTOS 里,非阻塞是基本原则

Q:color_changed 为什么用 atomic_t 而不用 k_mutex

A:atomic_t 是轻量级原子变量,适合简单的"置位/读后清零"场景,开销远小于互斥锁。这里定时器回调只做 atomic_set(&color_changed, 1),线程只做 atomic_get + atomic_set(&color_changed, 0),都是单步原子操作,互斥锁没必要。

总结

本篇用 k_timer_start 的重置特性实现消抖,用定时器+atomic_t标志驱动灯效颜色切换,把"阻塞等待"升级为非阻塞的事件驱动。同样这套定时器三件套(定义→回调→启停)可以直接套用到通信超时检测、传感器周期采样、心跳包发送等场景。


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

Logo

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

更多推荐