电子产业一站式赋能平台

PCB联盟网

搜索
查看: 33|回复: 0
收起左侧

怎么用面向对象和状态机架构,设计一个通用的按键检测功能?

[复制链接]

300

主题

300

帖子

2442

积分

三级会员

Rank: 3Rank: 3

积分
2442
发表于 昨天 07:50 | 显示全部楼层 |阅读模式
关注公众号,回复“入门资料”获取单片机入门到高级开挂教程
开发板带你入门,我们带你飞

文 | 无际(微信:2777492857)
全文约4589字,阅读大约需要 15 分钟
说起按键检测,在座的各位,哪个没被它折磨过?
           
我刚入门时,为了实现一个简单的按键功能,硬生生写了几十行代码,各种 if...else 嵌套,逻辑绕得我自己都头晕。
           
更可气的是,辛辛苦苦写完,一测试,发现按键跟“抽风”似的,时不时失灵,有时候按一下,它给我响应好几次,好想把板子砸了!
           
后来我才知道,这叫按键抖动,是硬件的“锅”,得软件来“背”。为了解决这个问题,我尝试了各种方法,什么延时消抖、多次采样,但效果都不尽如人意。要么系统响应变慢,要么代码臃肿不堪,维护起来简直是噩梦。
           
如果你也曾被按键检测折磨得死去活来,那么恭喜你,这篇文章将带你实现一个稳定性高,扩展性强的按键框架。
           
当然,更重要的是提供一种编程思维,起到抛砖引玉的作用,掌握这种思维,才能万变不离其宗,适应千变万化的需求。
           
1. C语言里的面向对象
这个方法的核心是用面向对象和状态机。
           
先聊聊面向对象(OOP)的核心是啥?是把数据和操作数据的代码打包在一起,形成一个“对象”,然后让这些对象自己管自己。
               
C语言虽然没有class关键字,但我们可以用结构体(struct)和函数指针来山寨一个面向对象的风格。
           
我们用一个结构体来定义这个按键对象:
  • typedef struct {    int pin;           // 按键连的引脚    int state;         // 当前状态    void (*press)();   // 按下时调用的函数    void (*release)(); // 松开时调用的函数} Key;
    看到没?pin和state是按键的“个人信息”,而press和release是函数指针,指向按键的行为。
               
    啥是函数指针?简单说,就是一个“遥控器”,你告诉它按下时要干啥,它就去干啥。
               
    比如,按键1按下时点亮LED,按键2按下时播放音乐,完全可以各干各的,不用挤在一个大函数里乱七八糟地if-else。
               
    这样设计的好处是啥?每个按键都是一个独立的对象,你可以随便创建多少个,互不干扰。以后加个新按键,直接复制粘贴一个Key实例,改改引脚和回调函数就行,代码复用性拉满。
               
    2. 状态机
    光有面向对象还不够,按键的状态变化得有个清晰的逻辑。这时候,状态机就派上用场了。   
               
    状态机是个啥?它就像一个流程图,告诉你系统当前在哪个状态,下一步能去哪儿,全程有条不紊。
               
    对于按键检测,我们可以定义几个常见状态:
    ?Idle(空闲):按键没被按下,啥也没发生。
    ?Pressed(按下):按键刚被按下,可能需要去抖动。
    ?Hold(持续按下):按键按着不放,可能是长按。
    ?Released(释放):按键被松开,结束一次操作。
    这些状态之间怎么跳来跳去呢?靠输入信号决定。
               
    比如,Idle状态下检测到引脚变低(按下),就跳到Pressed;Pressed状态下如果一直低电平,就进入Hold;Hold状态下引脚变高(松开),就跳到Released,最后回到Idle。
               
    听起来是不是有点像玩“超级马里奥”,每踩一个砖头就换个场景?
               
    用状态机的好处是啥?逻辑清晰啊!你不用写一堆嵌套的if-else去判断按了多久?抖不抖之类的问题,状态机自带"导航",每一步该干啥一目了然。
               
    3. 组合拳:面向对象+状态机设计按键检测
    好了,现在我们把面向对象和状态机捏到一起,设计一个通用的按键检测模块。
               
    思路是这样的:用结构体定义按键对象,用状态机控制它的行为。
                   
    第一步:定义状态和结构体
    先用枚举定义状态:
  • typedef enum {    IDLE,       // 空闲    PRESSED,    // 按下    HOLD,       // 持续按下    RELEASED    // 释放} KeyState;   
    然后,稍微升级一下之前的Key结构体,把状态加上:
  • typedef struct {    int pin;            // 引脚    KeyState state;     // 当前状态    void (*onPress)();  // 按下回调    void (*onRelease)(); // 释放回调} Key;
    第二步:写状态更新函数
               
    接下来,核心是个updateKeyState函数,负责根据引脚信号更新状态。
               
    我们假设有个readPin函数能读引脚状态(高电平表示没按,低电平表示按下),逻辑如下:   
  • void updateKeyState(Key *key) {    int currentInput = readPin(key->pin); // 读取引脚状态    switch (key->state)     {        case IDLE:            if (currentInput == 0)             { // 低电平,按下                key->state = PRESSED;                if (key->onPress) key->onPress();            }            break;        case PRESSED:            if (currentInput == 0)             { // 还是按着                key->state = HOLD;            } else             { // 松开了                key->state = RELEASED;                if (key->onRelease) key->onRelease();            }            break;        case HOLD:            if (currentInput == 1)             { // 松开了                key->state = RELEASED;                if (key->onRelease) key->onRelease();            }            break;        case RELEASED:            if (currentInput == 1)             { // 确认松开                key->state = IDLE;            }            break;    }}
    这个函数干了啥?它每次被调用时,检查当前引脚状态,然后根据状态机规则跳到下一个状态,顺便触发对应的回调函数。
               
    比如,从IDLE到PRESSED时调用onPress,从HOLD到RELEASED时调用onRelease。
               
    简单吧?但已经能处理基本的按下和释放了。
               
               
    第三步:让代码跑起来
    咱们写个例子跑跑看。
               
    假设有两个按键:
    一个连在P1引脚,按下时打印“开灯”,松开时打印“关灯”。
    另一个连在P2引脚,按下时打印“启动”,松开时打印“停止”。
               
    先定义回调函数:
  • void key1Press() {    printf("开灯
    ");}void key1Release() {    printf("关灯
    ");}void key2Press() {    printf("启动
    ");}void key2Release() {    printf("停止
    ");}           
    然后初始化两个按键对象:
  • Key key1 = {P1, IDLE, key1Press, key1Release};Key key2 = {P2, IDLE, key2Press, key2Release};           
    主循环里定期更新状态:
  • void main() {    while (1)     {        updateKeyState(&key1);        updateKeyState(&key2);        delay(10); // 每10ms检查一次    }}
    跑起来后,按下P1会打印“开灯”,松开打印“关灯”,P2同理。
               
    每个按键独门独户,互不干扰,想加第三个按键?再定义一个Key key3就行,so easy!
               
               
    3.1 加点料:支持长按功能
    这时候你可能会问:“这也太基础了吧,长按咋办?”别急,面向对象的好处就在于扩展性强。
               
    咱们给Key结构体加个计时器,专门记录按下的时间:
  • typedef struct {    int pin;    KeyState state;    int holdTime;       // 按下持续时间    void (*onPress)();    void (*onHold)();   // 长按回调    void (*onRelease)();} Key;
    然后改一下updateKeyState,增加长按逻辑:   
  • void updateKeyState(Key *key) {    int currentInput = readPin(key->pin);    switch (key->state)     {        case IDLE:            if (currentInput == 0)             {                key->state = PRESSED;                key->holdTime = 0;                if (key->onPress) key->onPress();            }            break;        case PRESSED:            if (currentInput == 0)             {                key->holdTime += 10; // 每次循环加10ms                if (key->holdTime >= 1000)                 { // 按了1秒算长按                    key->state = HOLD;                    if (key->onHold) key->onHold();                }            } else             {                key->state = RELEASED;                if (key->onRelease) key->onRelease();            }            break;        case HOLD:            if (currentInput == 1)             {                key->state = RELEASED;                if (key->onRelease) key->onRelease();            }            break;        case RELEASED:            if (currentInput == 1)             {                key->state = IDLE;            }            break;    }}           
    现在,按下不到1秒是短按,超过1秒触发长按回调。想改成2秒?把1000改成2000就行。这种设计改起来是不是跟玩似的?
               
               
    3.2 去抖动怎么办?
    说到按键检测,绕不过去抖动问题。机械按键按下或松开时,信号会抖个几毫秒到几十毫秒。
               
    咋解决?其实状态机已经帮我们铺好路了。
               
    去抖动的核心思想是:在检测到按键状态变化后,不立即做出反应,而是等待一段时间(通常为10ms到50ms,称为“去抖动时间”),然后再次检测按键状态,以确认变化是真实的。这样可以过滤掉抖动带来的短暂信号波动。
               
    不过为了保持通用性,咱们可以把去抖动时间做成参数,加到Key结构体里,留给用户自己调。   
               
    下面继续完善下代码:
    3.2.1 状态定义:
    为了支持去抖动,我们扩展状态机的状态,加入DEBOUNCE_PRESSED和DEBOUNCE_RELEASED两个去抖动状态:
  • typedef enum {    IDLE,               // 空闲    DEBOUNCE_PRESSED,   // 按下去抖动    PRESSED,            // 按下    HOLD,               // 持续按下    DEBOUNCE_RELEASED,  // 释放去抖动    RELEASED            // 释放} KeyState;           
    3.2.2 Key结构体
    在Key结构体中加入去抖动计时器debounceTimer,用于跟踪等待时间:
  • typedef struct {    int pin;            // 引脚    KeyState state;     // 当前状态    int debounceTimer;  // 去抖动计时器(单位:ms)    int holdTime;       // 按下持续时间(单位:ms)    void (*onPress)();  // 按下回调函数    void (*onHold)();   // 长按回调函数    void (*onRelease)(); // 释放回调函数} Key;           
    3.2.3 更新状态函数
    以下是实现去抖动的updateKeyState函数,假设按键低电平表示按下,高电平表示松开:
  • #define DEBOUNCE_TIME 20  // 去抖动时间,单位msvoid updateKeyState(Key *key) {    int currentInput = readPin(key->pin); // 读取当前引脚状态(0为按下,1为松开)    switch (key->state)     {        case IDLE:            if (currentInput == 0)             { // 检测到按下                key->state = DEBOUNCE_PRESSED;                key->debounceTimer = 0; // 重置去抖动计时器            }            break;        case DEBOUNCE_PRESSED:            key->debounceTimer += 10; // 假设每次循环10ms            if (key->debounceTimer >= DEBOUNCE_TIME)             { // 去抖动时间到                if (currentInput == 0)                 { // 确认按下                    key->state = PRESSED;                    key->holdTime = 0; // 重置长按计时器                    if (key->onPress) key->onPress(); // 触发按下回调                } else                 { // 是抖动                    key->state = IDLE;                }            }            break;        case PRESSED:            if (currentInput == 0)             { // 持续按下                key->holdTime += 10; // 累加按下时间                if (key->holdTime >= 1000)                 { // 长按1秒                    key->state = HOLD;                    if (key->onHold) key->onHold(); // 触发长按回调                }            } else             { // 检测到松开                key->state = DEBOUNCE_RELEASED;                key->debounceTimer = 0; // 重置去抖动计时器            }            break;        case HOLD:            if (currentInput == 1)             { // 检测到松开                key->state = DEBOUNCE_RELEASED;                key->debounceTimer = 0;            }            break;        case DEBOUNCE_RELEASED:            key->debounceTimer += 10;            if (key->debounceTimer >= DEBOUNCE_TIME)             { // 去抖动时间到                if (currentInput == 1)                 { // 确认松开                    key->state = RELEASED;                    if (key->onRelease) key->onRelease(); // 触发释放回调                } else { // 是抖动                    key->state = PRESSED; // 返回PRESSED状态                }            }            break;        case RELEASED:            if (currentInput == 1)             { // 确认松开                key->state = IDLE; // 返回空闲状态            }            break;    }}

    3.2.4 代码工作流程
    3.2.4.1 IDLE(空闲状态)
    ?如果检测到引脚变低(按下),进入DEBOUNCE_PRESSED状态,重置去抖动计时器。
               
    3.2.4.2 DEBOUNCE_PRESSED(按下去抖动状态)
    ?累加计时器,等待DEBOUNCE_TIME(20ms)。
    ?时间到后再次检测引脚:
    ?若仍为低电平,确认按下,进入PRESSED状态并触发onPress回调。
    ?若变为高电平,认为是抖动,返回IDLE。
               
    3.2.4.3 PRESSED(按下状态)
    ?若引脚持续低电平,累加holdTime;若达到1秒,进入HOLD状态并触发onHold回调。
    ?若检测到引脚变高(松开),进入DEBOUNCE_RELEASED状态。
               
    3.2.4.4 HOLD(持续按下状态)
    ?若检测到引脚变高,进入DEBOUNCE_RELEASED状态。
               
    3.2.4.5 DEBOUNCE_RELEASED(释放去抖动状态)
    ?累加计时器,等待DEBOUNCE_TIME。
    ?时间到后再次检测引脚:
    ?若仍为高电平,确认松开,进入RELEASED状态并触发onRelease回调。
    ?若变为低电平,认为是抖动,返回PRESSED。
               
    3.2.4.6 RELEASED(释放状态)
    ?若引脚保持高电平,返回IDLE状态。
               
    代码假设updateKeyState每10ms调用一次,debounceTimer每次加10ms。在实际应用中,建议使用单片机的硬件定时器以获得更精确的时间控制。
               
    4. 这架构好在哪里?
               
    用面向对象和状态机搞按键检测,好在哪儿?
               
    第一,模块化,每个按键自成体系,想加功能只改自己的结构体和回调,不用动全局逻辑。
    第二,可读性高,状态机把流程画得明明白白,比if-else嵌套强多了。
    第三,扩展性好,长按、双击、组合按,只要加状态和变量就能搞定。
               
    更重要的是,这种思路不只适用于按键检测。LED闪烁、传感器采集、通信协议解析,凡是有状态变化的模块,都能套用这个套路。
               
    学会了这一招,你写单片机代码的水平绝对能上一个台阶,所以在文章开头,我说这个编程思维能起到抛砖引玉的作用。
               
    4.最后想和大家说的话
    别小看按键检测功能,看似简单,其实是个磨炼设计能力的好机会,对编程思维和代码水平是一个考验。
               
    用面向对象和状态机,能让你的代码从“能跑”变成“跑得好”。
               
    当然,实际项目里,你可能还得考虑功耗、中断、定时器精度之类的问题,但核心思路不变:把复杂问题拆成小块儿,交给对象和状态机去管。
               
    所以,下次写代码时,别再一股脑儿堆if-else了,试试这套“组合拳”,保证你会爱上这种清晰又灵活的感觉。毕竟,好的设计不仅能解决问题,还能让你少掉点头发,对吧?
               

    x1p0bdaauwp64017826408.png

    x1p0bdaauwp64017826408.png

               end

    fxi0yxpyf0x64017826508.jpg

    fxi0yxpyf0x64017826508.jpg
  • 回复

    使用道具 举报

    发表回复

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则


    联系客服 关注微信 下载APP 返回顶部 返回列表