05 keyflow 扩展设计方案:矩阵键盘/组合键/事件队列/中断驱动

前置阅读 :建议先阅读 keyflow 核心设计文档,了解当前 V3 版本的状态机、回调分发、表驱动注册三大支柱。本文档在此基础上给出四个扩展方向的设计方案,遵循同样的设计哲学:数据驱动、控制流反转、零成本抽象


0. 设计哲学回顾

在扩展之前,明确三条核心设计原则,所有扩展方案均服从于这三条原则:

原则 含义 在扩展中的体现
数据驱动 配置信息集中管理,流程代码对所有实例一致 矩阵键盘配置表、组合键配置表、事件队列大小均在表/结构体中声明,扩展按键只需加表行
控制流反转 模块产生事件,调用者处理,而不是调用者主动轮询 中断模式下按键模块在边沿中断时触发处理;事件队列模式下模块产生事件推入队列,上层批量消费
零成本抽象 未使用的特性不产生任何运行时开销 所有扩展功能均通过编译宏或条件编译开启;未启用时对代码大小和执行速度零影响

1. 扩展一:矩阵键盘支持

1.1 问题背景

独立按键模式下,N 个按键需要 N 个 GPIO 引脚。当按键数量较多时(如 16 键、64 键),GPIO 资源会快速耗尽。

矩阵键盘通过行列扫描技术,用 N+M 个 GPIO 引脚支持 N×M 个按键的检测:

方案 GPIO 数量 可支持按键数
独立按键 N N
矩阵键盘 (4×4) 8 16
矩阵键盘 (8×8) 16 64
矩阵键盘 (4×4) vs 独立 8 vs 16 相同 16 按键,但节省 8 个 GPIO

1.2 数据结构设计

c 复制代码
/**
 * 矩阵键盘配置
 * 行线(ROW)配置为输出,列线(COL)配置为输入
 */
typedef struct {
    uint8_t  *row_pins;   /* 行线 GPIO 数组,配置为推挽输出 */
    uint8_t  *col_pins;   /* 列线 GPIO 数组,配置为上拉输入 */
    uint8_t   row_count;   /* 行线数量 */
    uint8_t   col_count;   /* 列线数量 */
    uint8_t   active_level; /* 按键按下时的列线电平 (0=低有效, 1=高有效) */
} MatrixKeyConfig;

/**
 * 矩阵按键事件
 * 包含按键在矩阵中的位置 (row, col) 而非 GPIO 编号
 */
typedef struct {
    uint8_t   row;         /* 按键所在行 (0~row_count-1) */
    uint8_t   col;         /* 按键所在列 (0~col_count-1) */
    ButtonEventType event; /* 事件类型 */
} MatrixKeyEvent;

1.3 行列扫描实现

c 复制代码
/**
 * 矩阵键盘扫描函数
 *
 * 工作原理:
 *   1. 依次将每一行拉低/高(输出低电平)
 *   2. 读取所有列的电平
 *   3. 若某列线为有效电平,说明该行该列交叉处的按键被按下
 *
 * 时间复杂度:O(row_count × col_count),
 * 但实际上每次扫描只遍历有效行
 */
static bool matrix_scan_once(const MatrixKeyConfig *cfg,
                              bool (*read_col)(uint8_t pin),
                              void (*set_row)(uint8_t pin, uint8_t level))
{
    for (uint8_t r = 0; r < cfg->row_count; r++) {
        /* 将当前行设置为有效电平,其余行为相反电平 */
        for (uint8_t i = 0; i < cfg->row_count; i++) {
            set_row(cfg->row_pins[i], (i == r) ? cfg->active_level : !cfg->active_level);
        }

        /* 短暂延时后读取所有列 */
        /* (实际项目中用硬件延时或 DMA,此处简化) */

        for (uint8_t c = 0; c < cfg->col_count; c++) {
            bool col_level = read_col(cfg->col_pins[c]);
            if (col_level == (cfg->active_level == 1)) {
                /* 发现了按下,记录坐标 (r, c) */
                /* 具体记录方式见"稳定矩阵"小节 */
                on_key_pressed(r, c);
            }
        }
    }

    /* 恢复所有行为高阻/输入模式(防短路)*/
    for (uint8_t i = 0; i < cfg->row_count; i++) {
        set_row(cfg->row_pins[i], !cfg->active_level);
    }

    return false;  /* 有按键按下返回 true */
}

/**
 * 带消抖的矩阵扫描 ------ 稳定矩阵
 *
 * 与独立按键的 stable_cnt 类似,维护一个"按键状态矩阵"
 * 只有当某个坐标连续 N 次扫描结果一致,才认为是稳定状态
 */
typedef struct {
    uint8_t      matrix[8][8];      /* 每个按键的上次稳定状态 */
    uint8_t      stable_cnt[8][8]; /* 连续相同采样计数 */
    uint8_t      row_count;
    uint8_t      col_count;
} MatrixStableState;

/**
 * 带消抖的矩阵按键检测
 *
 * @param cfg       矩阵配置
 * @param state     稳定状态缓存
 * @param read_col  列读取回调
 * @param set_row   行设置回调
 * @param on_event  按键事件回调 (r, c, event)
 */
void MatrixKey_ScanWithDebounce(const MatrixKeyConfig *cfg,
                                  MatrixStableState *state,
                                  bool (*read_col)(uint8_t pin),
                                  void (*set_row)(uint8_t pin, uint8_t level),
                                  void (*on_event)(uint8_t row, uint8_t col,
                                                   ButtonEventType event))
{
    uint8_t current_matrix[8][8] = {0};  /* 本次扫描结果 */
    bool any_change = false;

    /* 第一步:扫描获取当前电平矩阵 */
    for (uint8_t r = 0; r < cfg->row_count; r++) {
        set_row(cfg->row_pins[r], cfg->active_level);

        for (uint8_t c = 0; c < cfg->col_count; c++) {
            bool pressed = (read_col(cfg->col_pins[c]) == (cfg->active_level == 1));
            current_matrix[r][c] = pressed ? 1 : 0;
        }

        set_row(cfg->row_pins[r], !cfg->active_level);  /* 恢复 */
    }

    /* 第二步:逐位比较,更新稳定计数 */
    for (uint8_t r = 0; r < cfg->row_count; r++) {
        for (uint8_t c = 0; c < cfg->col_count; c++) {
            uint8_t cur  = current_matrix[r][c];
            uint8_t last = state->matrix[r][c];

            if (cur == last) {
                /* 状态未变,计数增加(上限 255)*/
                if (state->stable_cnt[r][c] < 255) {
                    state->stable_cnt[r][c]++;
                }
            } else {
                /* 状态变化,重置计数 */
                state->stable_cnt[r][c] = 1;
                state->matrix[r][c] = cur;
                any_change = true;
            }
        }
    }

    /* 第三步:检测边沿变化,产生事件 */
    /* 边沿检测:上次稳定,本次也稳定,且状态为按下 */
    /* 释放检测:上次稳定为按下,本次稳定为未按下 */
    for (uint8_t r = 0; r < cfg->row_count; r++) {
        for (uint8_t c = 0; c < cfg->col_count; c++) {
            /* 稳定阈值判断(与独立按键的 stable_cnt_required 相同逻辑)*/
            if (state->stable_cnt[r][c] < cfg->stable_cnt_required) {
                continue;  /* 未达到稳定,不产生事件 */
            }

            uint8_t cur = current_matrix[r][c];
            /* 边沿检测在应用层处理,此处只提供稳定状态 */
            /* 应用层可以通过比较 current_matrix 和上次保存的值来检测边沿 */
        }
    }
}

1.4 与现有 Button 模块的集成

有两种集成思路,各有优劣:

方案 A:矩阵键盘专用状态机(推荐)

c 复制代码
/**
 * 方案 A:矩阵键盘拥有独立的状态机
 * 优点:与独立按键完全解耦,按键数量不影响独立按键的扫描开销
 * 缺点:需要独立初始化和调用
 */
typedef struct {
    MatrixKeyConfig   config;
    MatrixStableState stable;
    /* 存放上一帧的稳定状态,用于边沿检测 */
    uint8_t          last_stable_matrix[8][8];
} MatrixKeyScanner;

void MatrixKey_Init(MatrixKeyScanner *scanner,
                     const MatrixKeyConfig *cfg);
void MatrixKey_Scan(MatrixKeyScanner *scanner,
                     uint32_t current_time,
                     bool (*read_col)(uint8_t),
                     void (*set_row)(uint8_t, uint8_t),
                     void (*on_event)(uint8_t, uint8_t, ButtonEventType));

方案 B:映射到独立按键接口

c 复制代码
/**
 * 方案 B:将矩阵按键映射到 Button 结构体
 * 矩阵中的每个按键对应一个 Button,通过 pin 字段存储 (row, col) 复合值
 *
 * pin 字段复用:高 4 位存 row,低 4 位存 col
 * 优点:可以直接复用 ButtonManager_AddButton 等接口
 * 缺点:需要额外的映射表,且引脚语义被修改
 */
#define MATRIX_PIN(row, col)  (((row) << 4) | (col))
#define PIN_TO_ROW(p)         ((p) >> 4)
#define PIN_TO_COL(p)         ((p) & 0x0F)

1.5 鬼键与多键检测

矩阵键盘的特殊问题:鬼键(Ghosting)

当三个按键同时按下形成特定拓扑时,可能在第四个交叉点产生虚假的按键信号:

复制代码
真实按下: A + B          可能产生鬼键: D 看似被按下
矩阵:      [ROW1]---[A]---[COL1]
           [ROW2]---[B]---[COL2]
           [ROW3]---[D]---[COL3]

解决方案

c 复制代码
/**
 * 鬼键检测配置
 * 如果应用场景中不允许 3 个以上按键同时按下,可以启用此检测
 */
typedef struct {
    uint8_t max_simultaneous_keys; /* 同时按下的最大按键数 */
    bool    enable_ghost_check;    /* 是否启用鬼键检测 */
} MatrixGhostConfig;

/**
 * 鬼键检测算法
 * 在扫描到 N 个按键同时按下时,检查是否所有行线和列线组合都是物理可能的
 */
static bool matrix_check_ghosting(uint8_t row_count, uint8_t col_count,
                                   uint8_t *pressed_rows,
                                   uint8_t *pressed_cols, uint8_t count)
{
    /* 如果同时按下的按键数等于行数×列数的拓扑不可能,
       则认为是鬼键 */
    if (count > 3) {  /* 3 按键及以上才可能产生鬼键 */
        /* 简化的鬼键判断:
         * 统计被按下的行和列,如果被按下行数 × 被按下列数 != 被按下按键数
         * 则存在鬼键(因为行列组合数应该等于实际按键数)*/
        uint8_t row_set = 0, col_set = 0;
        for (uint8_t i = 0; i < count; i++) {
            row_set |= (1 << pressed_rows[i]);
            col_set |= (1 << pressed_cols[i]);
        }
        /* 计算 set 中的行数和列数 */
        uint8_t active_rows = __builtin_popcount(row_set);
        uint8_t active_cols = __builtin_popcount(col_set);
        if (active_rows * active_cols != count) {
            return true;  /* 存在鬼键 */
        }
    }
    return false;
}

1.6 设计决策汇总

决策点 选项 推荐 理由
消抖策略 稳定矩阵 vs 全局延时 稳定矩阵 与独立按键设计一致,精度高
集成方式 独立状态机 vs 映射 Button 独立状态机 避免破坏 Button 语义,保持模块边界清晰
行线驱动 低有效 vs 高有效 低有效 大多数 MCU GPIO 默认高电平,低有效更安全
鬼键处理 硬件规避 vs 软件检测 硬件规避 软件检测增加复杂度,硬件设计上避免同一行/列同时按多个键

2. 扩展二:外部中断驱动模式

2.1 问题背景

当前轮询模式(每 10ms 调用 ButtonManager_Update)在电池供电设备中存在两个问题:

  1. CPU 无法深度休眠:即使按键长期空闲,CPU 也必须周期性醒来扫描
  2. 扫描开销与按键数量正相关:16 个按键每次要读取 16 个 GPIO

2.2 中断驱动的基本原理

利用 GPIO 外部中断(EXTI)在按键状态变化时唤醒 MCU:

复制代码
轮询模式:
  while(1) {               # CPU 持续运行,无法休眠
      ButtonManager_Update();
      HAL_Delay(10);
  }

中断模式:
  void EXTI0_IRQHandler(void) {   # 仅在按下/释放时触发
      ButtonManager_UpdateFromISR();
  }

  while(1) {
      enter_deep_sleep();          # 按键空闲时 CPU 休眠
  }

2.3 数据结构设计

c 复制代码
/**
 * 中断驱动按键配置
 * 在 ButtonConfig 基础上增加中断相关参数
 */
typedef struct {
    ButtonConfig  base;          /* 基础按键配置 */

    /* 中断相关 */
    IRQn_Type     irq_line;      /* 中断号 (如 EXTI0_IRQn) */
    uint8_t       exti_port;      /* GPIO 端口 (GPIOA=0, GPIOB=1, ...) */
    uint8_t       exti_pin;       /* 引脚号 (0~15) */
    uint8_t       exti_trigger;   /* 触发类型: 上升沿/下降沿/双边沿 */
} ExtiButtonConfig;

#define EXTI_TRIGGER_FALLING  0x01  /* 下降沿触发 */
#define EXTI_TRIGGER_RISING   0x02  /* 上升沿触发 */
#define EXTI_TRIGGER_BOTH     0x03  /* 双边沿触发 */

/**
 * 中断上下文数据
 * 用于在中断和主循环之间传递信息
 */
typedef struct {
    volatile bool      interrupt_occurred; /* 中断发生标志 */
    volatile uint8_t   triggered_pin;     /* 触发中断的引脚 */
    volatile uint32_t  timestamp;         /* 中断发生时的时间戳 */
} ExtiContext;

2.4 中断驱动的状态机改造

中断驱动的核心变化:状态机的推进不再依赖定时轮询,而是依赖中断事件触发

c 复制代码
/**
 * 从 GPIO 中断调用的更新函数
 * 这个函数与 ButtonManager_Update 的区别:
 *   1. 由硬件中断触发,而不是定时器
 *   2. 只更新发生变化的单个按键,而不是全部扫描
 *   3. 可以在中断上下文中调用(但有 restrictions)
 */
void Button_UpdateFromExti(Button *button, uint32_t interrupt_time)
{
    /* 注意:在真实的中断处理函数中,应该:
     *   1. 记录引脚和时间戳到共享结构
     *   2. 在主循环(而非中断上下文)中调用完整的更新逻辑
     * 下面的代码展示的是"中断触发后的按键更新" */

    /* 中断模式下,时间差从"轮询周期"变为"两次中断之间的时间" */
    /* 仍然使用同样的状态机逻辑,但计时基准不同 */
    button_update_single(button, interrupt_time);
}

/**
 * 中断服务程序(STM32 HAL 示例)
 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    /* 1. 记录中断信息(通过共享结构传递到主循环)*/
    g_exti_ctx.interrupt_occurred = true;
    g_exti_ctx.triggered_pin      = GPIO_Pin;
    g_exti_ctx.timestamp          = HAL_GetTick();

    /* 2. 清除中断标志(硬件自动处理)*/

    /* 3. 激活主循环中的按键处理任务(可选用 RTOS)*/
    /* 如果有 RTOS,可以在这里发送信号量或设置通知位 */
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    if (xKeyTaskHandle != NULL) {
        vTaskNotifyGiveFromISR(xKeyTaskHandle, &xHigherPriorityTaskWoken);
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}

2.5 主循环改造:从轮询到事件驱动

c 复制代码
/**
 * 中断驱动模式下的主循环
 * 与轮询模式的根本区别:主循环不再主动扫描,
 * 而是被中断唤醒后处理变化
 */
void key_task(void *arg)
{
    ButtonManager *mgr = (ButtonManager *)arg;

    while (1) {
        /* 等待中断通知(RTOS 信号量)
         * 没有中断时,任务阻塞在这里,CPU 可以进入休眠 */
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

        /* 被唤醒后,处理所有待处理的按键 */
        if (g_exti_ctx.interrupt_occurred) {
            g_exti_ctx.interrupt_occurred = false;

            /* 找到对应的按键并更新 */
            for (uint8_t i = 0; i < mgr->count; i++) {
                if (mgr->buttons[i].config.pin == g_exti_ctx.triggered_pin) {
                    button_update_single(&mgr->buttons[i],
                                         g_exti_ctx.timestamp);
                }
            }
        }

        /* 注意:这里没有 HAL_Delay,
         * 主循环只在有事情做时才会运行 */
    }
}

2.6 消抖在中断模式下的特殊处理

轮询模式下消抖依赖"时间窗 + 定时轮询"。中断模式下需要重新设计:

c 复制代码
/**
 * 中断模式下的软件消抖定时器
 *
 * 原理:第一次中断(按下沿)触发后,启动一个硬件定时器,
 *       在 debounce_ms 后再次读取 GPIO 确认状态
 *
 * 这种方式不需要 CPU 持续轮询,只在消抖定时器到期时才耗电
 */
typedef struct {
    Button        *button;
    uint16_t       debounce_ms;
    bool           pending;   /* 是否在等待消抖确认 */
} DebounceTimerEntry;

static DebounceTimerEntry g_debounce_timers[BUTTON_MANAGER_MAX];

/**
 * 按键中断处理(中断模式下的消抖逻辑)
 */
void Button_ExtiHandler(Button *button, uint32_t timestamp)
{
    /* 查找是否有待处理的消抖 */
    for (uint8_t i = 0; i < BUTTON_MANAGER_MAX; i++) {
        if (g_debounce_timers[i].button == button &&
            g_debounce_timers[i].pending) {
            /* 消抖定时器到期,再次确认按键状态 */
            bool current_level = button->read_pin(button->config.pin);
            bool expected_level = (button->state == BUTTON_STATE_DEBOUNCE)
                                   ? (button->config.active_level == 1)
                                   : 0;

            if (current_level == expected_level) {
                /* 状态稳定,确认按下/释放 */
                button_update_single(button, timestamp);
            }

            g_debounce_timers[i].pending = false;
            return;
        }
    }

    /* 没有待处理的消抖,说明是新的边沿中断 */
    /* 启动消抖定时器 */
    for (uint8_t i = 0; i < BUTTON_MANAGER_MAX; i++) {
        if (g_debounce_timers[i].pending == false) {
            g_debounce_timers[i].button = button;
            g_debounce_timers[i].debounce_ms = button->config.debounce_ms;
            g_debounce_timers[i].pending = true;

            /* 启动硬件定时器(在 STM32 中使用 Hardware Timer)*/
            HAL_TIM_Base_Start_IT(&DebounceTimer,
                                   button->config.debounce_ms);
            break;
        }
    }
}

/**
 * 消抖定时器到期回调
 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    (void)htim;
    /* 遍历所有待处理的消抖条目 */
    for (uint8_t i = 0; i < BUTTON_MANAGER_MAX; i++) {
        if (g_debounce_timers[i].pending) {
            Button_ExtiHandler(g_debounce_timers[i].button,
                                HAL_GetTick());
        }
    }
}

2.7 设计决策汇总

决策点 选项 推荐 理由
中断上下文处理 完整处理 vs 只记录标志 只记录标志 复杂状态机不应在中断上下文执行
消抖方式 定时器确认 vs 软件滤波 定时器确认 精度高,不占用 CPU 轮询资源
唤醒方式 信号量 vs 标志位 信号量(RTOS) RTOS 下更规范,避免竞争
与轮询模式的兼容性 二选一 vs 共存 共存 可在初始化时选择模式,按键模块无需改动

3. 扩展三:事件队列

3.1 问题背景

当前回调模式是即时分发:每当状态机产生事件,立即调用回调函数。这在简单场景下很高效,但在以下场景会有问题:

场景 回调模式的困难 队列模式的优势
游戏手柄 多个按键在同一帧内按下,回调被多次调用 事件入队,主循环末尾批量处理
GUI 系统 按键事件可能打断绘制流程 先入队,绘制完成后再处理
数据记录 需要按时间顺序记录所有事件 队列保证顺序不变
调试/回放 需要在事后重现事件序列 队列可作为事件日志的来源

3.2 数据结构设计

c 复制代码
/**
 * 按键事件记录
 * 与回调参数完全等价,但存储在队列中
 */
typedef struct {
    uint8_t         button_index; /* 按键索引 */
    ButtonEventType event;        /* 事件类型 */
    uint32_t        timestamp;    /* 事件发生时间 */
    uint8_t         reserved;     /* 对齐填充 */
} ButtonEventRecord;

/**
 * 事件队列配置
 */
typedef struct {
    uint8_t  capacity;    /* 队列容量,建议 2^n 如 16/32/64 */
    bool     overwrite;   /* 队列满时是否覆盖最旧的事件 */
} ButtonEventQueueConfig;

/**
 * 事件队列
 * 环形缓冲区实现,FIFO
 */
typedef struct {
    ButtonEventRecord *records;  /* 缓冲区指针 */
    uint8_t           capacity; /* 容量 */
    uint8_t           head;      /* 读指针 */
    uint8_t           tail;      /* 写指针 */
    volatile uint8_t  count;     /* 当前事件数(原子操作)*/
} ButtonEventQueue;

/* 队列状态 */
typedef enum {
    QUEUE_OK = 0,       /* 入队成功 */
    QUEUE_FULL,          /* 队列已满 */
    QUEUE_EMPTY,         /* 队列为空 */
    QUEUE_OVERWRITE      /* 队列满,已覆盖最旧事件 */
} ButtonQueueStatus;

3.3 队列操作实现

c 复制代码
/**
 * 初始化事件队列
 */
void ButtonEventQueue_Init(ButtonEventQueue *q,
                            ButtonEventRecord *buffer,
                            uint8_t capacity)
{
    q->records  = buffer;
    q->capacity = capacity;
    q->head     = 0;
    q->tail     = 0;
    q->count    = 0;
}

/**
 * 入队操作(原子)
 * 返回值说明是否成功入队或被覆盖
 */
ButtonQueueStatus ButtonEventQueue_Push(ButtonEventQueue *q,
                                         const ButtonEventRecord *rec)
{
    if (q->count >= q->capacity) {
        if (q->overwrite) {
            /* 覆盖模式:丢弃最旧的事件,移动 head */
            q->head = (q->head + 1) % q->capacity;
            q->count--;
        } else {
            /* 非覆盖模式:拒绝入队 */
            return QUEUE_FULL;
        }
    }

    q->records[q->tail] = *rec;
    q->tail = (q->tail + 1) % q->capacity;
    q->count++;

    return (q->count >= q->capacity) ? QUEUE_OVERWRITE : QUEUE_OK;
}

/**
 * 出队操作
 * 返回 false 表示队列为空
 */
bool ButtonEventQueue_Pop(ButtonEventQueue *q,
                           ButtonEventRecord *out)
{
    if (q->count == 0) {
        return false;  /* 队列空 */
    }

    *out = q->records[q->head];
    q->head = (q->head + 1) % q->capacity;
    q->count--;

    return true;
}

/**
 * 查看队首事件(不出队)
 */
bool ButtonEventQueue_Peek(const ButtonEventQueue *q,
                            ButtonEventRecord *out)
{
    if (q->count == 0) {
        return false;
    }
    *out = q->records[q->head];
    return true;
}

/**
 * 获取当前队列中的事件数量
 */
uint8_t ButtonEventQueue_Count(const ButtonEventQueue *q)
{
    return q->count;
}

3.4 与 ButtonManager 的集成

有两种集成方式,均保持"零成本抽象"原则:

方案 A:队列作为 ButtonManager 的成员

c 复制代码
/**
 * 增强版按键管理器 ------ 内置事件队列
 */
#define BUTTON_EVENT_QUEUE_SIZE  32

typedef struct {
    Button   buttons[BUTTON_MANAGER_MAX];
    uint8_t  count;
    uint32_t active_mask;
    uint32_t system_time_ms;
    ButtonTimeSourceFunc get_tick;

    /* 事件队列(可选,未启用时不占用空间)*/
    ButtonEventRecord  event_buffer[BUTTON_EVENT_QUEUE_SIZE];
    ButtonEventQueue   event_queue;
} ButtonManagerEx;

/**
 * 初始化(启用队列)
 */
void ButtonManagerEx_Init(ButtonManagerEx *mgr)
{
    memset(mgr, 0, sizeof(ButtonManagerEx));
    ButtonEventQueue_Init(&mgr->event_queue,
                           mgr->event_buffer,
                           BUTTON_EVENT_QUEUE_SIZE);
}

/**
 * 更新函数 ------ 事件入队而非即时回调
 */
void ButtonManagerEx_Update(ButtonManagerEx *mgr, uint32_t current_time)
{
    /* 正常的状态机更新... */
    for (uint8_t i = 0; i < mgr->count; i++) {
        button_update_single(&mgr->buttons[i], current_time);

        /* 如果产生了事件,入队而非直接回调 */
        if (mgr->buttons[i].last_event != BUTTON_EVENT_NONE) {
            ButtonEventRecord rec = {
                .button_index = i,
                .event        = mgr->buttons[i].last_event,
                .timestamp    = current_time
            };
            ButtonEventQueue_Push(&mgr->event_queue, &rec);
        }
    }
}

/**
 * 主循环中批量消费队列
 */
void app_main_loop(void)
{
    ButtonManagerEx mgr;
    ButtonManagerEx_Init(&mgr);
    /* ... 注册按键 ... */

    while (1) {
        ButtonManagerEx_Update(&mgr, HAL_GetTick());

        /* 批量处理事件 */
        ButtonEventRecord rec;
        while (ButtonEventQueue_Pop(&mgr.event_queue, &rec)) {
            Button *btn = &mgr.buttons[rec.button_index];
            handle_button_event(btn, rec.event);
        }

        /* 其他业务逻辑... */
    }
}

方案 B:回调中入队(更灵活)

c 复制代码
/**
 * 应用层回调 ------ 将事件入队
 */
static void enqueue_event(Button *btn, ButtonEventType event, void *ud)
{
    ButtonEventQueue *q = (ButtonEventQueue *)ud;

    ButtonEventRecord rec = {
        .button_index = btn->index,
        .event        = event,
        .timestamp    = get_system_tick()
    };

    ButtonQueueStatus st = ButtonEventQueue_Push(q, &rec);
    if (st == QUEUE_FULL && !q->overwrite) {
        /* 队列满但不允许覆盖,记录警告 */
        BTN_LOG_WARN("Button event queue full, event dropped");
    }
}

/**
 * 注册时传入队列指针作为 user_data
 */
ButtonManager_AddButton(&mgr, cfg, read_pin, enqueue_event, &g_event_queue);

3.5 设计决策汇总

决策点 选项 推荐 理由
队列容量 固定 vs 动态 固定 嵌入式内存敏感,固定容量更可预测
满队列策略 拒绝入队 vs 覆盖 覆盖(可配置) 覆盖保证事件不丢失,适合实时系统
回调 vs 队列 二选一 共存 回调高效,队列灵活,可按需切换
中断安全 加锁 vs 无锁环形缓冲 无锁环形缓冲 嵌入式避免锁,用原子操作或屏蔽中断

4. 扩展四:组合键与序列键

4.1 问题背景

某些场景需要检测复合按键动作

场景 例子 需求
组合键 Ctrl+C、Shift+Click、Alt+F4 多个按键同时按下触发
序列键 ↑↑↓↓←→←→BA(KONAMI Code) 按特定顺序按键触发
修饰键 Shift 修饰数字键输入大写字母 按住修饰键时改变主键行为

4.2 组合键设计

组合键的检测逻辑:在普通按键的基础上,增加"组合键修饰符"检测。

c 复制代码
/**
 * 修饰键(组合键辅助按键)配置
 * 修饰键本身不产生独立事件,但改变其他按键的行为
 */
typedef struct {
    uint8_t         key_index;     /* 修饰键的按键索引 */
    ButtonEventType trigger_event;  /* 触发修饰键状态的事件(通常是 PRESSED)*/
} ModifierKeyConfig;

/**
 * 修饰键状态
 */
typedef struct {
    uint8_t  key_index;     /* 按键索引 */
    bool     is_active;     /* 当前是否处于按下状态 */
    uint32_t active_time;   /* 按下开始时间 */
} ModifierState;

/**
 * 组合键配置
 * 定义哪些修饰键 + 主键的组合触发什么动作
 */
typedef struct {
    uint8_t         modifier_mask; /* 修饰键索引位掩码 (bit N = 修饰键 N) */
    uint8_t         primary_key;    /* 主键索引 */
    ButtonEventType trigger;       /* 触发的事件类型 */
    ButtonEventCallback callback;   /* 组合键匹配时的回调 */
    void           *user_data;
} ComboKeyConfig;

#define MOD_MASK(key_index)  (1U << (key_index))

/**
 * 组合键检测器
 */
typedef struct {
    ModifierState   *modifiers;   /* 各修饰键的当前状态 */
    uint8_t         modifier_count;
    const ComboKeyConfig *combos; /* 组合键配置表 */
    uint8_t         combo_count;
} ComboKeyDetector;

void ComboKeyDetector_Init(ComboKeyDetector *detector,
                            ModifierState *mod_buffer,
                            uint8_t modifier_count,
                            const ComboKeyConfig *combos,
                            uint8_t combo_count)
{
    detector->modifiers    = mod_buffer;
    detector->modifier_count = modifier_count;
    detector->combos       = combos;
    detector->combo_count  = combo_count;
}

/**
 * 修饰键事件处理(由普通按键的回调调用)
 */
void ComboKeyDetector_OnModifierEvent(ComboKeyDetector *detector,
                                      uint8_t modifier_index,
                                      ButtonEventType event,
                                      uint32_t timestamp)
{
    if (modifier_index >= detector->modifier_count) return;

    ModifierState *mod = &detector->modifiers[modifier_index];

    if (event == BUTTON_EVENT_PRESSED) {
        mod->is_active   = true;
        mod->active_time = timestamp;
    } else if (event == BUTTON_EVENT_RELEASED) {
        mod->is_active   = false;
    }
}

/**
 * 组合键检测
 * 在主键的回调中调用,检查当前修饰键状态是否匹配组合键
 *
 * 返回:匹配到的组合键配置指针,未匹配返回 NULL
 */
const ComboKeyConfig* ComboKeyDetector_Check(ComboKeyDetector *detector,
                                              uint8_t primary_key,
                                              ButtonEventType trigger_event)
{
    /* 构建当前修饰键位掩码 */
    uint8_t current_mod_mask = 0;
    for (uint8_t i = 0; i < detector->modifier_count; i++) {
        if (detector->modifiers[i].is_active) {
            current_mod_mask |= MOD_MASK(i);
        }
    }

    /* 在组合键配置表中查找匹配项 */
    for (uint8_t i = 0; i < detector->combo_count; i++) {
        const ComboKeyConfig *combo = &detector->combos[i];

        if (combo->primary_key == primary_key &&
            combo->trigger == trigger_event &&
            combo->modifier_mask == current_mod_mask) {
            return combo;  /* 找到匹配 */
        }
    }

    return NULL;  /* 无匹配 */
}

/**
 * 主键回调示例 ------ 检测组合键
 */
static void on_primary_key(Button *btn, ButtonEventType event, void *ud)
{
    ComboKeyDetector *detector = (ComboKeyDetector *)ud;

    /* 先检查是否为组合键 */
    const ComboKeyConfig *combo =
        ComboKeyDetector_Check(detector, btn->index, event);

    if (combo != NULL) {
        /* 命中组合键,调用组合键专用回调 */
        combo->callback(btn, event, combo->user_data);
    } else {
        /* 普通按键处理(无修饰键组合)*/
        /* ... */
    }
}

4.3 序列键设计

序列键检测需要追踪按键的时序

c 复制代码
/**
 * 按键序列配置
 * 定义一个特定的按键序列
 */
typedef struct {
    uint8_t         *sequence;     /* 按键索引序列,长度为 length */
    uint8_t          length;       /* 序列长度 */
    uint32_t         timeout_ms;   /* 序列必须在 timeout_ms 内完成 */
    ButtonEventType  trigger_event;/* 最后按键的触发事件(通常为 CLICKED)*/
    ButtonEventCallback callback;   /* 序列完成时的回调 */
    void            *user_data;
} KeySequence;

/**
 * 序列检测器状态
 */
typedef struct {
    uint8_t         current_pos;    /* 当前匹配到的序列位置 */
    uint32_t        last_event_time;/* 上一次按键事件的时间 */
    uint8_t         last_key_index; /* 上一次按键的索引 */
    bool            sequence_active;/* 是否处于序列匹配过程中 */
} SequenceState;

/**
 * 序列检测器
 */
typedef struct {
    const KeySequence *sequences; /* 序列配置表 */
    uint8_t          seq_count;   /* 序列数量 */
    SequenceState    *states;      /* 各序列的当前检测状态 */
    uint8_t          state_count;
    uint32_t         system_time_ms;
} SequenceDetector;

void SequenceDetector_Init(SequenceDetector *det,
                            const KeySequence *seqs,
                            uint8_t seq_count,
                            SequenceState *state_buffer,
                            uint8_t state_buffer_count)
{
    det->sequences  = seqs;
    det->seq_count  = seq_count;
    det->states     = state_buffer;
    det->state_count = state_buffer_count;
}

void SequenceDetector_UpdateTime(SequenceDetector *det, uint32_t time_ms)
{
    det->system_time_ms = time_ms;
}

/**
 * 序列检测 ------ 在任意按键事件时调用
 *
 * @returns 命中的序列配置,未命中返回 NULL
 */
const KeySequence* SequenceDetector_OnEvent(SequenceDetector *det,
                                             uint8_t key_index,
                                             ButtonEventType event,
                                             uint32_t timestamp)
{
    det->system_time_ms = timestamp;

    for (uint8_t s = 0; s < det->seq_count; s++) {
        const KeySequence *seq = &det->sequences[s];
        SequenceState *st = &det->states[s];

        /* 检查是否超时 */
        if (st->sequence_active) {
            if (timestamp - st->last_event_time > seq->timeout_ms) {
                /* 超时,重置该序列的匹配进度 */
                st->sequence_active = false;
                st->current_pos = 0;
            }
        }

        /* 检查是否可以开始或继续匹配 */
        uint8_t expected_key = seq->sequence[st->current_pos];

        if (key_index == expected_key && event == seq->trigger_event) {
            /* 匹配到序列中的下一个按键 */
            st->current_pos++;
            st->last_event_time = timestamp;
            st->sequence_active = true;

            if (st->current_pos >= seq->length) {
                /* 序列完成! */
                st->sequence_active = false;
                st->current_pos = 0;

                /* 调用完成回调 */
                if (seq->callback) {
                    seq->callback(NULL, BUTTON_EVENT_SEQUENCE_COMPLETE,
                                   seq->user_data);
                }
                return seq;
            }
        } else if (st->sequence_active && st->current_pos > 0) {
            /* 序列进行中,但当前按键不是期望的下一个
             * 检查是否与序列的第一个按键匹配(允许序列重叠)*/
            if (key_index == seq->sequence[0] &&
                event == seq->trigger_event) {
                st->current_pos = 1;
                st->last_event_time = timestamp;
            } else if (key_index != expected_key) {
                /* 完全不匹配,重置 */
                st->current_pos = 0;
                st->sequence_active = false;
            }
        }
    }

    return NULL;  /* 无序列完成 */
}

4.4 KONAMI Code 示例

c 复制代码
/* KONAMI Code: ↑ ↑ ↓ ↓ ← → ← → B A */
static uint8_t konami_seq_data[] = {
    KEY_INDEX_UP, KEY_INDEX_UP,
    KEY_INDEX_DOWN, KEY_INDEX_DOWN,
    KEY_INDEX_LEFT, KEY_INDEX_RIGHT,
    KEY_INDEX_LEFT, KEY_INDEX_RIGHT,
    KEY_INDEX_B, KEY_INDEX_A
};

static void on_konami_code(Button *btn, ButtonEventType event, void *ud)
{
    (void)btn; (void)event; (void)ud;
    printf("KONAMI CODE 输入正确!\n");
    /* 触发作弊功能或解锁彩蛋 */
}

static const KeySequence g_sequences[] = {
    {
        .sequence    = konami_seq_data,
        .length      = 10,
        .timeout_ms  = 5000,    /* 5 秒内完成 */
        .trigger_event = BUTTON_EVENT_CLICKED,
        .callback    = on_konami_code,
        .user_data   = NULL
    },
    /* 可以同时检测多个序列 */
    {
        .sequence    = (uint8_t[]){ KEY_INDEX_1, KEY_INDEX_2, KEY_INDEX_3 },
        .length      = 3,
        .timeout_ms  = 1000,
        .trigger_event = BUTTON_EVENT_CLICKED,
        .callback    = on_cheat_code,
        .user_data   = NULL
    }
};

/* 初始化 */
static SequenceState g_seq_states[2];
SequenceDetector_Init(&g_seq_detector,
                       g_sequences, 2,
                       g_seq_states, 2);

/* 在按键回调中调用序列检测 */
static void on_any_key(Button *btn, ButtonEventType event, void *ud)
{
    /* ... 正常按键处理 ... */

    /* 传递给序列检测器 */
    SequenceDetector_OnEvent(&g_seq_detector, btn->index, event,
                               get_system_tick());
}

4.5 设计决策汇总

决策点 选项 推荐 理由
修饰键状态存储 Button 结构体内 vs 独立数组 独立数组 修饰键本身不需要完整 Button 对象
序列重叠 丢弃 vs 重叠匹配 重叠匹配 KONAMI Code 等需要允许序列内部重叠
超时策略 硬超时 vs 软超时 软超时 软超时只在本序列内部计时,不影响其他序列
序列数量上限 固定数组 vs 动态 固定数组 嵌入式内存敏感,动态分配不推荐

5. 扩展整合:模块化架构图

以下是四个扩展在整体架构中的位置:

复制代码
┌─────────────────────────────────────────────────────┐
│                    应用层 (Application)               │
│    主循环 / RTOS 任务 / GUI 事件循环                 │
└─────────────────────┬───────────────────────────────┘
                      │
         ┌────────────┼────────────┐
         │            │            │
         ▼            ▼            ▼
┌─────────────────┐ ┌──────────┐ ┌──────────────────┐
│  回调函数层      │ │ 事件队列  │ │  组合键/序列键    │
│  (即时触发)      │ │ (批量消费)│ │  (修饰符检测)     │
└────────┬────────┘ └────┬─────┘ └────────┬─────────┘
         │               │               │
         ▼               ▼               ▼
┌─────────────────────────────────────────────────────┐
│              按键状态机层 (ButtonManager)             │
│   ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
│   │ 独立按键状态机 │ │ 矩阵键盘扫描  │ │ EXT中断驱动 │ │
│   └──────────────┘ └──────────────┘ └────────────┘ │
└─────────────────────┬───────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────┐
│           硬件抽象层 (HAL / Platform)                │
│   GPIO 读取 / 定时器 / 中断控制器 / RTOS 抽象        │
└─────────────────────────────────────────────────────┘

设计原则的统一体现

  • 数据驱动:所有扩展的配置(矩阵键盘行列数、组合键修饰掩码、序列键数组)均为纯数据,流程代码一致
  • 控制流反转:中断驱动模式下模块推事件给主循环;队列模式下模块入队,由主循环主动批量拉取
  • 零成本抽象 :组合键检测、序列键检测均通过条件编译(#ifdef)可选;未启用时不增加二进制大小

6. 配置宏汇总

以下宏控制各扩展的编译开关,全部默认关闭以保持零成本:

c 复制代码
/* button_options.h --- 扩展配置宏 */

/* 矩阵键盘支持 */
#ifndef BUTTON_FEATURE_MATRIX
#define BUTTON_FEATURE_MATRIX  0   /* 0=关闭, 1=开启 */
#endif

/* 外部中断驱动模式 */
#ifndef BUTTON_FEATURE_EXTI
#define BUTTON_FEATURE_EXTI    0
#endif

/* 事件队列 */
#ifndef BUTTON_FEATURE_EVENT_QUEUE
#define BUTTON_FEATURE_EVENT_QUEUE  0
#endif

/* 组合键 */
#ifndef BUTTON_FEATURE_COMBO_KEY
#define BUTTON_FEATURE_COMBO_KEY    0
#endif

/* 序列键 */
#ifndef BUTTON_FEATURE_SEQUENCE_KEY
#define BUTTON_FEATURE_SEQUENCE_KEY 0
#endif

/* 日志输出(调试用)*/
#ifndef BTN_LOG_ERROR
#define BTN_LOG_ERROR(fmt, ...)   /* 默认关闭 */
#endif
#ifndef BTN_LOG_WARN
#define BTN_LOG_WARN(fmt, ...)    /* 默认关闭 */
#endif
#ifndef BTN_LOG_DEBUG
#define BTN_LOG_DEBUG(fmt, ...)   /* 默认关闭 */
#endif

/* 活跃位掩码优化 */
#ifndef BUTTON_FEATURE_ACTIVE_MASK
#define BUTTON_FEATURE_ACTIVE_MASK 1   /* 默认开启 */
#endif

/* 时间源抽象 */
#ifndef BUTTON_FEATURE_TIME_SOURCE
#define BUTTON_FEATURE_TIME_SOURCE 1  /* 默认开启 */
#endif

7. 文件结构规划

扩展后的 keyflow 项目建议目录结构:

复制代码
keyflow/
├── include/
│   └── button/
│       ├── button.h              # 核心 API(始终存在)
│       ├── button_options.h      # 扩展配置宏
│       ├── button_matrix.h      # 矩阵键盘扩展
│       ├── button_exti.h         # 中断驱动扩展
│       ├── button_queue.h        # 事件队列扩展
│       └── button_combo.h        # 组合键/序列键扩展
├── src/
│   └── button/
│       ├── button.c              # 核心实现
│       ├── button_matrix.c       # 矩阵键盘实现
│       ├── button_exti.c         # 中断驱动实现
│       ├── button_queue.c        # 事件队列实现
│       └── button_combo.c        # 组合键/序列键实现
├── demo/
│   ├── simple_demo.c             # 独立按键
│   ├── matrix_demo.c             # 矩阵键盘 (4×4)
│   ├── exti_demo.c               # 中断驱动模式
│   ├── queue_demo.c              # 事件队列模式
│   └── combo_demo.c              # 组合键 + 序列键
├── docs/
│   └── 05_design_extension.md        # 本文档
├── Makefile
└── README.md

8. 优先级与实施建议

优先级 扩展方向 实现难度 推荐理由
⭐⭐⭐ 事件队列 最独立,不影响核心状态机,所有场景都受益
⭐⭐⭐ 矩阵键盘 实用性最高,嵌入式键盘场景必备
⭐⭐ 外部中断驱动 低功耗场景必须,但需要平台特定代码
⭐⭐ 组合键 游戏手柄、工业键盘必需,其余场景可选
序列键 中高 使用场景较少(作弊码、快捷键),可后期加

推荐的演进顺序

  1. 第一步 :将当前 V3 代码拆分为 include/src/ 多文件结构(已完成目录整理)
  2. 第二步:加入事件队列(最独立,影响最小)
  3. 第三步:加入矩阵键盘(使用最广)
  4. 第四步:加入外部中断驱动(低功耗产品)
  5. 第五步:加入组合键/序列键(特殊场景)

本文档为 keyflow 扩展设计方案,所有代码为参考实现(并非直接可编译),用于描述架构和设计决策。


项目仓库

免责声明

本文内容仅作为技术研究与学习交流 之用,不构成任何形式的产品设计建议、电子工程建议或商业推荐。文中涉及的代码片段、状态机模型、消抖策略等技术方案,基于特定嵌入式场景与硬件条件设计,直接用于生产环境前请务必进行充分的测试与验证

使用本文内容所导致的任何直接或间接后果(包括但不限于设备损坏、数据丢失、商业损失等),作者及 AZE-BlackCore 不承担任何责任。 请根据你的实际项目需求,结合硬件手册、行业规范与最佳实践进行独立判断和决策。

版权声明:本文版权归 AZE-BlackCore 所有,转载请注明出处。封面与示意图由 AI 生成,仅供示意参考。

相关推荐
米小虾1 小时前
让AI自主运行:Loop Engineering设计指南
人工智能·agent
微学AI1 小时前
递阶式智能体开发范式(HADP):从超级Agent到智能体应用的层级架构理论与工程实践
人工智能·架构·agent
Z-D-K1 小时前
考验AI的“自我和意识“-AI对《红楼梦》后40回的改写(22)
人工智能·ai·aigc·agent·agi
工头阿乐2 小时前
相机坐标系标定与外参矩阵求解
数码相机·线性代数·矩阵
qcx232 小时前
【AI Daily 2026-06-05】 AI 方向的基础设施化,能力从模型层下沉到工具链和工作流
人工智能·ai·llm·agent·agi
DO_Community2 小时前
百亿参数开源模型托管成本账:从按 Token 计费到单卡 GPU 服务器怎么选?
运维·服务器·开源·llm·agent
用户805533698033 小时前
Linux 工作队列:把中断里做不了的事推迟到进程上下文
linux·嵌入式
沉默王二3 小时前
又一款国产模型诞生,StepPlan性价比杀疯了!
agent·ai编程·claude
宋哥转AI3 小时前
Spring AI Alibaba实战:通过MCP协议串联Graph编排与RAG检索
agent·mcp