3、按键扫描算法:行列扫描法、中断触发法、状态机扫描、低功耗扫描策略
按键扫描,说白了就是怎么让单片机知道「哪个键被按下了」。
这个问题看起来简单,但实际项目中坑不少。我刚开始做产品时,就因为在扫描策略上偷了懒,结果产品在用户手里各种「按键失灵」的投诉。嗯,后来老老实实把几种算法都吃透了,才算是真正搞定。
3.1 行列扫描法——最基础的方案
行列扫描法,也叫矩阵扫描。它的核心思想是:用较少的IO口,检测较多的按键。
举个例子:4行×4列,只需要8个IO口,就能搞定16个按键。如果每个按键单独接一个IO,16个键就得16个口,成本直接翻倍。
工作原理是这样的:
- 先把所有行线置为低电平,列线置为高电平(或者反过来)。
- 然后逐行拉低,读取列线的电平变化。
- 如果某列变成了低电平,说明该行与该列交叉点的按键被按下了。
关键点:行列扫描必须配合「消抖」处理。机械按键按下和释放时,会有大约5-20ms的抖动期。不消抖的话,一次按键可能被误判成多次。
// 行列扫描伪代码示例
uint8_t scan_keypad(void) {
uint8_t row, col;
for (row = 0; row < 4; row++) {
set_row_low(row); // 拉低当前行
delay_ms(1); // 等待电平稳定
col = read_column(); // 读取列状态
if (col != 0xFF) { // 有按键按下
delay_ms(20); // 软件消抖
col = read_column(); // 再次确认
if (col != 0xFF) {
return (row << 4) | col; // 返回键值
}
}
set_row_high(row); // 恢复高电平
}
return NO_KEY_PRESSED;
}
我在项目中遇到过一个问题:行列扫描时,如果按键按得很快,扫描周期跟不上,就会漏掉按键。后来我加了一个「连续扫描三次」的机制,才把这个问题解决掉。
3.2 中断触发法——让CPU喘口气
行列扫描有个明显的缺点:CPU得不停地轮询。哪怕没按键,也得一遍遍扫描,浪费电。
中断触发法就不一样了。平时CPU该干嘛干嘛,只有按键按下时,才触发中断去处理。
实现方式:
- 把所有行线或列线通过一个「或门」接到一个外部中断引脚上。
- 任何按键按下,都会引起电平变化,触发中断。
- 在中断服务函数里,再调用行列扫描,确定具体是哪个键。
我的经验:中断触发法特别适合「唤醒」场景。比如设备在休眠,用户按一下键,中断唤醒CPU,然后执行扫描。这样功耗能降到极低。
但要注意一点:中断服务函数里不要做太多事。我曾经犯过一个错,在中断里直接做了按键处理逻辑,结果导致中断响应时间过长,影响了系统实时性。正确的做法是:中断里只设置一个标志位,主循环里再去处理。
// 中断服务函数(简洁版)
void EXTI_IRQHandler(void) {
if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
key_pressed_flag = 1; // 只设标志,不做具体处理
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
// 主循环中处理
while (1) {
if (key_pressed_flag) {
key_pressed_flag = 0;
uint8_t key = scan_keypad(); // 调用行列扫描确定键值
process_key(key); // 处理按键逻辑
}
// 其他任务...
}
3.3 状态机扫描——告别混乱的代码
你想想看,如果按键处理逻辑全写在if-else里,代码会变成什么样?
我见过一个项目,按键处理函数写了300多行,各种嵌套、各种标志位,改一个地方就崩一片。后来我全部重构成状态机,代码量少了三分之二,还特别稳定。
按键状态机通常包含这几个状态:
| 状态 | 说明 | 典型处理 |
|---|---|---|
| IDLE | 空闲状态,无按键按下 | 持续扫描,检测到低电平则进入DEBOUNCE |
| DEBOUNCE | 消抖等待状态 | 延时20ms,再次检测,确认按下则进入PRESS |
| PRESS | 按键已按下 | 记录键值,触发按键事件,等待释放 |
| RELEASE | 按键释放状态 | 检测到高电平,回到IDLE |
| LONG_PRESS | 长按检测状态 | 计时超过阈值,触发长按事件 |
// 状态机扫描核心代码
typedef enum {
STATE_IDLE,
STATE_DEBOUNCE,
STATE_PRESS,
STATE_RELEASE,
STATE_LONG_PRESS
} KeyState_t;
KeyState_t key_state = STATE_IDLE;
uint16_t press_timer = 0;
void key_scan_fsm(void) {
uint8_t key_val = read_key(); // 读取当前按键值
switch (key_state) {
case STATE_IDLE:
if (key_val != NO_KEY) {
key_state = STATE_DEBOUNCE;
press_timer = 0;
}
break;
case STATE_DEBOUNCE:
if (key_val != NO_KEY) {
// 消抖确认
key_state = STATE_PRESS;
key_event = KEY_PRESSED;
} else {
key_state = STATE_IDLE; // 抖动,回到空闲
}
break;
case STATE_PRESS:
press_timer++;
if (press_timer > LONG_PRESS_THRESHOLD) {
key_state = STATE_LONG_PRESS;
key_event = KEY_LONG_PRESS;
}
if (key_val == NO_KEY) {
key_state = STATE_RELEASE;
}
break;
case STATE_LONG_PRESS:
if (key_val == NO_KEY) {
key_state = STATE_RELEASE;
}
break;
case STATE_RELEASE:
key_state = STATE_IDLE;
key_event = KEY_RELEASED;
break;
}
}
注意:状态机里的每个状态转换,都要考虑「异常情况」。比如按键卡住了怎么办?我遇到过用户把东西压在按键上,结果设备一直处于PRESS状态,功耗飙升。后来我加了一个「超时复位」机制,超过一定时间没释放,强制回到IDLE。
3.4 低功耗扫描策略——省电才是硬道理
做电池供电的产品,功耗是头等大事。按键扫描如果处理不好,可能就是整机功耗的「罪魁祸首」。
常用的低功耗策略有这几种:
- 间歇扫描:平时让CPU进入休眠,每隔几十毫秒醒来扫描一次。没按键就继续睡。我做过一个遥控器,用这种方式,两节干电池用了两年。
- 中断唤醒:前面讲过的,按键按下触发中断,把CPU从深度睡眠中唤醒。
- 电容触摸替代:如果条件允许,用触摸按键代替机械按键。触摸按键没有机械触点,不需要频繁扫描,功耗更低。
- 动态调整扫描频率:设备活跃时,扫描频率高一些(比如10ms一次);设备待机时,扫描频率降到100ms一次。既保证响应速度,又省电。
我的建议:低功耗设计不是「一刀切」。你得根据产品的使用场景来权衡。比如一个门禁键盘,用户按完键就走了,那间歇扫描就够用。但如果是游戏手柄,按键响应要求高,那就得用中断触发法。
我曾经在一个智能家居面板上,把按键扫描功耗从2mA降到了50μA。怎么做到的?很简单:平时用定时器唤醒,每50ms扫描一次,扫描完立刻进休眠。再加上中断唤醒作为「紧急通道」,用户按键时能立即响应。效果非常好。
好了,这四种扫描策略,基本覆盖了绝大多数应用场景。实际项目中,我通常会组合使用:中断触发 + 状态机扫描 + 低功耗间歇扫描。这样既省电,又稳定,代码还好维护。你想想看,是不是这个理?