经典三问
Q: 按键事件是什么?
A: 它是程序根据电平变化和持续时间整理出的单击、长按或多击等一次性结果。
Q: 为什么要把电平转换成事件?
A: 业务代码关心的是"用户完成了什么动作",不应该重复处理抖动、计时和多击窗口。
Q: 按键事件常见使用场景?
A: 菜单确认与返回、模式切换、参数调整、急停和组合按键操作。
上一回的按键还是老写法:按下、消抖、等松手,那一段 while 把主循环冻在原地。灯已经躲进 1ms 节拍里不受影响了,可按键自己还堵着路------按住不放的时候,主循环什么都不做。
这一回把它也拆开。我不会一次写完一大坨代码,而是从最小的一版开始,每加一个功能就把改过的函数重新放一遍。第一版只认单击,之后依次加上"松开"、双击、长按、组合键。每一版都能单独烧进板子跑起来,做到哪一版就能停在哪一版。
非阻塞的第二种形状:事件
上一回的形状是周期任务:记起点、保持状态、短节拍反复来、到点动一小步。它擅长处理"过一段时间做一次"的事。
按键不是这类事。什么时候被按、按多久、按几次,全看手指,没有固定周期。它需要另一种形状:
- 底层专心盯电平,把连续变化整理成一次性结果------按下、松开、单击、双击、长按;
- 结果不立刻决定做什么,先放进一个小队列;
- 应用层从队列里取出来,自己决定每个结果对应什么动作。
分层之后,底层只回答"发生了什么",应用层只回答"这该做什么"。按键模块不需要知道车上有没有呼吸灯,业务代码也不用再管抖动、计时和几次点击的窗口。
后面每一版都只动其中一层:要么动驱动(多认出一种事件),要么动应用层(给新事件接上动作)。层级清楚了,加功能才不会牵一发动全身。
接线和读引脚
两个按键用内部上拉、按下接地的接法:A 接 PB11,B 接 PB1,松开读到高电平,按下读到低电平。先给它们起好名字,再把引脚读出来:
c
typedef enum
{
KEY_A = 0,
KEY_B,
KEY_COUNT
} KeyId;
static const uint16_t key_pins[KEY_COUNT] =
{
GPIO_Pin_11, GPIO_Pin_1
};
static uint8_t Key_ReadPin(uint8_t id)
{
return (uint8_t)(GPIO_ReadInputDataBit(GPIOB, key_pins[id]) == 0U);
}
Key_ReadPin(KEY_A) 返回 1 表示 A 此刻正被按着,返回 0 表示松着。它读的是瞬时电平,带着抖动------这一层的输出不能直接当动作使用,这正是后面要处理的东西。
GPIO_ReadInputDataBit() 是前面用过的标准库函数,读一个引脚当前的电平。
第一版:只认单击
先把最小的骨架立起来:按键能被稳定地读出来,一次按下产生一个事件,应用层照着表执行动作。
事件和状态
驱动层要交出去的是"一次性结果",那就先给它一个名字。
c
typedef enum
{
KEY_EVENT_CLICK = 0,
KEY_EVENT_COUNT
} KeyEventType;
typedef struct
{
uint8_t id;
uint8_t type;
} KeyEvent;
KeyEvent 里两样东西:是谁(id)、发生了什么(type)。这一版只会产生 KEY_EVENT_CLICK 一种事件。
每个按键各自记一份状态,互不干扰:
c
#define KEY_SCAN_MS 20U /* 每 20ms 采一次,消抖就靠这个采样 */
typedef struct
{
uint8_t down; /* 消抖之后的状态:1 按着,0 松着 */
uint8_t raw; /* 上一拍采到的原始值,和 down 不一样就说明还在抖 */
uint32_t edge_ms; /* down 是哪一刻变成现在这样的 */
} KeyRuntime;
static KeyRuntime key_runtime[KEY_COUNT];
static uint8_t key_div = 0U; /* 1ms 计数,到 20 清零再扫 */
void Key_PushEvent(uint8_t id, uint8_t type); /* 入队,定义见下面的队列一节 */
消抖:20ms 采一次,两拍一致才算数
按键模块不"延时 20ms 再读一次"。它挂在 1ms 节拍上,但每 20 次才真正扫一遍------采样本身就是消抖:一个电平要连续两次采样都读到它,才算真的变了。这样既没有延时,也不用再单独数一个"稳定了多久"的计数器。
c
void Key_Tick(void)
{
uint8_t i;
uint32_t now;
if (++key_div < KEY_SCAN_MS)
{
return; /* 还没到 20ms,这一毫秒什么也不做 */
}
key_div = 0U;
now = Timer_Millis();
for (i = 0U; i < KEY_COUNT; i++)
{
KeyRuntime *k = &key_runtime[i];
uint8_t pin = Key_ReadPin(i);
if (pin == k->down) /* 和稳定状态一致,没有新变化 */
{
continue;
}
if (pin != k->raw) /* 第一拍看到它变了,先记下,再等一拍 */
{
k->raw = pin;
continue;
}
k->down = pin; /* 第二拍还是它:认这个新状态 */
k->edge_ms = now;
if (k->down == 0U) /* 松手就是一次点击 */
{
Key_PushEvent(i, KEY_EVENT_CLICK);
}
}
}
Key_Tick() 挂在 1ms 中断里,和上一回的 Mode_Tick() 并排------不过它每 20 次才真正扫一遍键。扫描频率降下来了,等待仍然是零。
那个 1ms 中断现在是这样:
c
void TIM1_UP_IRQHandler(void)
{
if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
Mode_Tick(); /* 上一回的灯 */
Key_Tick(); /* 这一回的键 */
}
}
事件先排队,应用层再来取
事件产生后先进队列,应用层从队列里取。队列是一块小环形缓冲,两个指针一进一出:
c
#define KEY_QUEUE_SIZE 8U
static KeyEvent key_queue[KEY_QUEUE_SIZE];
static uint8_t key_queue_head = 0U;
static uint8_t key_queue_tail = 0U;
void Key_PushEvent(uint8_t id, uint8_t type)
{
uint8_t next = (uint8_t)((key_queue_head + 1U) % KEY_QUEUE_SIZE);
if (next == key_queue_tail)
{
return; /* 队列满,丢掉新事件 */
}
key_queue[key_queue_head].id = id;
key_queue[key_queue_head].type = type;
key_queue_head = next;
}
uint8_t Key_GetEvent(KeyEvent *event)
{
if (key_queue_head == key_queue_tail)
{
return 0U;
}
*event = key_queue[key_queue_tail];
key_queue_tail = (uint8_t)((key_queue_tail + 1U) % KEY_QUEUE_SIZE);
return 1U;
}
为什么不让按键驱动直接调用业务函数?因为那样驱动就得知道"按下 A 要切模式"这种业务规则。加了队列以后,驱动只负责报告,业务只负责决定------中间隔着一块缓冲,两边谁都不用认识对方。
顺带还换来一点缓冲能力:一次主循环可以把积压的几个事件都取干净,短暂的处理不过来不会立刻丢动作。缓冲不是无限的------队列一共 8 格,满了以后新事件会被丢掉,所以真正的小车里要按事件重要性决定取舍:长按的重复事件可以丢,停止和急停不能静默丢掉。
应用层:一张动作表
应用层不再写一长串 if,而是列一张表:哪个键的哪种事件,对应哪个动作函数。这和上一回的模式表是同一种做法。
先写动作函数,每个只做一件小事:
c
void Action_NextMode(void)
{
next_mode = (uint8_t)((next_mode + 1U) % LED_MODE_COUNT);
}
void Action_PrevMode(void)
{
next_mode = (uint8_t)((next_mode + LED_MODE_COUNT - 1U) % LED_MODE_COUNT);
}
next_mode、LED_MODE_COUNT 都来自上一回的模式框架:改一个变量就等于换一档模式,动作函数里不需要出现任何模式内部的东西。
再写表本身和分发函数:
c
typedef struct
{
uint8_t id; /* 哪个按键 */
uint8_t type; /* 哪种事件 */
void (*Action)(void); /* 对应的动作 */
} KeyAction_TypeDef;
#define ACTION_COUNT (sizeof(action_table) / sizeof(action_table[0]))
static const KeyAction_TypeDef action_table[] =
{
{ KEY_A, KEY_EVENT_CLICK, Action_NextMode }
};
static void HandleEvent(const KeyEvent *event)
{
uint8_t index;
for (index = 0; index < ACTION_COUNT; index++)
{
if ((action_table[index].id == event->id) &&
(action_table[index].type == event->type))
{
action_table[index].Action();
return;
}
}
}
ACTION_COUNT 用 sizeof 算出来,所以表里加一行,循环次数自己就跟着变------不用手改任何数字。这和上一回 mode_table 的做法一样:表变了,框架不用动。
屏幕上的证据和主循环
事件有没有按预期产生,靠屏幕看。三行显示用一个小函数刷出来:
c
static const char *event_names[] =
{
"CLICK", "HOLD", "RELEASE", "DOUBLE", "LONG"
};
static uint8_t led_selected = 0U; /* 当前被操作的那颗灯 */
static uint32_t event_count = 0;
void OLED_ShowEvent(const KeyEvent *event, uint32_t count)
{
OLED_ShowString(0, 0, (char *)event_names[event->type], OLED_6X8);
OLED_ShowNum(0, 8, count, 4, OLED_6X8);
OLED_ShowNum(0, 16, led_selected, 1, OLED_6X8);
OLED_Update();
}
event_names[] 的顺序要和事件枚举一一对应。这里一次把后面几版要用到的名字都排好,扩展时这个数组不用再动。led_selected 记录"模式现在写给哪一颗灯",第三版会给它加上换值的动作。
主循环只剩三件事:
c
static KeyEvent event;
static uint32_t event_count = 0;
while (1)
{
while (Key_GetEvent(&event) != 0U)
{
event_count++;
HandleEvent(&event);
OLED_ShowEvent(&event, event_count);
}
Mode_Loop();
}
Key_Tick() 和 Mode_Tick() 都挂在 1ms 中断里,主循环只剩收事件和跑模式两步,每一步都不等待。
第一版的接线和现象
按键不变,LED 沿用上一回的三颗灯。第一版的完整现象是:按一下 A,模式切到下一档,屏幕第一行出现 CLICK,第二行的计数加一。
骨架到这里就搭好了。接下来每加一个功能,需要改的地方都不会超过两个函数。
第二版:把"按下"和"松开"分开
第一版只在松手时报一次 CLICK,按下那一刻什么也不说。可很多事需要知道手指什么时候按下去、什么时候离开:长按要从按下开始算,而"按住不放"这件事本身也得能报出来。所以先把驱动层说清楚:电平从松到按是 HOLD,从按到松是 RELEASE。
事件类型多两个名字:
c
typedef enum
{
KEY_EVENT_CLICK = 0,
KEY_EVENT_HOLD,
KEY_EVENT_RELEASE,
KEY_EVENT_COUNT
} KeyEventType;
Key_Tick() 里,按下和松开分成两条路。整个函数重新放一遍(和第一版相比,只多了中间那几行):
c
void Key_Tick(void)
{
uint8_t i;
uint32_t now;
if (++key_div < KEY_SCAN_MS) { return; } /* 每 20ms 才扫一遍 */
key_div = 0U;
now = Timer_Millis();
for (i = 0U; i < KEY_COUNT; i++)
{
KeyRuntime *k = &key_runtime[i];
uint8_t pin = Key_ReadPin(i);
if (pin == k->down) { continue; } /* 没变化 */
if (pin != k->raw) { k->raw = pin; continue; }/* 第一拍先记下,再等一拍 */
k->down = pin; /* 两拍一致,认了 */
k->edge_ms = now;
if (k->down != 0U)
{
Key_PushEvent(i, KEY_EVENT_HOLD);
}
else
{
Key_PushEvent(i, KEY_EVENT_CLICK);
Key_PushEvent(i, KEY_EVENT_RELEASE);
}
}
}
按下发 HOLD,松开时一次产生两个事件:CLICK 在前、RELEASE 在后。屏幕第一行只显示最近一个,所以松手后停在 RELEASE,而事件计数会一次加二------两个事件都确实产生过。CLICK 暂时还在松开这一支里直接发,第三版会把它挪出去。KeyRuntime 这一版不用加字段,所以结构体的定义保持原样。
应用层的表加两行,顺便给 HOLD 和 RELEASE 各接一个能看见的动作:
c
static const KeyAction_TypeDef action_table[] =
{
{ KEY_A, KEY_EVENT_CLICK, Action_NextMode },
{ KEY_B, KEY_EVENT_CLICK, Action_PrevMode },
{ KEY_A, KEY_EVENT_HOLD, Action_AllOn },
{ KEY_A, KEY_EVENT_RELEASE, Action_AllOff }
};
两个新动作也都是一行:
c
void Action_AllOn(void)
{
LED_WriteAll(1);
}
void Action_AllOff(void)
{
LED_WriteAll(0);
}
现象变成:按住 A 不松,屏幕先显示 HOLD、三颗灯全亮;松手时事件计数一次加二(先 CLICK 后 RELEASE),屏幕停在 RELEASE,灯全灭,模式同时切到下一档。
按住 A 的过程里仍然没有任何等待------HOLD 是在按下那一刻产生的,之后主循环照常运转,另一个按键的动作照样能被处理。
第三版:双击,和一个绕不开的延迟
现在"松开就发 CLICK"有个问题:如果紧接着又按了一次,那第一次的 CLICK 已经发出去了,等到发现是双击时已经晚了。双击要认出来,就不能在第一次松手时急着下结论。
所以 CLICK 从松手那一刻搬出去,改成"松手只记一次点击,等一小段时间看看还有没有下一次"。
状态里多两个字段:
c
typedef struct
{
uint8_t down; /* 消抖后的状态 */
uint8_t raw; /* 上一拍原始值 */
uint8_t taps; /* 这一组里松手了几次,最多记到 2 */
uint32_t edge_ms; /* 状态变过来的时刻 */
} KeyRuntime;
#define KEY_CLICK_WIN_MS 400U
事件类型多一个 DOUBLE_CLICK(枚举里加在 RELEASE 之后):
c
typedef enum
{
KEY_EVENT_CLICK = 0,
KEY_EVENT_HOLD,
KEY_EVENT_RELEASE,
KEY_EVENT_DOUBLE_CLICK,
KEY_EVENT_COUNT
} KeyEventType;
Key_Tick() 也要重放一遍:松手那一支改成只累加 taps,等窗口到期再下结论;活着的那一支多了"看窗口"这一步。
c
void Key_Tick(void)
{
uint8_t i;
uint32_t now;
uint32_t elapsed;
if (++key_div < KEY_SCAN_MS) { return; }
key_div = 0U;
now = Timer_Millis();
for (i = 0U; i < KEY_COUNT; i++)
{
KeyRuntime *k = &key_runtime[i];
uint8_t pin = Key_ReadPin(i);
if (pin == k->down)
{
if (k->down == 0U) /* 松着,看窗口到期没有 */
{
elapsed = now - k->edge_ms;
if ((k->taps > 0U) && (elapsed >= KEY_CLICK_WIN_MS))
{
Key_PushEvent(i, (k->taps >= 2U) ? KEY_EVENT_DOUBLE_CLICK
: KEY_EVENT_CLICK);
k->taps = 0U;
}
}
continue;
}
if (pin != k->raw) { k->raw = pin; continue; }
k->down = pin;
k->edge_ms = now;
if (k->down != 0U)
{
Key_PushEvent(i, KEY_EVENT_HOLD);
}
else
{
if (k->taps < 2U) { k->taps++; } /* 松手只记一次,先不下结论 */
Key_PushEvent(i, KEY_EVENT_RELEASE);
}
}
}
Timer_Millis() 是那个 1ms 中断一直维护的毫秒计数,和上一回的 tick_ms 是同一个时间轴。
窗口的规则是:每次松手重新计时 400ms (edge_ms 就在那一刻更新),这 400ms 之内没有再按下来,就按 taps 结算 CLICK 或 DOUBLE_CLICK,然后清零、下一组重新开始。
注意这一版只用了"整组 400ms"这一个门槛,并没有单独限制"相邻两次点击最多隔多久"。想再加一条"两次点击间隔超过 200ms 就算两组"的规则,得在松开那一支里比较这次点击和上次点击的时间差------那是更细的一层规则,先不往代码里叠。
这里有个真实的代价:单击要等 400ms 才被确认。按下去到看见模式切换,手上会有明显的停顿感。这不是写错了,是"允许双击"必然要付的钱------程序必须留出一段时间来观察"还会不会再来一次"。要更快的单击响应,可以减少点击级数或缩短窗口,或者改用"先发单击、后撤回"的交互规则,那是另一套取舍。
表里那个 { KEY_A, KEY_EVENT_CLICK, Action_NextMode } 一行都不用改------它还是在等 CLICK,只不过这个事件现在晚 400ms 才到。
应用层给双击接上动作,顺便让双击做点单击做不到的事:换"被操作的那一颗灯"。
c
void Action_NextLed(void)
{
led_selected = (uint8_t)((led_selected + 1U) % 3U);
}
void Action_PrevLed(void)
{
led_selected = (uint8_t)((led_selected + 2U) % 3U);
}
上一回的六档模式里,点闪、慢闪、快闪、呼吸都是写给三颗的(LED_WriteAll)。这一版把它们的输出对象换成选中的那一颗,例如慢闪那一句:
c
LED_WriteOne(led_selected, (uint8_t)((elapsed % 1000U) < 500U));
模式输出要改对象,就再补一个设置接口:
c
void LED_SetSingle(uint8_t index)
{
if (index < 3U)
{
led_selected = index;
}
}
整组的"全亮、全灭"仍然用 LED_WriteAll(),留给下面长按那一版要用到的动作。表变成:
c
static const KeyAction_TypeDef action_table[] =
{
{ KEY_A, KEY_EVENT_CLICK, Action_NextMode },
{ KEY_B, KEY_EVENT_CLICK, Action_PrevMode },
{ KEY_A, KEY_EVENT_HOLD, Action_AllOn },
{ KEY_A, KEY_EVENT_RELEASE, Action_AllOff },
{ KEY_A, KEY_EVENT_DOUBLE_CLICK, Action_NextLed },
{ KEY_B, KEY_EVENT_DOUBLE_CLICK, Action_PrevLed }
};
现象:单击 A,等大约 400ms,屏幕出现 CLICK,模式换一档;两次单击 A 的间隔小于 200ms,屏幕最终停的是 DOUBLE,模式不动,而是换了被操作的那颗灯。
第四版:长按,和"动作搬家"
单击、双击都有了,还差一种最常用的操作:按住不放。它的规则和前两种不同------不是"某件事发生一次",而是"持续按住之后每隔一段时间报一次"。
事件类型加 LONG_HOLD:
c
typedef enum
{
KEY_EVENT_CLICK = 0,
KEY_EVENT_HOLD,
KEY_EVENT_RELEASE,
KEY_EVENT_DOUBLE_CLICK,
KEY_EVENT_LONG_HOLD,
KEY_EVENT_COUNT
} KeyEventType;
状态里加三个字段记录长按的进度:
c
typedef struct
{
uint8_t down; /* 消抖后的状态 */
uint8_t raw; /* 上一拍原始值 */
uint8_t taps; /* 这一组里松手了几次,最多记到 2 */
uint8_t long_sent; /* 这次按压的长按报过没有 */
uint32_t edge_ms; /* down 是哪一刻变成现在这样的 */
} KeyRuntime;
#define KEY_LONG_MS 800U
#define KEY_REPEAT_MS 100U
Key_Tick() 重放一遍:按下时记下时刻、把长按标记清掉;松开时如果这次按压已经被长按用掉了,就不再算一次点击。
c
void Key_Tick(void)
{
uint8_t i;
uint32_t now;
uint32_t elapsed;
if (++key_div < KEY_SCAN_MS) { return; }
key_div = 0U;
now = Timer_Millis();
for (i = 0U; i < KEY_COUNT; i++)
{
KeyRuntime *k = &key_runtime[i];
uint8_t pin = Key_ReadPin(i);
if (pin != k->down)
{
if (pin != k->raw) { k->raw = pin; continue; }
k->down = pin;
k->edge_ms = now;
if (k->down != 0U)
{
k->long_sent = 0U;
Key_PushEvent(i, KEY_EVENT_HOLD);
}
else
{
if (k->long_sent == 0U) /* 没被长按用掉,才算一次点击 */
{
if (k->taps < 2U) { k->taps++; }
}
Key_PushEvent(i, KEY_EVENT_RELEASE);
}
continue;
}
elapsed = now - k->edge_ms;
if (k->down != 0U)
{
if (k->taps != 0U) { continue; } /* 还有点没结算,先归点击那一组 */
if (k->long_sent == 0U)
{
if (elapsed >= KEY_LONG_MS)
{
k->long_sent = 1U;
k->edge_ms = now;
Key_PushEvent(i, KEY_EVENT_LONG_HOLD);
}
}
else if (elapsed >= KEY_REPEAT_MS)
{
k->edge_ms = now;
Key_PushEvent(i, KEY_EVENT_LONG_HOLD);
}
}
else if ((k->taps > 0U) && (elapsed >= KEY_CLICK_WIN_MS))
{
Key_PushEvent(i, (k->taps >= 2U) ? KEY_EVENT_DOUBLE_CLICK : KEY_EVENT_CLICK);
k->taps = 0U;
}
}
}
没有后面那个 long_sent 判断会怎样?按住 A 超过 800ms 再松手,程序会先发 LONG_HOLD(灯全亮),紧接着再发一个 CLICK(模式切下一档)------一次按压冒出两个动作。加上判断以后,一次按压只归一类:要么是一次点击,要么是一次长按。
长按的检查函数单独一个,规则是"到 800ms 报第一次,之后每 100ms 再报一次":
那个 if (k->taps != 0U) { continue; } 是长按和点击的分界线:上一组点击还没结算(taps 不是 0),这一次按压就先不报长按。手指一直压着、而且从没点过时,taps 是 0,程序才会去比对 KEY_LONG_MS。
还有一种夹在中间的情形:先点一下、松手,紧接着又按住不放。这时手指是按着的,但上一组的点击还没结算(taps 还是 1)------这一下按压会被算作"这一组的后续点击",等窗口结算、taps 清零以后,长按才从头开始计时。反过来说,一次按压要么属于一组点击,要么属于一次长按,不会两边都算。
现在说"动作搬家"。第二版里,HOLD 和 RELEASE 被接成了"全亮"和"全灭"。那是当时的临时安排------按住就全亮,其实更像"长按"该干的事。现在长按有了名字,这两个动作就该挪过去:
c
static const KeyAction_TypeDef action_table[] =
{
{ KEY_A, KEY_EVENT_CLICK, Action_NextMode },
{ KEY_B, KEY_EVENT_CLICK, Action_PrevMode },
{ KEY_A, KEY_EVENT_DOUBLE_CLICK, Action_NextLed },
{ KEY_B, KEY_EVENT_DOUBLE_CLICK, Action_PrevLed },
{ KEY_A, KEY_EVENT_LONG_HOLD, Action_AllOn },
{ KEY_B, KEY_EVENT_LONG_HOLD, Action_AllOff }
};
表里 HOLD 和 RELEASE 两行被删掉了。事件本身还在产生(屏幕上照样显示 HOLD 和 RELEASE),只是暂时没有动作接它们------这正是分层的用处:驱动层照旧报告,应用层想接就接、想撤就撤。
现象:按住 A 不松,屏幕先显示 HOLD,约 800ms 后开始每隔 100ms 显示一次 LONG_HOLD,三颗灯保持全亮;松手出现 RELEASE,灯灭,而且不会再补一个 CLICK。
第五版:组合键,为什么它进不了表
最后一种操作:按住一个键的同时单击另一个键。上一版那张表只能表达"某键的某事件",表达不了"另一个键现在还按着"------表里没有位置放这个条件。
所以组合判断写在查表之前,单独一段:
c
static void HandleEvent(const KeyEvent *event)
{
uint8_t index;
if ((event->id == KEY_A) && (event->type == KEY_EVENT_CLICK) &&
(Key_IsDown(KEY_B) != 0U))
{
next_mode = LED_MODE_BREATH; /* 按住 B,单击 A:进呼吸档 */
return;
}
for (index = 0; index < ACTION_COUNT; index++)
{
if ((action_table[index].id == event->id) &&
(action_table[index].type == event->type))
{
action_table[index].Action();
return;
}
}
}
Key_IsDown() 读的是稳定之后的状态,专门给这种组合判断用:
c
uint8_t Key_IsDown(uint8_t id)
{
return (id < KEY_COUNT) ? key_runtime[id].down : 0U;
}
关键是顺序:组合判断必须在查表之前 。如果先查表,A 的 CLICK 已经按表把模式切走了,再想"其实是组合键"就晚了。这和第三版里"单击要等窗口结束才确认"是同一个道理------条件没集齐之前不能先动手。
现象:按住 B 不松,单击 A,屏幕出现 CLICK,程序直接进呼吸档;A 的长按、B 的长按各走各的路,互不干扰。松开 B 再单击 A,A 恢复成"切下一档模式"。
五版下来的全貌
驱动层每次都只是多认一种事件:
| 版本 | 驱动层多出来的东西 | 应用层多出来的东西 |
|---|---|---|
| 第一版 | 消抖 + 松手产生 CLICK |
动作表;A 单击切下一档 |
| 第二版 | HOLD、RELEASE |
A 按住全亮、松手全灭(临时) |
| 第三版 | 点击计数 + 400ms 窗口;DOUBLE_CLICK |
A/B 双击换选中灯 |
| 第四版 | LONG_HOLD(800ms + 100ms 重复) |
长按接管全亮/全灭,HOLD 退出表 |
| 第五版 | Key_IsDown()(读稳定状态,给组合判断用) |
按住 B + 单击 A 进呼吸档 |
next_mode、LED_MODE_COUNT、Mode_Loop() 都来自上一回的六档模式框架与那张 mode_table------应用层始终只改 next_mode 这一个变量,模式内部怎么点灯、点哪一颗,都由上一回那套写法负责。
最终的事件与动作对照:
| 操作 | 事件 | 应用动作 |
|---|---|---|
| A 单击 | CLICK |
切到下一档模式 |
| B 单击 | CLICK |
切到上一档模式 |
| A 双击 | DOUBLE_CLICK |
选中下一颗灯 |
| B 双击 | DOUBLE_CLICK |
选中上一颗灯 |
| A 长按 | LONG_HOLD |
全部 LED 亮 |
| B 长按 | LONG_HOLD |
全部 LED 灭 |
| 按住 B + 单击 A | CLICK(带条件) |
进入呼吸档 |
三个可以复现的操作
单击与双击的边界
单击 A 一次并等到屏幕出现 CLICK,模式换到下一档;两次单击 A 的间隔保持在 200ms 内,屏幕最终停在 DOUBLE,模式不变、选中的灯换一颗。点击间隔不要超过 400ms:超过以后上一组窗口已经结算,两次点击会各自确认,模式连换两档。
长按与长按的重复
按住 A 不松:先看到 HOLD,约 800ms 后开始每 100ms 出现一次 LONG_HOLD,灯保持全亮;松手出现 RELEASE,不会再出现 CLICK。
按住 B 单击 A
按住 B 不松,单击 A。B 一直是按着的,A 的 CLICK 被组合判断截住,程序进入呼吸档。松开 B 之后再单击 A,A 恢复成"切下一档"。
一个常见的代码错误
把单击直接写在松开那一支里:
c
else
{
Key_PushEvent(index, KEY_EVENT_CLICK);
Key_PushEvent(index, KEY_EVENT_RELEASE);
}
这正是第二版的样子。在这一版里它是对的------那时还不认双击。等加上双击以后,如果这里不改,双击一定会先换两次模式,等 400ms 窗口结束时再想补救已经晚了。驱动层每多认一种事件,都要回头检查旧事件产生的位置还合不合适,这也是为什么单击那一行要从这里搬到窗口检查里。
最后橘猫说
输入这一层现在的样子是:电平每 1ms 被读一次,抖动被时间过滤掉,剩下的变化被整理成一次性事件,事件进队列,应用层取出来决定动作。五种事件、一张动作表、一处组合判断,就是全部。
和上一回的周期任务放在一起看,非阻塞只有两种形状------一种是"每段时间做一次",一种是"有事发生就报一次"。按键属于后一种,而它内部的每一次扩展都没有引入等待:识别双击靠的是一个时间窗口在 1ms 节拍上被反复检查,认长按靠的是另一个时间点被反复比较。小车上要处理的输入输出,绝大多数落在这两种形状里:定时器负责让时间往前走,输入层负责把外部世界的变化翻译成事件,应用层只做决定,谁也不替谁等。