# button_v2 **Repository Path**: njit-sam/button_v2 ## Basic Information - **Project Name**: button_v2 - **Description**: 按键事件检测,支持单击,双击,长按,长按连续触发,组合键 - **Primary Language**: C - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-04-30 - **Last Updated**: 2026-04-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # button_v2 #### 介绍 按键事件检测,支持单击,双击,长按,长按连续触发,组合键 # 按键模块说明 ## 1. 模块功能 这是一个通用的嵌入式按键驱动模块,使用 `C` 语言实现,适合单片机、裸机或 RTOS 项目移植。 当前支持: - 最多 `5` 个按键 - 按键变化由 GPIO 中断触发上报 - 消抖 - 单击 - 双击 - 长按 - 长按连续触发 - 组合键 - 事件注册为回调函数 - 只处理已注册的回调事件 设计目标是: - 中断里不做复杂逻辑 - 时序判断统一放到 `1ms` 周期任务里 - 业务回调不在中断里执行 ## 2. 整体架构 模块分成三层: 1. GPIO 中断触发层 2. `1ms` 按键状态机层 3. 事件分发与回调层 完整流程如下: 1. 按键电平发生变化 2. 外部中断触发 3. 中断服务函数里只调用 `key_isr_notify(key_id)` 4. `key_task_1ms()` 在每个 `1ms` 周期里读取实际 GPIO 电平,进行消抖和事件判定 5. 识别出的事件先放入内部队列 6. 主循环调用 `key_process()` 7. `key_process()` 从队列中取出事件,只调用已经注册过的回调函数 核心思想可以概括成一句话: `中断负责发现变化,1ms任务负责确认变化,主循环负责执行回调` ## 3. 为什么要这样设计 如果把所有逻辑都放到中断里,会出现几个问题: - 中断里做消抖很麻烦 - 双击、长按这类依赖时间窗口的逻辑不好维护 - 回调函数如果在中断里执行,容易拖长中断时间 - 业务代码可能调用不适合在中断中使用的接口 如果完全使用轮询,又不符合“按键按下由中断触发判断”的需求。 所以这里采用折中方案: - 中断只负责告诉软件“这个按键可能变了” - `1ms` 任务负责判断“它到底是不是稳定按下/释放,以及对应什么事件” 这个结构兼顾了: - 响应及时 - 逻辑清晰 - 时序稳定 - 易于移植 ## 4. 主要数据结构 ### 4.1 单键控制块 `key_ctrl_t` 每个按键对应一个 `key_ctrl_t`,内部保存了这个按键的全部运行状态。 重要字段如下: - `id` 说明:按键 ID - `active_level` 说明:按下有效电平。若按键按下时 GPIO 为高电平,则用 `KEY_LEVEL_PRESSED`;若按下为低电平,则用 `KEY_LEVEL_RELEASED` - `stable_pressed` 说明:消抖之后的稳定状态,`1` 表示当前稳定按下,`0` 表示当前稳定释放 - `irq_pending` 说明:中断是否已经上报过“该按键可能变化” - `stage` 说明:当前按键状态机所处阶段 - `debounce_cnt` 说明:消抖计数器 - `click_wait_cnt` 说明:单击/双击等待计数器 - `repeat_cnt` 说明:长按连续触发间隔计数器 - `press_tick` 说明:按键稳定按下的时间戳 - `release_tick` 说明:按键稳定释放的时间戳 - `callbacks[event_type]` 说明:某个按键针对不同事件类型的回调函数表 - `callback_args[event_type]` 说明:对应回调的用户参数 ### 4.2 组合键控制块 `combo_ctrl_t` 每个组合键对应一个 `combo_ctrl_t`。 主要字段如下: - `combo_id` 说明:组合键编号 - `key_ids[]` 说明:参与该组合键的按键 ID 列表 - `key_count` 说明:组合键包含多少个按键 - `active_latched` 说明:组合键在当前这次按住过程中是否已经触发过 - `callback` 说明:组合键事件回调函数 - `callback_arg` 说明:组合键回调参数 ### 4.3 事件队列 `key_event_t` 事件不会在识别后立刻执行回调,而是先进入队列。 事件结构包含: - `key_id` - `type` - `tick` - `combo_mask` 这样做的目的: - 状态机只负责识别事件 - 主循环只负责处理回调 - 事件识别和业务处理相互解耦 ## 5. 状态机设计 每个按键都运行一个独立状态机,共 4 个状态: 1. `KEY_STAGE_IDLE` 2. `KEY_STAGE_DEBOUNCE_PRESS` 3. `KEY_STAGE_PRESSED` 4. `KEY_STAGE_DEBOUNCE_RELEASE` ### 5.1 空闲态 `KEY_STAGE_IDLE` 含义: - 当前认为按键处于稳定释放状态 处理逻辑: - 如果中断上报了变化,并且当前采样结果为按下,则进入“按下消抖态” - 如果中断来了,但当前读出来并不是按下,则清除中断标志,继续保持空闲态 为什么要这样做: - 中断只是提示电平可能变化 - 不能直接根据中断边沿认定已经按下 ### 5.2 按下消抖态 `KEY_STAGE_DEBOUNCE_PRESS` 含义: - 软件怀疑按键被按下,但还没有确认 处理逻辑: - 每隔 `1ms` 读一次 GPIO - 如果连续读到“按下”状态,就让 `debounce_cnt` 递增 - 当 `debounce_cnt >= debounce_ms` 时,认为按下稳定成立 - 这时产生 `KEY_EVENT_PRESS`,然后进入稳定按下态 - 如果中途又读到释放,说明刚才是抖动,直接回到空闲态 ### 5.3 稳定按下态 `KEY_STAGE_PRESSED` 含义: - 按键已经被确认稳定按下 这个状态里会处理三件事: 1. 判断是否达到长按时间 2. 判断是否进入长按连续触发 3. 等待释放中断 这是整个模块最核心的状态。 ### 5.4 释放消抖态 `KEY_STAGE_DEBOUNCE_RELEASE` 含义: - 软件怀疑按键已经松开,但还没有确认 处理逻辑: - 每 `1ms` 检查一次是否持续处于释放状态 - 当释放持续达到 `debounce_ms` 后,认为释放成立 - 这时产生 `KEY_EVENT_RELEASE` - 如果中途电平又回到按下,说明是释放抖动,退回稳定按下态 ## 6. 消抖原理 机械按键在按下和释放瞬间都可能发生抖动。 所以这里的消抖不是“来了中断就算一次”,而是: - 中断作为触发信号 - `1ms` 周期里连续采样 - 只有状态持续保持一段时间,才认为真的发生了变化 按下消抖: 1. 收到按下相关中断 2. 进入 `KEY_STAGE_DEBOUNCE_PRESS` 3. 连续 `debounce_ms` 个周期都读到按下 4. 才确认按下成立 释放消抖: 1. 收到释放相关中断 2. 进入 `KEY_STAGE_DEBOUNCE_RELEASE` 3. 连续 `debounce_ms` 个周期都读到释放 4. 才确认释放成立 这种方式的优点是稳定,而且不依赖具体芯片的中断毛刺特性。 ## 7. 单击与双击判定原理 这部分的关键点在于: 第一次短按释放后,系统并不能立刻确定这是“单击”,因为它还有可能是“双击的第一下”。 所以实现方式是延迟确认单击。 ### 7.1 第一次短按释放 当一个按键短按后释放: - 立即产生 `KEY_EVENT_RELEASE` - 设置 `click_waiting = 1` - 将 `click_wait_cnt` 清零并开始计时 此时先不产生 `KEY_EVENT_CLICK`。 ### 7.2 在等待窗口内没有第二次按下 如果在 `double_click_ms` 时间内没有第二次稳定按下: - 判定为单击 - 产生 `KEY_EVENT_CLICK` - 清除等待状态 ### 7.3 在等待窗口内发生第二次按下 如果第二次按下在超时前发生: - 把 `second_press_down` 置位 等第二次释放也稳定成立后: - 产生 `KEY_EVENT_DOUBLE_CLICK` - 清除等待状态 ### 7.4 为什么单击会“延后” 因为单击必须等到双击窗口结束后才能最终确认。 这是双击识别中常见且必要的处理方式,不是缺陷。 ## 8. 长按与长按连续触发原理 ### 8.1 长按 当按键进入稳定按下态后: - 记录 `press_tick` - 后续每个 `1ms` 周期都计算当前时间与 `press_tick` 的差值 如果: `当前时间 - press_tick >= long_press_ms` 则认为长按成立,产生: - `KEY_EVENT_LONG_PRESS` 同时还会取消单击/双击等待状态,因为长按不应再被视为点击事件。 ### 8.2 长按连续触发 长按成立后,还可以继续判断是否进入连发模式。 当按住时间达到: `long_repeat_start_ms` 时,产生第一次: - `KEY_EVENT_LONG_REPEAT` 之后每间隔: `long_repeat_interval_ms` 再产生一次 `KEY_EVENT_LONG_REPEAT` 这个功能适合: - 音量连续加减 - 菜单连续翻页 - 参数连续调节 ## 9. 组合键原理 组合键的判定在 `key_scan_combo_events()` 中完成。 它的执行时机是: - 每次 `key_task_1ms()` 完成所有单键状态更新后 - 再统一检查所有已注册的组合键 ### 9.1 判定逻辑 对于每个组合键: 1. 遍历该组合中所有按键 2. 判断这些按键是否全部处于 `stable_pressed == 1` 3. 如果全部都按下,并且此前没有触发过,则产生 `KEY_EVENT_COMBO` 4. 并将 `active_latched = 1` ### 9.2 为什么要加锁存 `active_latched` 因为如果不锁存: - 只要两个键一直按着 - 每次 `1ms` 扫描都会重复产生一次组合键事件 加入锁存后: - 同一轮持续按住过程中,只触发一次组合键事件 当组合中的任意一个键释放后: - `active_latched` 会被清零 下一次再同时按下时,组合键才能重新触发。 ### 9.3 当前版本的组合键策略 当前代码已经改成: - 组合键优先 - 组合键一旦成立,就占用参与该组合的成员键 具体行为是: 1. 当组合键所有成员都进入稳定按下后,产生 `KEY_EVENT_COMBO` 2. 同时把该组合中的所有成员键标记为 `combo_suppressed` 3. 清除这些成员键的点击等待状态 4. 在本轮按住期间,成员键不再产生以下单键事件: - `KEY_EVENT_RELEASE` - `KEY_EVENT_CLICK` - `KEY_EVENT_DOUBLE_CLICK` - `KEY_EVENT_LONG_PRESS` - `KEY_EVENT_LONG_REPEAT` 这样就避免了你提到的问题: - 触发组合键时,不希望成员键又额外触发单击或长按事件 需要注意一件事: - `KEY_EVENT_PRESS` 是在单键按下稳定后立即产生的 - 如果组合键是在后续才成立,那么此前已经发出的 `KEY_EVENT_PRESS` 不会被回滚 也就是说,当前策略是: - 屏蔽组合成立后的后续单键事件 - 不回滚组合成立前已经分发出去的底层 `PRESS` 事件 如果你的产品连 `PRESS` 都不希望出现,那么单键按下事件本身也需要延迟判定,这会把整个策略再复杂一层。 ## 10. 回调注册与事件分发原理 这是这版代码相对前一版最重要的变化。 你提出的要求是: `把事件注册成回调函数,只处理注册的回调函数事件` 当前实现正是按这个原则设计的。 ### 10.1 为什么不用直接轮询全部事件 如果把所有事件都暴露给应用层轮询: - 应用层还要自己判断关不关心某个事件 - 主循环里会出现很多 `switch-case` - 逻辑分散,不利于维护 改成回调注册后: - 只注册你关心的事件 - 没注册的事件直接忽略 - 业务层更清晰 ### 10.2 单键事件回调表 每个按键都带一个: - `callbacks[KEY_EVENT_COUNT]` - `callback_args[KEY_EVENT_COUNT]` 含义是: - 一个按键的每种事件类型,都可以绑定一个回调 例如: - `KEY_ID_UP` 注册单击 - `KEY_ID_UP` 注册双击 - `KEY_ID_OK` 注册长按 - `KEY_ID_OK` 注册长按连发 它们互不影响。 ### 10.3 组合键回调 组合键单独用: - `key_register_combo_callback()` 进行注册。 组合键事件与单键事件分开分发。 ### 10.4 分发流程 当状态机识别出事件后: 1. 先调用 `key_push_event()` 放入内部队列 2. 主循环调用 `key_process()` 3. `key_process()` 不断从队列中取事件 4. 调用 `key_dispatch_event()` 5. `key_dispatch_event()` 查找该事件是否有对应回调 6. 如果有,就执行回调 7. 如果没有,就直接忽略 这正好实现了“只处理已注册事件”的需求。 ## 11. 为什么回调不在中断里执行 这是非常关键的工程实践。 如果在中断里直接调用回调,会有这些风险: - 回调执行时间不可控 - 可能影响其他中断实时性 - 回调可能调用串口打印、消息发送、内存操作等非中断安全接口 - 整体调试难度更高 当前设计中: - `key_isr_notify()` 只置位 `irq_pending` - `key_task_1ms()` 只做状态判断 - `key_process()` 才真正调用用户回调 因此回调运行在正常上下文,不在中断上下文,安全性更高。 ## 12. 各个接口的职责 ### 12.1 `key_init()` ```c void key_init(key_read_level_fn read_fn, const key_time_config_t *time_cfg); ``` 作用: - 初始化整个模块 - 保存 GPIO 读取函数 - 保存时序参数 ### 12.2 `key_register()` ```c void key_register(uint8_t key_id, key_level_t active_level); ``` 作用: - 注册一个按键 - 指定其按下有效电平 ### 12.3 `key_register_combo()` ```c void key_register_combo(uint8_t combo_id, const uint8_t *key_ids, uint8_t key_count); ``` 作用: - 注册一个组合键 ### 12.4 `key_register_callback()` ```c void key_register_callback(uint8_t key_id, key_event_type_t event_type, key_event_callback_t callback, void *user_arg); ``` 作用: - 给某个单键的某种事件绑定回调函数 ### 12.5 `key_register_combo_callback()` ```c void key_register_combo_callback(uint8_t combo_id, key_event_callback_t callback, void *user_arg); ``` 作用: - 给组合键绑定回调函数 ### 12.6 `key_isr_notify()` ```c void key_isr_notify(uint8_t key_id); ``` 作用: - 在 GPIO 中断里告诉模块“这个按键状态可能变化了” 注意: - 这里只通知,不做消抖 - 这里不允许直接执行业务回调 ### 12.7 `key_task_1ms()` ```c void key_task_1ms(void); ``` 作用: - 运行整个按键状态机 - 完成消抖 - 识别单击、双击、长按、连发、组合键 - 把识别到的事件压入队列 ### 12.8 `key_process()` ```c void key_process(void); ``` 作用: - 从事件队列中取出事件 - 调用已注册的回调函数 - 未注册的事件自动丢弃 ### 12.9 `key_is_pressed()` 和 `key_get_pressed_mask()` ```c uint8_t key_is_pressed(uint8_t key_id); uint32_t key_get_pressed_mask(void); ``` 作用: - 作为辅助状态查询接口使用 ## 13. 典型接入方式 ### 13.1 实现 GPIO 读取函数 ```c static uint8_t board_key_read(uint8_t key_id) { return read_gpio_level_somehow(key_id); } ``` ### 13.2 初始化参数 ```c key_time_config_t cfg; cfg.debounce_ms = 20U; cfg.double_click_ms = 250U; cfg.long_press_ms = 800U; cfg.long_repeat_start_ms = 1000U; cfg.long_repeat_interval_ms = 200U; key_init(board_key_read, &cfg); ``` ### 13.3 注册 5 个按键 ```c key_register(KEY_ID_UP, KEY_LEVEL_PRESSED); key_register(KEY_ID_DOWN, KEY_LEVEL_PRESSED); key_register(KEY_ID_LEFT, KEY_LEVEL_PRESSED); key_register(KEY_ID_RIGHT, KEY_LEVEL_PRESSED); key_register(KEY_ID_OK, KEY_LEVEL_PRESSED); ``` 如果你的硬件是低电平按下,那么应改为: ```c key_register(KEY_ID_UP, KEY_LEVEL_RELEASED); ``` 原因是: - 当 GPIO 读到低电平时表示按下 - 此时“按下有效电平”就是 `0` ### 13.4 注册组合键 ```c uint8_t combo_keys[] = {KEY_ID_UP, KEY_ID_OK}; key_register_combo(COMBO_ID_UP_OK, combo_keys, 2U); ``` ### 13.5 注册你关心的回调 ```c key_register_callback(KEY_ID_UP, KEY_EVENT_CLICK, on_click, 0); key_register_callback(KEY_ID_UP, KEY_EVENT_DOUBLE_CLICK, on_double_click, "UP"); key_register_callback(KEY_ID_OK, KEY_EVENT_LONG_PRESS, on_long_press, 0); key_register_callback(KEY_ID_OK, KEY_EVENT_LONG_REPEAT, on_long_repeat, 0); key_register_combo_callback(COMBO_ID_UP_OK, on_combo, "UP+OK"); ``` 只有这些注册过的事件才会进入业务处理。 ### 13.6 在 GPIO 中断里调用 ```c void key_gpio_exti_callback(uint8_t key_id) { key_isr_notify(key_id); } ``` 建议使用双边沿中断,这样按下和释放都能及时上报。 ### 13.7 在 `1ms` 定时中断里调用 ```c void systick_1ms_callback(void) { key_task_1ms(); } ``` ### 13.8 在主循环里调用 ```c for (;;) { key_process(); } ``` ## 14. 几个典型时序 ### 14.1 单击 时序如下: 1. 按下中断触发 2. 进入按下消抖 3. 按下稳定,产生 `KEY_EVENT_PRESS` 4. 释放中断触发 5. 进入释放消抖 6. 释放稳定,产生 `KEY_EVENT_RELEASE` 7. 进入双击等待窗口 8. 超时后没有第二次按下 9. 产生 `KEY_EVENT_CLICK` ### 14.2 双击 时序如下: 1. 第一次按下稳定 2. 第一次释放稳定 3. 进入点击等待窗口 4. 第二次按下在超时前发生 5. 第二次释放稳定 6. 产生 `KEY_EVENT_DOUBLE_CLICK` ### 14.3 长按与长按连发 时序如下: 1. 按下稳定 2. 持续按住达到 `long_press_ms` 3. 产生 `KEY_EVENT_LONG_PRESS` 4. 继续按住达到 `long_repeat_start_ms` 5. 产生第一次 `KEY_EVENT_LONG_REPEAT` 6. 后续每隔 `long_repeat_interval_ms` 再产生一次 `KEY_EVENT_LONG_REPEAT` ### 14.4 组合键 时序如下: 1. 组合中的按键 A 稳定按下 2. 组合中的按键 B 稳定按下 3. 所有成员都处于稳定按下 4. 产生 `KEY_EVENT_COMBO` 5. 持续按住不会重复产生组合事件 6. 任意成员释放后,组合锁存解除 ## 15. 当前实现的局限 当前版本还有几个已知限制: 1. 事件队列满了以后,新事件会被静默丢弃 2. 组合键虽然会屏蔽成员键后续的点击、长按、释放等事件,但不会回滚组合成立前已经发出的 `PRESS` 3. 一个按键的一种事件类型当前只能绑定一个回调 4. 模块假设 `key_task_1ms()` 能比较准确地每 `1ms` 调用一次 ## 16. 后续可扩展方向 如果你后面还要继续增强,这几个方向最实用: 1. 连 `PRESS` 也纳入组合键优先策略 2. 队列溢出统计 3. 一个事件支持多个订阅回调 4. 每个按键独立配置消抖和长按参数 5. 长按时做加速连发 ## 17. 文件说明 - `key.h` 说明:对外接口、类型定义、回调注册 API - `key.c` 说明:状态机、消抖、组合键、事件队列、回调分发 - `main.c` 说明:一个 5 键回调式使用示例 ## 18. 总结 这个模块的实现原理可以压缩成五句话: 1. 中断只负责上报按键可能变化 2. `1ms` 周期任务负责做消抖和状态机判定 3. 所有识别出的事件先进入内部队列 4. 主循环统一分发事件 5. 只有注册过的回调才会被执行 这种设计简单、稳定,而且适合真实嵌入式项目直接落地。