嵌入式按键驱动设计:基于比特位状态机与异步回调的轻量级解决方案
1. 项目概述与设计动机在嵌入式开发里按键处理是个老生常谈但又极其磨人的基础活儿。但凡做过几个项目的朋友估计都自己手搓过按键驱动从最简陋的延时消抖到状态机轮子再到带双击、长按的复杂逻辑代码越写越乱状态越理越晕。特别是当项目里需要十几个按键每个按键还要支持短按、长按、连击甚至组合键时那种在中断和主循环里到处塞if-else的酸爽谁写谁知道。EmbeddedButton这个模块就是在这种背景下我为了把自己和团队从重复造轮子和维护地狱里解放出来折腾出来的一个轻量级按键驱动。它的核心目标就三个简单、灵活、可靠。简单到五分钟就能接入一个新按键灵活到按键事件类型可以无限组合拓展你想定义“三击后长按”这种奇葩操作都行可靠到其内部的状态判断逻辑基于几个非常清晰的原则杜绝了模棱两可的误触发。整个模块用纯C实现没有依赖任何特定操作系统或硬件平台从8位单片机到32位ARM Cortex-M系列都能跑得飞起。它采用异步回调也就是中断的思想但在主循环中实现来处理按键事件这样你的应用层代码就不用再操心“现在按键是什么状态”了只需要告诉驱动“当发生某种按键事件时请调用我这个函数”程序结构瞬间清爽。2. 核心设计思想与架构解析2.1 状态记录的“比特位魔法”EmbeddedButton最巧妙的设计在于它如何用极小的开销来记录一个按键复杂的历史状态。传统方法可能需要一堆flag变量is_pressed,is_long_press_detected,click_count... 不仅变量多状态组合判断也复杂。EmbeddedButton的解决方案是使用一个state_bits变量通常是uint16_t或uint32_t的比特位来记录。每一个比特位代表一个特定的时间片或事件标记。简单来说它把时间轴“切片”了。假设我们以5ms为一个时间单位即调用button_ticks()的周期。当按键被按下时对应的比特位被置1松开时置0。驱动内部维护一个比特位“窗口”随着时间推移这个窗口不断向左或向右滑动通过位运算实现。那么如何判断“短按”呢可以定义为在某个时间窗口内比如20ms到500ms出现了一个“1”的脉冲。如何判断“长按”呢可以定义为连续多个时间片比如超过100个即500ms比特位都为1。双击呢就是在一个特定时间间隔内出现了两个“1”的脉冲。这种方法的精妙之处在于将时间维度上的状态判断转化为了对固定比特位模式的匹配这非常适合用高效的位操作和查表法来实现而且扩展性极强理论上可以通过定义更复杂的比特模式来识别任意序列的按键事件。2.2 数据驱动与异步回调架构第二个核心思想是数据驱动。驱动本身不关心你的按键具体做了什么它只负责识别出“单击”、“长按开始”、“双击”等抽象事件。这些事件具体对应什么操作完全由使用者通过一个“事件-回调函数”映射表来配置。const key_value_map_t button1_map[] { {.key_value SINGLE_CLICK_KV, .kv_func_cb single_click_handle}, {.key_value LONG_PRESS_START_KV, .kv_func_cb long_press_start_handle}, // ... 更多事件 };这种设计带来了巨大的灵活性解耦按键扫描逻辑和业务逻辑彻底分离。驱动模块非常稳定业务逻辑怎么变都不需要改驱动。可配置性同一个物理按键在不同产品模式下可以绑定不同的功能表实现“一键多用”。易于调试你可以很容易地通过日志打印出触发的key_value来确认驱动是否按预期工作。异步回调机制是数据驱动的自然延伸。驱动在后台通常在一个定时器中断或主循环的定时任务中默默扫描所有按键状态。一旦识别出一个有效事件它不会立刻执行你的业务代码而是将这个事件“通知”出去。在EmbeddedButton里这个“通知”就是调用你事先注册好的回调函数。这避免了在扫描函数中直接调用可能耗时的业务函数保证了扫描时序的稳定性也符合嵌入式系统里“快进快出”的中断处理原则。2.3 面向对象的结构体封装尽管是用C语言编写但模块采用了面向对象的思想来管理每个按键。每个按键都是一个独立的对象拥有自己的全部属性封装在一个结构体button_obj_t中。typedef struct button_obj_t { uint8_t debounce_cnt : 4; // 消抖计数器使用位域节省空间 uint8_t active_level : 1; // 有效触发电平0或1 uint8_t read_level : 1; // 当前读取到的电平 uint8_t read_level_update : 1; // 电平更新标志 uint8_t event_analyze_en : 1; // 事件分析使能标志 uint8_t id; // 按键ID用于区分多个按键 uint16_t ticks; // 状态持续计时器单位与调用周期一致 state_bits_type_t state_bits; // 状态比特位记录 key_value_type_t key_value; // 当前计算出的键值 uint8_t (*read_button_func_ptr)(uint8_t button_id); // 读GPIO的函数指针 const key_value_map_t *map_ptr; // 指向事件-回调映射表的指针 size_t map_size; // 映射表大小 struct button_obj_t *next; // 指向下一个按键对象的指针构成链表 } button_obj_t;这种设计的好处显而易见独立性每个按键的参数如消抖时间、有效电平和回调表都是独立的互不干扰。可扩展性通过next指针所有按键对象被链接成一个链表。驱动只需要遍历这个链表就能处理所有按键实现“无限扩展”。新增一个按键就是malloc或静态分配一个结构体然后把它链入链表。资源清晰所有与某个按键相关的数据都聚集在一起管理起来非常清晰也便于在调试器中观察。3. 关键实现细节与源码剖析3.1 按键对象的初始化与启动使用EmbeddedButton的第一步是创建并初始化按键对象。button_init函数是这个过程的枢纽。// 伪代码展示核心逻辑 button_status_t button_init(button_obj_t *btn, uint8_t (*read_pin_func)(uint8_t), uint8_t active_level, uint8_t button_id, const key_value_map_t *map, size_t map_size) { // 1. 参数检查 if (btn NULL || read_pin_func NULL || map NULL) return ERROR; // 2. 初始化结构体成员 memset(btn, 0, sizeof(button_obj_t)); // 清零确保所有标志位为0 btn-read_button_func_ptr read_pin_func; btn-active_level active_level; btn-id button_id; btn-map_ptr map; btn-map_size map_size; btn-event_analyze_en 1; // 默认使能事件分析 // 3. 读取初始电平并进行消抖预处理 btn-read_level read_pin_func(button_id); // 如果初始电平就是有效电平可能需要特殊处理防止一上电就触发 // 通常这里会连续读取几次确保稳定 // 4. 将当前电平作为初始稳定状态记录到 state_bits 的某一位 // 具体实现取决于位记录策略 return SUCCESS; }关键点解析read_pin_func这是一个函数指针驱动通过它来读取硬件GPIO的电平。这样的设计使得驱动与硬件平台完全解耦。无论你是直接操作寄存器、使用HAL库还是RTOS的GPIO API只需要提供一个统一的读取函数即可。active_level有效触发电平。比如你的按键是低电平有效按下时GPIO读回0那么这里就填0。驱动内部所有的逻辑判断都基于这个“有效电平”概念而不是固定的0或1适应性更强。button_id当多个按键共享同一个read_pin_func时这个ID可以用来在函数内部区分具体是哪个按键。如果每个按键都有独立的读取函数这个参数可以忽略。初始化完成后需要调用button_start将按键对象添加到驱动的管理链表中。这个函数通常很简单就是将btn-next指向当前链表头然后更新链表头指针。3.2 心脏节拍button_ticks()函数这是整个驱动的引擎必须在一个稳定的定时中断或主循环定时任务中调用周期推荐为5-20ms。它的主要工作就是遍历按键链表对每个按键执行以下步骤电平读取与消抖uint8_t current_level btn-read_button_func_ptr(btn-id); if (current_level ! btn-read_level) { btn-debounce_cnt; if (btn-debounce_cnt DEBOUNCE_TICKS) { // 例如 DEBOUNCE_TICKS2 btn-read_level current_level; btn-read_level_update 1; // 标记电平已变化 btn-debounce_cnt 0; } } else { btn-debounce_cnt 0; // 电平稳定清零消抖计数器 }消抖原理机械按键在按下或释放的瞬间会产生一段时间的抖动电平快速跳变。消抖的目的就是忽略这段不稳定期。这里采用的是“计时消抖”只有当检测到的电平变化持续了足够长的时间DEBOUNCE_TICKS * tick周期才认为是一次有效的状态变化。DEBOUNCE_TICKS设为2在5ms周期下就能过滤掉10ms内的抖动对于大多数按键足够了。状态比特位更新 一旦确认电平有效更新read_level_update 1就需要更新state_bits。假设我们使用一个16位的state_bits并采用“左移入新位”的策略if (btn-read_level_update) { btn-state_bits 1; // 整体左移一位最旧的状态移出 // 将当前稳定电平是否等于有效电平填入最低位 btn-state_bits | ((btn-read_level btn-active_level) ? 1 : 0); btn-read_level_update 0; // 清除更新标志 btn-ticks 0; // 状态变化计时器清零用于长按等计时判断 } else { // 状态未变化tick计数器增加用于长按计时 if (btn-read_level btn-active_level) { btn-ticks; } }state_bits就像一个长度为16的时间窗记录了最近16个时间片5ms*1680ms内按键的稳定状态。这个窗口是分析连击、短按、长按的基础。事件分析与键值计算 这是驱动最核心的算法部分。它分析当前的state_bits和ticks计算出当前的key_value。key_value是一个枚举值或宏定义代表“无事件”、“单击”、“长按开始”、“长按结束”、“双击”等。短按/单击判断在state_bits中寻找一个“1”的脉冲并且这个脉冲的宽度连续1的个数在一个预设的范围内如对应20ms-500ms。同时脉冲结束后需要经过一段“空闲时间”确认没有后续按键。长按判断ticks计数器持续增长当超过“长按触发阈值”如对应1000ms时产生LONG_PRESS_START_KV事件。在长按期间可以持续产生LONG_PRESS_HOLD_KV事件如果支持直到按键释放产生LONG_PRESS_END_KV。连击判断在state_bits中寻找多个“1”的脉冲且脉冲之间的间隔0的个数在允许的连击间隔内。这需要更复杂的模式匹配。回调函数触发 计算出key_value后遍历该按键的map_ptr映射表寻找匹配的键值。如果找到且回调函数不为空则调用该函数。for (size_t i 0; i btn-map_size; i) { if (btn-map_ptr[i].key_value btn-key_value) { if (btn-map_ptr[i].kv_func_cb ! NULL) { btn-map_ptr[i].kv_func_cb((void*)btn); // 将按键对象指针作为参数传入 } break; // 通常一个键值只对应一个回调找到即退出 } }将btn指针传给回调函数非常有用回调函数可以从中获取按键ID等信息实现一个回调处理多个按键。3.3 键值映射与回调函数设计映射表key_value_map_t是连接驱动和应用的桥梁。它的设计直接影响使用的便利性。// 建议的键值枚举清晰明了 typedef enum { BUTTON_EVENT_NONE 0, BUTTON_EVENT_SINGLE_CLICK, // 单击 BUTTON_EVENT_DOUBLE_CLICK, // 双击 BUTTON_EVENT_TRIPLE_CLICK, // 三击 BUTTON_EVENT_LONG_PRESS_START, // 长按开始 BUTTON_EVENT_LONG_PRESS_HOLD, // 长按保持可周期性触发 BUTTON_EVENT_LONG_PRESS_END, // 长按结束 BUTTON_EVENT_SINGLE_CLICK_THEN_LONG_PRESS, // 单击后紧接着长按 // ... 其他复合事件 } button_event_t; // 映射表定义 const key_value_map_t power_button_map[] { {BUTTON_EVENT_SINGLE_CLICK, power_on_off_handler}, {BUTTON_EVENT_LONG_PRESS_START, factory_reset_handler}, {BUTTON_EVENT_DOUBLE_CLICK, toggle_light_mode_handler}, };回调函数原型void (*kv_func_cb)(void *btn_ptr)。为什么用void*为了通用性。回调函数内部可以强制转换回button_obj_t*来获取信息但更常见的做法是在回调函数里根据按键IDbtn-id来执行不同的逻辑或者直接忽略这个参数如果你的回调是专用于某个按键的。注意回调函数是在button_ticks()的上下文中被调用的而button_ticks()通常是在定时器中断或高优先级任务中执行。因此回调函数必须遵循中断服务程序ISR的安全准则快进快出避免在回调中执行耗时操作如printf、复杂计算、阻塞式延时。避免调用不可重入函数。与主循环通信如果回调需要触发复杂任务最佳实践是设置一个标志位volatile变量、发送一个消息到队列、或者触发一个信号量/事件组让主循环或其他任务去处理实际业务。4. 从零开始集成与实战配置4.1 硬件抽象层HAL适配EmbeddedButton不依赖任何硬件所以第一步是提供它所需的GPIO读取接口。以STM32的HAL库为例// button_hal.c #include main.h // 包含你的HAL头文件 #include embedded_button.h // 假设有三个按键连接在PC13, PA0, PA1上 #define BUTTON1_PIN GPIO_PIN_13 #define BUTTON1_PORT GPIOC #define BUTTON1_ID 0 #define BUTTON2_PIN GPIO_PIN_0 #define BUTTON2_PORT GPIOA #define BUTTON2_ID 1 #define BUTTON3_PIN GPIO_PIN_1 #define BUTTON3_PORT GPIOA #define BUTTON3_ID 2 // 统一的GPIO读取函数 uint8_t read_button_gpio(uint8_t button_id) { GPIO_PinState pin_state; switch(button_id) { case BUTTON1_ID: pin_state HAL_GPIO_ReadPin(BUTTON1_PORT, BUTTON1_PIN); // 假设按键按下为低电平则有效电平为0 return (pin_state GPIO_PIN_RESET) ? 0 : 1; case BUTTON2_ID: pin_state HAL_GPIO_ReadPin(BUTTON2_PORT, BUTTON2_PIN); return (pin_state GPIO_PIN_RESET) ? 0 : 1; case BUTTON3_ID: pin_state HAL_GPIO_ReadPin(BUTTON3_PORT, BUTTON3_PIN); return (pin_state GPIO_PIN_RESET) ? 0 : 1; default: return 1; // 默认返回无效电平未按下 } }如果你的硬件是按键按下产生高电平那么返回逻辑就要反过来。关键是active_level参数要和这个读取函数的逻辑对应。4.2 定时器设置与驱动心跳驱动需要一个稳定的时间基准。有两种主流方式方式一SysTick定时器中断适用于无RTOS// stm32f1xx_it.c 或其他中断文件 volatile uint32_t system_ticks 0; // 系统滴答每1ms递增 void SysTick_Handler(void) { system_ticks; static uint8_t button_tick_cnt 0; button_tick_cnt; if (button_tick_cnt 5) { // 每5ms执行一次 button_tick_cnt 0; button_ticks(); // 调用驱动的核心处理函数 } // ... 其他定时任务 }方式二RTOS的软件定时器适用于FreeRTOS等// 创建一個5ms周期的软件定时器 TimerHandle_t button_timer_handle; void button_timer_callback(TimerHandle_t xTimer) { button_ticks(); } void app_main() { // ... 其他初始化 button_timer_handle xTimerCreate(BtnTmr, pdMS_TO_TICKS(5), pdTRUE, NULL, button_timer_callback); xTimerStart(button_timer_handle, 0); }方式三在主循环中基于系统滴答定时执行uint32_t last_tick 0; while(1) { uint32_t current_tick get_system_tick(); // 获取当前系统毫秒数 if (current_tick - last_tick 5) { last_tick current_tick; button_ticks(); } // ... 执行其他任务 }重要经验button_ticks()的执行周期直接影响所有时间相关的参数消抖时间、单击/长按判定时间、连击间隔。如果你将周期从5ms改为10ms那么代码中所有以tick为单位的时间阈值都需要相应调整例如原来500ms的长按阈值对应100个tick现在需要调整为50个tick。建议在embedded_button.h中用宏定义这些时间阈值并与tick周期关联起来例如#define BUTTON_TICK_PERIOD_MS 5 #define DEBOUNCE_TICKS (20 / BUTTON_TICK_PERIOD_MS) // 20ms消抖 #define LONG_PRESS_TICKS (1000 / BUTTON_TICK_PERIOD_MS) // 1000ms长按4.3 完整应用实例智能灯控面板假设我们有一个智能灯上面有三个按键开关POWER、调亮度BRIGHT、调色温COLOR。需求POWER键单击开关灯长按3秒进入配网模式。BRIGHT键单击增加亮度长按快速增加亮度双击切换亮度模式如阅读/影院。COLOR键单击增加色温长按快速增加色温双击切换预设场景。代码实现// button_app.c #include embedded_button.h #include light_control.h // 假设的灯控业务头文件 // 1. 定义按键对象 struct button_obj_t button_power, button_bright, button_color; // 2. 定义回调函数 void power_click_handler(void *btn) { light_set_power(!light_get_power()); // 切换开关状态 printf([PWR] Click: Toggle power.\n); } void power_long_press_start_handler(void *btn) { printf([PWR] Long press start: Enter Wi-Fi config mode.\n); start_wifi_configuration(); } void bright_click_handler(void *btn) { light_adjust_brightness(10); // 亮度10 printf([BRT] Click: Brightness 10.\n); } void bright_long_press_hold_handler(void *btn) { // 长按期间每100ms触发一次快速调整 light_adjust_brightness(5); printf([BRT] Holding: Brightness 5.\n); } void bright_double_click_handler(void *btn) { light_switch_brightness_mode(); // 切换模式 printf([BRT] Double click: Switch brightness mode.\n); } void color_click_handler(void *btn) { light_adjust_color_temp(100); // 色温100K printf([COL] Click: Color temp 100K.\n); } // ... 其他回调函数类似 // 3. 为每个按键定义事件-回调映射表 const key_value_map_t power_button_map[] { {BUTTON_EVENT_SINGLE_CLICK, power_click_handler}, {BUTTON_EVENT_LONG_PRESS_START, power_long_press_start_handler}, // 不需要处理的事件可以不列出来 }; const key_value_map_t bright_button_map[] { {BUTTON_EVENT_SINGLE_CLICK, bright_click_handler}, {BUTTON_EVENT_LONG_PRESS_HOLD, bright_long_press_hold_handler}, // 注意是HOLD事件 {BUTTON_EVENT_DOUBLE_CLICK, bright_double_click_handler}, }; const key_value_map_t color_button_map[] { {BUTTON_EVENT_SINGLE_CLICK, color_click_handler}, {BUTTON_EVENT_LONG_PRESS_HOLD, color_long_press_hold_handler}, {BUTTON_EVENT_DOUBLE_CLICK, color_double_click_handler}, }; // 4. 初始化函数 void buttons_init(void) { // 初始化POWER键低电平有效ID为0 button_init(button_power, read_button_gpio, 0, BUTTON_POWER_ID, power_button_map, ARRAY_SIZE(power_button_map)); button_start(button_power); // 初始化BRIGHT键低电平有效ID为1 button_init(button_bright, read_button_gpio, 0, BUTTON_BRIGHT_ID, bright_button_map, ARRAY_SIZE(bright_button_map)); button_start(button_bright); // 初始化COLOR键低电平有效ID为2 button_init(button_color, read_button_gpio, 0, BUTTON_COLOR_ID, color_button_map, ARRAY_SIZE(color_button_map)); button_start(button_color); printf(All buttons initialized.\n); } // 5. 在主函数或任务中确保 button_ticks() 被定期调用如前文所述5. 高级技巧、调试与问题排查5.1 动态修改按键参数与映射表有时我们需要根据系统模式动态改变按键功能。例如设备正常运行时POWER键是开关在设置菜单中POWER键可能变成“确认”键。EmbeddedButton的结构体成员如map_ptr和map_size在初始化后是可以修改的。但需要注意线程安全如果button_ticks()在中断中被调用修改这些指针的操作需要放在临界区如关闭中断进行。// 进入设置模式 void enter_settings_mode(void) { // 临时定义一个新的映射表 const key_value_map_t power_button_setting_map[] { {BUTTON_EVENT_SINGLE_CLICK, settings_confirm_handler}, }; // 关闭中断或使用互斥锁防止 button_ticks 正在访问时修改 __disable_irq(); button_power.map_ptr power_button_setting_map; button_power.map_size ARRAY_SIZE(power_button_setting_map); __enable_irq(); printf(POWER button function changed to Confirm.\n); } // 退出设置模式 void exit_settings_mode(void) { __disable_irq(); button_power.map_ptr power_button_map; // 指回原来的表 button_power.map_size ARRAY_SIZE(power_button_map); __enable_irq(); printf(POWER button function restored.\n); }5.2 功耗优化策略在电池供电的设备中需要尽可能降低功耗。按键扫描本身消耗很低但我们可以做得更好间歇性扫描如果设备处于深度睡眠只有按键能唤醒那么通常使用外部中断唤醒MCU然后才启动定时器和按键扫描。在这种情况下EmbeddedButton可以正常工作。动态tick频率在设备空闲时可以降低button_ticks()的调用频率比如从5ms改为20ms。但要注意这会降低按键响应的实时性并且所有时间阈值都需要重新计算。更稳妥的方法是在初始化时根据系统低功耗模式预设几套不同的时间参数切换模式时整体更换。按键对象休眠可以为button_obj_t结构体增加一个enabled标志。在不需要扫描某个按键时将其禁用在button_ticks()中跳过它。// 在 button_ticks() 循环中 button_obj_t *btn button_list_head; while(btn ! NULL) { if (btn-enabled) { // 新增的使能标志 // ... 正常的扫描处理逻辑 } btn btn-next; }5.3 常见问题与调试方法问题1按键无反应或反应迟钝。检查1button_ticks()是否被定期调用在button_ticks()函数入口加一个翻转IO或打印语句用示波器或日志确认其执行频率是否正确。检查2GPIO读取函数是否正确在读取函数里打印或返回固定值测试驱动是否能收到正确的电平信号。确认active_level参数设置是否正确按下时read_button_gpio()返回的值是否等于active_level。检查3消抖时间是否过长默认消抖时间如20ms对于某些特殊按键可能太长。可以尝试减小DEBOUNCE_TICKS或缩短button_ticks()的周期。检查4事件判定阈值是否合理单击最大时间如500ms设得太短用户按得慢就不识别设得太长又容易和双击的第一下混淆。需要根据产品定义和用户测试调整。问题2按键连发或误触发一次按下触发多次事件。检查1消抖时间是否过短或失效机械抖动没有被过滤掉。增加DEBOUNCE_TICKS。检查2button_ticks()调用频率是否过高频率过高如1ms可能导致在抖动期间状态被多次确认。保持5-20ms的周期是比较稳妥的。检查3回调函数中是否有阻塞或耗时操作这会导致button_ticks()执行被延迟打乱内部计时逻辑可能引发状态误判。务必确保回调函数快速执行。问题3长按事件不触发或触发时机不对。检查1LONG_PRESS_START事件的阈值。确保ticks计数器的阈值设置正确阈值 期望时长(ms) / button_ticks周期(ms)。检查2是否在电平变化时清零了ticks在状态比特位更新逻辑中当read_level_update为1时必须将btn-ticks 0。这是为了长按计时从本次稳定按下开始。检查3是否支持LONG_PRESS_HOLD如果需要长按持续触发驱动需要在长按开始后每隔一定时间如100ms产生一个HOLD事件。检查驱动源码是否有此逻辑以及你的映射表是否注册了该事件的回调。调试技巧打印状态机信息在button_ticks()函数内部的关键点添加条件编译的调试代码打印出每个按键的id,read_level,state_bits,ticks,key_value等信息。这是理解驱动内部状态流转最直接的方法。#ifdef BUTTON_DEBUG if (btn-id DEBUG_BUTTON_ID) { printf(Btn%d: Lvl%d, Bits0x%04X, Tick%d, KV%d\n, btn-id, btn-read_level, btn-state_bits, btn-ticks, btn-key_value); } #endif5.4 扩展复杂事件序列与组合键EmbeddedButton的基础设计已经为复杂事件留出了空间。要实现“单击后长按”这类序列事件本质上是在state_bits中寻找“1-0-1...”的特定模式并且第一个“1”脉冲的宽度要符合单击条件第二个“1”脉冲的宽度要符合长按条件且中间“0”的间隔要短。这可以通过扩展key_value的计算函数来实现。你需要分析更长的state_bits历史窗口可能需要32位并编写更复杂的模式匹配算法。虽然这会增加一些CPU开销和代码复杂度但得益于数据驱动的设计一旦新的key_value被定义和识别绑定回调函数的方式是完全一样的。对于真正的“组合键”如A键和B键同时按下EmbeddedButton的单按键对象模型处理起来就不太直接了。一种思路是创建一个“虚拟按键”对象它的read_button_func_ptr函数同时读取两个物理GPIO并返回一个组合后的逻辑电平例如只有两个都按下才返回有效电平。这样就把组合键变成了一个“虚拟按键”来处理可以复用所有单键的事件逻辑。