从零构建嵌入式菜单库(四):按键系统与事件驱动——从轮询到响应式

系列定位:这是一套编写教程
前几篇我们建好了菜单骨架和变量绑定,但菜单还不会响应用户操作。本篇解决:物理按键如何变成菜单操作——以及如何用事件系统让整个框架可扩展。


前言:嵌入式按键的挑战

PC 上的 GUI 框架有键盘事件、鼠标事件,操作系统帮你做好了消抖和重复触发。嵌入式裸机编程什么都没有——你需要从 GPIO 电平开始处理:

  • 抖动:按下或释放瞬间的电平毛刺(5~20ms)
  • 长按:用户按住不放需要持续响应
  • 多键:不止一个按键,且行为可能动态映射
  • 实时性:按键检测不能阻塞绘制循环

原型的解决方案是两个裸函数:

void pageUp()   { if (position) position -= maxCharHeight; }
void pageDown() { if (position < totalLength - windowHeight) position += maxCharHeight; }

没有消抖、没有长按、没有键值映射——需要构建一套完整的按键系统。


知识点预备

1.1 按键消抖——积分器状态机

机械按键在按下和释放的瞬间会产生一系列快速通断,典型的抖动波形:

实际电平: ──┐┌┐┌┐┌─────┐┌┐┌───
理想信号: ──┘    └─────┘  └───

软件消抖的基本思想:不立即相信电平变化,而是等待它稳定一段时间。

实现方式——积分器(Integrator):

初始化: shakeFree = 0
每次定时中断:
  if (key == HIGH)  shakeFree += time;
  if (key == LOW)   shakeFree -= time;
  shakeFree = clamp(shakeFree, 0, MAX);

  if (shakeFree > THRESHOLD_HIGH)  state = STABLE_HIGH;
  if (shakeFree < THRESHOLD_LOW)   state = STABLE_LOW;

把 “HIGH 就是按下” 这样的二元判断变成了"积分值超过阈值才算按下"的连续判断,滤掉了瞬时毛刺。

1.2 环形队列 (Circular Buffer)

事件系统需要暂存待处理事件——环形队列是嵌入式场景下最合适的数据结构:

[0] [1] [2] [3] [4] [5]   ← 固定大小数组
 ↑R              ↑W
 read           write

入队: items[W] = data; W = (W+1) % CAP; quantity++;
出队: data = items[R]; R = (R+1) % CAP; quantity--;

优势:无动态分配、O(1) 入队出队、内存连续。

1.3 弱定义 (Weak Symbol)

// 库中提供默认空实现
WEAK uint8_t menuEventKey_weak(u8g2_menu_t *m, u8g2_menuKeyValue_t k) {
    return 0;
}

// 用户在应用层重写
uint8_t menuEventKey_weak(u8g2_menu_t *m, u8g2_menuKeyValue_t k) {
    if (k == MENU_Key_Up) { /* 自定义行为 */ return 1; }
    return 0;
}

链接器会优先使用用户提供的强符号,库中的弱符号被覆盖。这是不改库源码注入逻辑的 C 语言手段。


2. 消抖系统:从 GPIO 到"可靠按键"

2.1 配置宏

#define MenuKey_holdTime        800   // 长按触发时间 (ms)
#define MenuKey_repeatTime      200   // 长按重复间隔 (ms)
#define MenuKey_debouncePeriod  20    // 消抖稳定检测周期
#define MenuKey_triggerHigh     18    // 触发高电平积分阈值
#define MenuKey_triggerLow      2     // 释放低电平积分阈值

2.2 核心实现

void u8g2_MenuKeyScannDebounce(u8g2_menu_t *u8g2_menu,
                                u8g2_menuKeyValue_t keyType,
                                uint8_t key, uint16_t time)
{
    // 积分器:按下时递增,释放时递减
    if (u8g2_menu->key_shakeFree[keyType] < MenuKey_debouncePeriod && key)
        u8g2_menu->key_shakeFree[keyType] += time;
    if (u8g2_menu->key_shakeFree[keyType] > 0 && !key)
        u8g2_menu->key_shakeFree[keyType] -= time;

    // 状态判定
    if (u8g2_menu->key_shakeFree[keyType] < MenuKey_triggerLow
        && u8g2_menu->key_state[keyType] == 0)
        u8g2_menu->key_state[keyType] = 1;   // 确认按下
    if (u8g2_menu->key_shakeFree[keyType] > MenuKey_triggerHigh
        && u8g2_menu->key_state[keyType] == 1)
        u8g2_menu->key_state[keyType] = 0;   // 确认释放

    // 传入消抖后的电平
    u8g2_MenuKeyScann(u8g2_menu, keyType,
                      !u8g2_menu->key_state[keyType], time);
}

MenuKey_triggerHigh=18MenuKey_triggerLow=2 的不对称设计:需要很强的"证据"才承认释放(积分 > 18),但只需要很弱的证据就承认按下(积分 < 2)。这偏保守——宁可少报一次按下,也不在抖动中误触发。

2.3 长按检测

void u8g2_MenuKeyScann(u8g2_menu_t *u8g2_menu,
                        u8g2_menuKeyValue_t keyType,
                        uint8_t key, uint16_t time)
{
    u8g2_menu->keyTim[keyType] += time;

    // 首次长按触发:holdTime 后第一次响应
    if (u8g2_menu->keyTim[keyType] > MenuKey_holdTime
        && u8g2_menu->keyTim[keyType] < MenuKey_holdTime + MenuKey_repeatTime
        && key != 0)
    {
        u8g2_menu->keyTim[keyType] = MenuKey_holdTime + MenuKey_repeatTime;
        u8g2_MenuKeys(u8g2_menu, keyType);  // 触发!
    }
    // 重复触发:每 repeatTime 响应一次
    else if (u8g2_menu->keyTim[keyType] > MenuKey_holdTime + MenuKey_repeatTime * 2
             && key != 0)
    {
        u8g2_menu->keyTim[keyType] = MenuKey_holdTime + MenuKey_repeatTime;
        u8g2_MenuKeys(u8g2_menu, keyType);  // 再次触发!
    }

    // 按键释放时重置计时器
    if (key == 0 && u8g2_menu->keyLog[keyType] != 0)
        u8g2_menu->keyTim[keyType] = 0;

    // 首次按下(短按):在 holdTime 内释放即视为短按
    if (key != 0 && u8g2_menu->keyLog[keyType] == 0)
        if (u8g2_menu->keyTim[keyType] <= MenuKey_holdTime)
            u8g2_MenuKeys(u8g2_menu, keyType);

    u8g2_menu->keyLog[keyType] = key;
}

时间轴示例

0ms        800ms       1000ms      1200ms
|----------|-----------|-----------|-------▶
 按住不放    首次长按触发  重复触发1    重复触发2
 250ms 释放 → 短按触发

keyTim 在长按触发后被"重置"到 holdTime + repeatTime,这样后续每过 repeatTime 就会再次触发——实现了"按住不放持续生效"。


3. 键值映射:编辑态的动态行为

这是 menuEventKeyPre_weak 的核心逻辑——默认实现就在库中,但用户可以覆盖:

WEAK void menuEventKeyPre_weak(u8g2_menu_t *u8g2_menu,
                                u8g2_menuKeyValue_t *key)
{
    if (u8g2_MenuGetItemSelect(u8g2_menu) != -1) {
        // 编辑态:方向键重映射
        if (*key == MENU_Key_Up)    *key = MENU_Key_Add;
        if (*key == MENU_Key_Down)  *key = MENU_Key_Sub;
        if (*key == MENU_Key_Enter) *key = MENU_Key_Return;
    }
}

设计意图:物理按键只有一组(Up/Down/Enter),但逻辑行为有两组(导航/编辑)。与其要求用户自己判断状态,不如在事件分发前统一映射。


4. 事件系统:环形队列 + 延迟处理

4.1 数据结构

#define U8G2_MENU_EVENT_Capacity 30

struct u8g2_menu_event_item_struct {
    uint8_t nextLoopRequired : 1;  // 延迟到下一帧处理
    void* value;                   // 事件附带数据
    menuEventHandle_cb menuEventHandle;  // 处理函数
};

struct u8g2_menu_event_struct {
    uint8_t lock;       // 简易互斥锁
    size_t quantity;    // 当前事件数
    size_t itemsWrite;  // 写指针
    size_t itemsRead;   // 读指针
    struct u8g2_menu_event_item_struct items[U8G2_MENU_EVENT_Capacity];
};

4.2 入队

uint8_t u8g2_MenuEventRecord(u8g2_menu_t *u8g2_menu,
                              u8g2_menu_event_item_t *eventItem)
{
    if (u8g2_menu->event.quantity >= U8G2_MENU_EVENT_Capacity) return 1;
    if (u8g2_menu->event.lock) return 1;  // 防止重入

    u8g2_menu->event.lock = 1;
    size_t w = u8g2_menu->event.itemsWrite;
    u8g2_menu->event.itemsWrite
        = (u8g2_menu->event.itemsWrite + 1) % U8G2_MENU_EVENT_Capacity;

    memcpy(&u8g2_menu->event.items[w], eventItem,
           sizeof(u8g2_menu_event_item_t));

    u8g2_menu->event.quantity++;
    u8g2_menu->event.lock = 0;
    return 0;
}

lock 是简易的互斥锁——防止在中断中入队和主循环出队产生竞态。

4.3 出队处理

void u8g2_MenuEventProcess(u8g2_menu_t *u8g2_menu)
{
    while (u8g2_menu->event.quantity) {
        u8g2_menu_event_item_t *item
            = &u8g2_menu->event.items[u8g2_menu->event.itemsRead];

        if (item->nextLoopRequired) {
            item->nextLoopRequired = 0;  // 标记已处理,下帧执行
            break;  // 停止处理——等下一帧
        }

        u8g2_menu->event.itemsRead
            = (u8g2_menu->event.itemsRead + 1) % U8G2_MENU_EVENT_Capacity;

        // 过滤器:用户可拦截
        if (menuEventUserHandle_weak(u8g2_menu, item) == 0)
            if (item->menuEventHandle)
                item->menuEventHandle(u8g2_menu, item->value);

        u8g2_menu->event.quantity--;
    }
}

4.4 nextLoopRequired 的巧用

这个位字段解决了"在当前帧处理事件可能导致重入"的问题。例如快捷字符跳转:

void u8g2_MenuItemBindChar(char c, uint8_t selected)
{
    u8g2_menu_event_item_t eventItem;

    // 事件 1:移动到目标项(立即执行)
    eventItem.nextLoopRequired = 0;
    eventItem.menuEventHandle = u8g2_MenuEvent_itemBindChar_itemMove;
    eventItem.value = (void*)(uint32_t)menu->currentDrawItem;
    u8g2_MenuEventRecord(menu, &eventItem);

    // 事件 2:模拟 Enter 键(延迟一帧)
    eventItem.nextLoopRequired = 1;
    eventItem.menuEventHandle = u8g2_MenuEvent_itemBindChar_itemSelect;
    eventItem.value = (void*)(uint32_t)selected;
    u8g2_MenuEventRecord(menu, &eventItem);
}

nextLoopRequired=0 的事件立刻处理,处理完后 nextLoopRequired=1 的事件被留在队列里,下一帧才会执行。这确保了"先移动到项 N"→"下一帧再对项 N 执行选中"。


5. 字符输入:u8g2_MenuInChar

void u8g2_MenuInChar(u8g2_menu_t *u8g2_menu, char c)
{
    u8g2_menuCharEvent_weak(u8g2_menu, &c);  // 用户可拦截/修改字符

    // 如果当前绑定的是字符串输入
    if (u8g2_menu->u8g2_menuValueType == MENU_str
        && u8g2_MenuGetItemSelect(u8g2_menu) != -1)
    {
        uint16_t len = strlen(u8g2_menu->u8g2_menuValue.str.s);
        if (U8G2_MENUKeyValue_Back == c) {
            if (len > 0)
                u8g2_menu->u8g2_menuValue.str.s[len - 1] = '\0';
        } else if (U8G2_MENUKeyValue_Clear == c) {
            u8g2_menu->u8g2_menuValue.str.s[0] = '\0';
        } else if (len + 1 < u8g2_menu->u8g2_menuValue.str.s_len
                   && isprint(c)) {
            u8g2_menu->u8g2_menuValue.str.s[len] = c;
            u8g2_menu->u8g2_menuValue.str.s[len + 1] = '\0';
        }
    }
    else if (c) {
        u8g2_menu->bindChar = c;  // 否则用于快捷跳转
    }
}

双模式:编辑字符串输入时,字符追加到缓冲区;非编辑态时,字符触发快捷跳转。


6. 弱定义钩子全景

按键系统暴露了 4 个弱定义钩子,组成一条"责任链":

// 1. 预处理(修改键值)
WEAK void menuEventKeyPre_weak(u8g2_menu_t *m, u8g2_menuKeyValue_t *k);

// 2. 拦截(返回 1 则停止后续处理)
WEAK uint8_t menuEventKey_weak(u8g2_menu_t *m, u8g2_menuKeyValue_t k);

// 3. 通知(不能修改键值,只能知道有按键发生)
WEAK void u8g2_menuKeyEvent_weak(u8g2_menu_t *m, u8g2_menuKeyValue_t *k);

// 4. 自定义按键处理
WEAK void menuEventUserKey_weak(u8g2_menu_t *m, u8g2_menuKeyValue_t k);

责任链流程:

物理按键
  → [1] menuEventKeyPre_weak   修改键值(编辑态映射)
  → [2] menuEventKey_weak      拦截?(返回1=停止)
  → [3] u8g2_menuKeyEvent_weak 通知(被动监听)
  → 库内部 switch 分发
  → [4] menuEventUserKey_weak  未识别按键(USER_1~6)

7. 小结

设计 收益 代价
积分器消抖 可靠,参数可调 需定时中断驱动
长按 repeatTime 机制 自然的手感 比简单轮询多 4 个状态变量/键
环形事件队列 O(1)、无动态分配 固定容量可能溢出
nextLoopRequired 避免重入 增加一帧延迟
弱定义责任链 灵活可扩展 5 个钩子增加复杂度

核心教训:按键系统是嵌入式 UI 库的"脊柱"。MenuKey_debouncePeriod=20 这个数字不是拍脑袋——需要在抖动宽度(5~20ms)和响应延迟之间做折中。如果你移植到不同的硬件平台,首先需要调整的就是消抖和长按的配置宏。

下一篇,我们将进入可视化模块——双缓冲图表、XBM 图片、滑块进度条和图层叠加。


上一篇:从零构建嵌入式菜单库(三):变量绑定系统
下一篇:从零构建嵌入式菜单库(五):图表、图片、控件与图层

Logo

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

更多推荐