C C51 | 按键的单击、双击和长按的按键动作检测

90 阅读7分钟

本文目标

写一个 C51 程序, 当单击 K1 时,点亮 D1, 当双击 K1 时,点亮 D2, 当长按 K1 时,点亮 D3。

1 框架

这个项目最难搞的地方就是这个按键检测。

本文的项目拿 LED 的动作作为最终效果的检验。

#include <REGX52.H>
int k1Listener();

// [entry]
void main() {
    if (k1Listener == 1) {
        P2_0 = 0;
    } else if (k1Listener == 2) {
        P2_1 = 0;
    } else if (k1Listener == 3) {
        P2_2 = 0;
    }
}

int k1Listener() {
    // TODO
}

2 基础知识参考

2.1 按键检测的简单实现

通常像这样来检测按键状态

sbit K1 = P3^0;  // 定义按键引脚

if (K1 == 0) {
    // 按键被按下
} else {
    // 按键被释放
}

实际上,这样的检测存在一些问题。

2.2 按键抖动

几乎所有机械按键都存在一个称为按键抖动的机械现象。当按键被按下或释放时,由于机械触点的弹性特性,在稳定接触之前会产生一系列的快速通断现象,这个过程通常持续 5 - 20 毫秒。

如果没有处理抖动,单次按键操作可能会被误检测为多次按键。

2.3 定时器

为了准确检测不同的按键动作,特别是区分单击、双击和长按,应测量事件的时间分布。在 C51 系统中,这通常通过定时器中断来实现。定时器可以提供一个稳定的时间基准,让我们能够测量按键按下的持续时间、两次单击之间的时间间隔等关键时间参数。

3 构建

3.1 按键检测

从基础开始。这个版本只检测按键是否被按下,但加入了基本的消抖处理:

int key_scan(void) {
    static unsigned char key_state = 0;
    
    // 按下 K1
    if (K1 == 0) {
        if (key_state == 0) {
            delay(10);  // 消抖延时
            if (K1 == 0) {
                key_state = 1;
                return 1;  // 返回有效按下
            }
        }
    } else {
        key_state = 0;
    }
    return 0;
}

已经解决了按键抖动的问题。它通过一个状态变量来跟踪按键状态,只有在检测到稳定的按下信号后才返回有效按键。

这种方法只能检测到按键被按下,但无法区分这是单击、双击还是长按,这显然是不够的。

3.2 时间测量

不同的按键动作在时间特征上有着明显的区别

单击:短暂的按下和释放,整个过程通常在几十毫秒内完成

双击:两次连续的单击,两次按下之间的间隔通常在几百毫秒内

长按:持续的按下状态,通常需要保持1秒或更长时间

实现时间测量,可以利用 C51 的定时器功能

static unsigned int press_time = 0;

// 在定时器中断服务函数中
void timer_isr()
    interrupt 1
{
    TH0 = 0xFC;  // 重装定时器初值,实现1ms定时
    TL0 = 0x66;
    
    if (K1 == 0) {
        press_time++;  // 按键按下时持续计时
    }
}

通过这种方式,我们可以精确测量按键按下的持续时间,这是区分不同按键动作的基础。

3.3 构建状态机

以下是一个简单的状态转换图

graph LR
    A[状态0: 空闲] -->|按键按下| B[状态1: 按下检测]
    B -->|按键释放| C[状态2: 等待]
    B -->|长按超时| D[状态3: 确认为长按]
    C -->|超时判定| A
    D -->|按键释放| A

这个状态机描述了按键检测的基本流程:

  1. 从空闲状态开始,等待按键按下
  2. 按键按下后进入按下检测状态,开始计时
  3. 如果按键很快释放,进入释放等待状态,准备检测是否还有第二次按下
  4. 如果按键持续按下超过阈值,进入长按确认状态
  5. 最终都会回到空闲状态,准备下一次检测

4 实现

4.1 状态定义

为了在代码中清晰地表达状态机,我们首先定义各个状态:

// 按键状态定义
#define STATE_IDLE     0  // 空闲状态:等待按键按下
#define STATE_PRESS    1  // 按下状态:按键已按下,正在检测持续时间  
#define STATE_RELEASE  2  // 释放状态:按键已释放,等待可能的第二次按下
#define STATE_LONG     3  // 长按状态:已确认为长按操作

4.2 时间参数的设定

可以根据实际情况调整

#define CLICK_MAX      50   // 单击最大时间 50ms
#define DOUBLE_TIMEOUT 300  // 双击超时时间 300ms  
#define LONG_PRESS     960  // 长按判定时间 1000ms

4.3 核心状态机逻辑详解

以状态转换图来理解检测逻辑

graph TD
    A[开始按键检测] --> B{K1按下?}
    
    B -->|是| C{状态=0?}
    B -->|否| D{状态=1?}
    B -->|否| E{状态=3?}
    
    C -->|是| F[状态=1,时间=0]
    C -->|否| G
    
    D -->|是| H[时间++]
    H --> I{时间>960?}
    I -->|是| J[状态=3,返回3,时间=0]
    I -->|否| K[结束]
    
    E -->|是| L[状态=0,计数=0]
    
    F --> K
    J --> K
    L --> K
    
    D -->|否| M
    E -->|否| M
    
    M --> N{状态=2?}
    N -->|是| O[超时计数++]
    O --> P{计数>300?}
    P -->|是| Q{单击计数?}
    P -->|否| K
    
    Q -->|1| R[返回1]
    Q -->|≥2| S[返回2]
    
    R --> T[状态=0,计数=0]
    S --> T
    T --> K
    
    N -->|否| K

这个状态机详细描述了所有可能的状态转换路径。需要注意的是,状态机的执行是周期性的,每次调用检测函数时,都会根据当前状态和输入条件决定是否进行状态转换。

4.4 代码

现在让我们来看完整的代码实现,我会逐部分进行详细解释:

#include <REGX52.H>
#define CLICK_MAX_TIME       20      // 200ms
#define DOUBLE_CLICK_TIMEOUT 40      // 400ms  
#define LONG_PRESS_TIME      100     // 1000ms

// 简单的延时函数
void Delay(unsigned int ms) {
    unsigned int i, j;
    for(i = ms; i > 0; i--)
        for(j = 110; j > 0; j--);
}

unsigned char k1() {
    static unsigned char k1State = 0;
    static unsigned int pressTime = 0;
    static unsigned char clickCount = 0;
    static unsigned int doubleClickTimeout = 0;
    unsigned char statusCode = 0;
    
    if (P3_1 == 0) {  // 按键按下
        if (k1State == 0) {  // 初始状态
            k1State = 1;
            pressTime = 0;
        } else if (k1State == 1) {  // 按下状态
            pressTime++;
            
            if (pressTime > LONG_PRESS_TIME) {
                k1State = 3;
                statusCode = 3;  // 长按
                clickCount = 0;
            }
        } else if (k1State == 2) {  // 等待第二次点击时再次按下
            k1State = 1;
            pressTime = 0;
            // 这是第二次按下,立即判定为双击
            statusCode = 2;
            k1State = 0;
            clickCount = 0;
            doubleClickTimeout = 0;
        }
    } else {  // 按键释放
        if (k1State == 1) {  // 从按下状态释放
            if (pressTime < CLICK_MAX_TIME) {
                clickCount = 1;  // 记录一次点击
                k1State = 2;     // 进入等待第二次点击状态
                doubleClickTimeout = 0;
            } else {
                // 按下时间过长,不算点击
                k1State = 0;
                clickCount = 0;
            }
            pressTime = 0;
        } else if (k1State == 3) {  // 长按释放
            k1State = 0;
            clickCount = 0;
        }
    }
    
    // 在等待第二次点击状态
    if (k1State == 2) {
        doubleClickTimeout++;
        
        if (doubleClickTimeout > DOUBLE_CLICK_TIMEOUT) {
            // 超时,判定为单击
            if (clickCount == 1) {
                statusCode = 1;
            }
            k1State = 0;
            clickCount = 0;
            doubleClickTimeout = 0;
        }
    }
    
    return statusCode;
}

这段代码的逻辑相对复杂,但通过状态机的分解,变得清晰可控。

4.5 应用

目标项目可以被清晰地实现了

#include <REGX52.H>
#define CLICK_MAX_TIME       20      // 200ms
#define DOUBLE_CLICK_TIMEOUT 40      // 400ms  
#define LONG_PRESS_TIME      100     // 1000ms

// 简单的延时函数
void Delay(unsigned int ms) {
    unsigned int i, j;
    for(i = ms; i > 0; i--)
        for(j = 110; j > 0; j--);
}

unsigned char k1() {
    static unsigned char k1State = 0;
    static unsigned int pressTime = 0;
    static unsigned char clickCount = 0;
    static unsigned int doubleClickTimeout = 0;
    unsigned char statusCode = 0;
    
    if (P3_1 == 0) {  // 按键按下
        if (k1State == 0) {  // 初始状态
            k1State = 1;
            pressTime = 0;
        } else if (k1State == 1) {  // 按下状态
            pressTime++;
            
            if (pressTime > LONG_PRESS_TIME) {
                k1State = 3;
                statusCode = 3;  // 长按
                clickCount = 0;
            }
        } else if (k1State == 2) {  // 等待第二次点击时再次按下
            k1State = 1;
            pressTime = 0;
            // 这是第二次按下,立即判定为双击
            statusCode = 2;
            k1State = 0;
            clickCount = 0;
            doubleClickTimeout = 0;
        }
    } else {  // 按键释放
        if (k1State == 1) {  // 从按下状态释放
            if (pressTime < CLICK_MAX_TIME) {
                clickCount = 1;  // 记录一次点击
                k1State = 2;     // 进入等待第二次点击状态
                doubleClickTimeout = 0;
            } else {
                // 按下时间过长,不算点击
                k1State = 0;
                clickCount = 0;
            }
            pressTime = 0;
        } else if (k1State == 3) {  // 长按释放
            k1State = 0;
            clickCount = 0;
        }
    }
    
    // 在等待第二次点击状态
    if (k1State == 2) {
        doubleClickTimeout++;
        
        if (doubleClickTimeout > DOUBLE_CLICK_TIMEOUT) {
            // 超时,判定为单击
            if (clickCount == 1) {
                statusCode = 1;
            }
            k1State = 0;
            clickCount = 0;
            doubleClickTimeout = 0;
        }
    }
    
    return statusCode;
}

void main() {
    P2 = 0xFF;  // 初始化LED全灭
    
    while(1) {
        unsigned char result = k1();
        
        if (result == 1) {
            P2_0 = ~P2_0;  // 单击切换LED1
            Delay(200);     // 防止连按
        } else if (result == 2) {
            P2_1 = ~P2_1;  // 双击切换LED2
            Delay(200);     // 防止连按
        } else if (result == 3) {
            P2_2 = ~P2_2;  // 长按切换LED3
            Delay(200);     // 防止连按
        }
        
        Delay(10);  // 主循环延时
    }
}

这个主循环的结构非常清晰,按键检测和功能执行完全分离,符合模块化设计的原则。如果需要添加新的按键功能,只需要在 switch 语句中添加新的 case 即可。