前置阅读 :建议先阅读 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)在电池供电设备中存在两个问题:
- CPU 无法深度休眠:即使按键长期空闲,CPU 也必须周期性醒来扫描
- 扫描开销与按键数量正相关: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. 优先级与实施建议
| 优先级 | 扩展方向 | 实现难度 | 推荐理由 |
|---|---|---|---|
| ⭐⭐⭐ | 事件队列 | 低 | 最独立,不影响核心状态机,所有场景都受益 |
| ⭐⭐⭐ | 矩阵键盘 | 中 | 实用性最高,嵌入式键盘场景必备 |
| ⭐⭐ | 外部中断驱动 | 中 | 低功耗场景必须,但需要平台特定代码 |
| ⭐⭐ | 组合键 | 中 | 游戏手柄、工业键盘必需,其余场景可选 |
| ⭐ | 序列键 | 中高 | 使用场景较少(作弊码、快捷键),可后期加 |
推荐的演进顺序:
- 第一步 :将当前 V3 代码拆分为
include/和src/多文件结构(已完成目录整理) - 第二步:加入事件队列(最独立,影响最小)
- 第三步:加入矩阵键盘(使用最广)
- 第四步:加入外部中断驱动(低功耗产品)
- 第五步:加入组合键/序列键(特殊场景)
本文档为 keyflow 扩展设计方案,所有代码为参考实现(并非直接可编译),用于描述架构和设计决策。
项目仓库
- GitCode 仓库 :https://gitcode.com/AZE-BlackCore/keyflow
免责声明
本文内容仅作为技术研究与学习交流 之用,不构成任何形式的产品设计建议、电子工程建议或商业推荐。文中涉及的代码片段、状态机模型、消抖策略等技术方案,基于特定嵌入式场景与硬件条件设计,直接用于生产环境前请务必进行充分的测试与验证。
使用本文内容所导致的任何直接或间接后果(包括但不限于设备损坏、数据丢失、商业损失等),作者及 AZE-BlackCore 不承担任何责任。 请根据你的实际项目需求,结合硬件手册、行业规范与最佳实践进行独立判断和决策。
版权声明:本文版权归 AZE-BlackCore 所有,转载请注明出处。封面与示意图由 AI 生成,仅供示意参考。