从零构建嵌入式菜单库(四):按键系统与事件驱动——从轮询到响应式
从零构建嵌入式菜单库(四):按键系统与事件驱动——从轮询到响应式
系列定位:这是一套编写教程。
前几篇我们建好了菜单骨架和变量绑定,但菜单还不会响应用户操作。本篇解决:物理按键如何变成菜单操作——以及如何用事件系统让整个框架可扩展。
前言:嵌入式按键的挑战
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=18 和 MenuKey_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 图片、滑块进度条和图层叠加。
更多推荐
所有评论(0)