怎么用面向对象和状态机架构,设计一个通用的按键检测功能?
说起按键检测,在座的各位,哪个没被它折磨过?
我刚入门时,为了实现一个简单的按键功能,硬生生写了几十行代码,各种 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("开灯\n");
}void key1Release()
{printf("关灯\n");
}void key2Press()
{printf("启动\n");
}void key2Release()
{printf("停止\n");
}
然后初始化两个按键对象:
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; // 每次循环加10msif (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; // 假设每次循环10msif (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了,试试这套“组合拳”,保证你会爱上这种清晰又灵活的感觉。毕竟,好的设计不仅能解决问题,还能让你少掉点头发,对吧?

最近很多粉丝问我单片机怎么学,我根据自己从业十年经验,累积耗时一个月,精心整理一份「单
片机最佳学习路径+单片机入门到高级教程+工具包」,全部无偿分享给铁粉!!!
除此以外,再含泪分享我压箱底的22个热门开源项目,包含源码+原理图+PCB+说明文档,让你迅速进阶成高手!

教程资料包和详细的学习路径可以看我下面这篇文章的开头。
《单片机入门到高级开挂学习路径(附教程+工具)》
《单片机入门到高级开挂学习路径(附教程+工具)》
《单片机入门到高级开挂学习路径(附教程+工具)》
相关文章:
怎么用面向对象和状态机架构,设计一个通用的按键检测功能?
说起按键检测,在座的各位,哪个没被它折磨过? 我刚入门时,为了实现一个简单的按键功能,硬生生写了几十行代码,各种 if...else 嵌套,逻辑绕得我自己都头晕。 更可气的是,辛辛苦苦写完…...
Java基础系列-LinkedList源码解析
文章目录 简介LinkedList 插入和删除元素的时间复杂度?LinkedList 为什么不能实现 RandomAccess 接口? LinkedList 源码分析Node 定义初始化获取元素插入元素删除元素遍历链表 简介 LinkedList 是一个基于双向链表实现的集合类,经常被拿来和…...
day47—双指针-平方数之和(LeetCode-633)
题目描述 给定一个非负整数 c ,你要判断是否存在两个整数 a 和 b,使得 a^2 b^2 c 。 示例 1: 输入:c 5 输出:true 解释:1 * 1 2 * 2 5示例 2: 输入:c 3 输出:f…...
qwen 14B模型配置文件,层名称weight_map. 28GB
qwen 14B模型配置文件,层名称weight_map. 28GB 目录 qwen 14B模型配置文件,层名称weight_map. 28GBmetadata(元数据)weight_map(权重映射)lm_head.weightmodel.layersmlp.{proj_type}.weightpost_attention_layernormself_attn.{proj_type}.{bias_or_weight}model.norm.w…...
LVDS系列8:Xilinx 7系可编程输入延迟(一)
在解析LVDS信号时,十分重要的一环就是LVDS输入信号线在经过PCB输入到FPGA中后,本来该严格对齐的信号线会出现时延,所以需要在FPGA内部对其进行延时对齐后再进行解析。 Xilinx 7系器件中用于输入信号延时的组件为IDELAYE2可编程原语࿰…...
【Oracle专栏】函数中SQL拼接参数 报错处理
Oracle相关文档,希望互相学习,共同进步 风123456789~-CSDN博客 1.背景 最近同事反馈了一个很奇怪的问题,即有一个函数,入参是当前年月,主要作用是通过SQL语句将不合规的数据插入到指定表中,插入数据时带上入参的年月参数。当前问题:单独测试SQL没有问题可以执行成功,…...
自然语言处理(NLP)领域大图
以下是一份自然语言处理(NLP)与大模型领域的领域大图,涵盖技术框架、发展脉络、交叉融合点和应用场景的完整解析: 1. 核心技术体系 基础分析层级 词法分析:分词、词性标注、命名实体识别句法分析:依存句法…...
【Linux我做主】GDB调试工具完全指南
Linux下GDB调试工具完全指南:25个核心命令详解与实战示例 github地址 有梦想的电信狗 前言 GDB(GNU Debugger)是Linux开发中不可或缺的调试工具,尤其在定位代码逻辑错误和内存问题时表现卓越。本文基于实际开发经验࿰…...
Pycharm 如何删除某个 Python Interpreter
在PyCharm中,点击右下角的“Interpreter Settings”按钮,或者通过菜单栏选择“File” > “Settings”(macOS用户选择“PyCharm” > “Preferences”)。在设置窗口中,导航到“Project: [Your Project Name]” >…...
在 Debian 12 中恢复被删除的 smb.conf 配置文件
https://forum.ubuntu.com.cn/viewtopic.php?t494763 本文结合ai输出,内容中可能有些错误,但确实解决了我的问题,我采取保留完整输出的方式摘录。 在 Debian 12 中恢复被删除的 smb.conf 配置文件,需结合 dpkg 和 ucf(…...
Day3:个人中心页面布局前端项目uniapp壁纸实战
接下来我们来弄一下个人中心页面布局user.vue <template><view class"userLayout"><view class"userInfo"><view class"avatar"><image src"../../static/Kx.jpg" mode"aspectFill"></im…...
访问”和“初始化本质区别以及C++静态成员变量定义位置详解
💡 1.访问”和“初始化本质区别: ✅ 访问 protectedNum:Derived 作为 Base 的子类,是可以在自己的函数中访问 protectedNum 的。❌ 初始化 protectedNum:只能通过 Base 的构造函数来初始化,因为它是 Base …...
正则表达式反向引用的综合应用魔法:从重复文本到简洁表达的蜕变
“我....我要....学学学学....编程 java!” —— 这类“重复唠叨”的文本是否让你在清洗数据时头疼不已? 本文将带你一步步掌握正则表达式中的反向引用技术,并结合 Java 实现一个中文文本去重与清洗的实用工具。 结合经典的结巴实例。如何高效地将这样的…...
C实现md5功能
md5在线验证: 在线MD5计算_ip33.com 代码如下: #include "md5.h" #include <string.h> #include "stdio.h"/** 32-bit integer manipulation macros (little endian)*/ #ifndef GET_ULONG_LE #define GET_ULONG_LE(n,b,i) …...
FFmpeg+Nginx+VLC打造M3U8直播
一、视频直播的技术原理和架构方案 直播模型一般包括三个模块:主播方、服务器端和播放端 主播放创造视频,加美颜、水印、特效、采集后推送给直播服务器 播放端: 直播服务器端:收集主播端的视频推流,将其放大后推送给…...
在 Debian 10.x 安装和配置 Samba
1. 更新系统 sudo apt update sudo apt upgrade -y2. 安装 Samba sudo apt install samba -y3. 配置 Samba 备份默认配置文件 sudo cp /etc/samba/smb.conf /etc/samba/smb.conf.bak编辑配置文件 sudo nano /etc/samba/smb.conf示例配置(共享目录) …...
基础(测试用例:介绍,测试用例格式,案例)
目录 测试用例介绍 测试用例编写格式 案例 测试用例介绍 用例:用户使用软件的案例场景 测试用例:是为测试项目而设计的测试执行文档 测试用例的作用: 防止漏测是实施测试的标准可以作为测试工作量的评估 测试用例编写格式 用例编号 用例…...
C++学习:六个月从基础到就业——内存管理:RAII原则
C学习:六个月从基础到就业——内存管理:RAII原则 本文是我C学习之旅系列的第十九篇技术文章,也是第二阶段"C进阶特性"的第四篇,主要介绍C中的RAII原则及其在资源管理中的应用。查看完整系列目录了解更多内容。 引言 在…...
Windows串口通信
Windows串口通信相比较Android串口通信,在开发上面相对方便一些。原理都是一样,需要仔细阅读厂商设备的串口通信协议。结合串口调试助手进行测试,测试通过后,编写代码实现。 比如近期就接触到了一款天平,其最大测量值为100g,测量精度0.001g。 拿到手之后我就先阅读串口通…...
bert项目解析
数据预处理 读取csv数据集 def read_file(file_path):data []label []with open(file_path, "r", encoding"utf-8") as file:reader csv.reader(file)next(reader) # 跳过标题行# row每一行用英文逗号分割成列表[标签,文本] 所以标签和文本用英文逗…...
Linux `init` 相关命令的完整使用指南
Linux init 相关命令的完整使用指南—目录 一、init 系统简介二、运行级别(Runlevel)详解三、常用 init 命令及使用方法1. 切换运行级别2. 查看当前运行级别3. 服务管理4. 紧急模式(Rescue Mode) 四、不同 Init 系统的兼容性1. Sy…...
【开源项目】Excel手撕AI算法深入理解(三):时序(RNN、mamba、Long Short Term Memory (LSTM)、xLSTM)
项目源码地址:https://github.com/ImagineAILab/ai-by-hand-excel.git 一、RNN 1. RNN 的核心思想 RNN 的设计初衷是处理序列数据(如时间序列、文本、语音),其核心特点是: 隐藏状态(Hidden Stateÿ…...
嵌入式音视频开发指南:从MPP框架到QT实战全解析
嵌入式音视频开发指南:从MPP框架到QT实战全解析 一、音视频技术全景概述 1.1 技术演进里程碑 2003-2010年:标清时代(H.264/AVC + RTMP)2011-2018年:高清时代(H.265/HEVC + WebRTC)2019-至今:智能时代(AV1 + AI编解码 + 低延迟传输)1.2 现代音视频技术栈 #mermaid-s…...
构建专业金融图表系统的高效路径——QtitanChart在金融行业的应用价值
QtitanChart是一个C 库,它代表一组控件,这些控件使您可以快速轻松地为应用程序提供漂亮而丰富的图表。QtitanChart在Qt.C 上实现,并且支持所有主要的桌面操作系统 - Windows、Linux和Mac OSX。要将QtitanChart添加到您的程序中,只…...
如何通过window端来ssh连接本地虚拟机的ubuntu
首先在 Ubuntu 虚拟机上安装和配置 SSH 服务: # 安装 SSH 服务器 sudo apt update sudo apt install openssh-server# 检查 SSH 服务状态 sudo systemctl status ssh# 如果没有启动,则启动 SSH 服务 sudo systemctl start ssh# 设置开机自启动 sudo sys…...
问题:el-tree点击某节点的复选框由半选状态更改为全选状态以后,点击该节点展开,懒加载出来子节点数据以后,该节点又变为半选状态
具体问题场景: 用户点击父节点复选框将其从半选变为全选(此时子节点尚未加载)。 点击节点展开触发懒加载,加载子节点。 子节点加载后,组件重新计算父节点状态,发现并非所有子节点被选中,因此父节…...
【Rust 精进之路之第8篇-工具赋能】深入 Cargo:依赖管理、构建配置与工作空间 (Workspace)
系列: Rust 精进之路:构建可靠、高效软件的底层逻辑 作者: 码觉客 发布日期: 2025-04-20 引言:超越构建,Cargo 是 Rust 生态的引擎 在我们的 Rust 学习之旅初期(第二篇),我们已经与 Cargo 有过初步的接触。我们学会了使用 cargo new 创建项目骨架,用 cargo build 编…...
多模态大语言模型arxiv论文略读(二十六)
Holistic Autonomous Driving Understanding by Bird’s-Eye-View Injected Multi-Modal Large Models ➡️ 论文标题:Holistic Autonomous Driving Understanding by Bird’s-Eye-View Injected Multi-Modal Large Models ➡️ 论文作者:Xinpeng Ding,…...
Java虚拟机(JVM)平台无关?相关?
计算机的概念模型 计算机实际上就是实现了一个图灵机模型。即,输入参数,根据程序计算,输出结果。图灵机模型如图。 Tape是输入数据,Program是针对这些数据进行计算的程序,中间横着的方块表示的是机器的状态。 目前使…...
Ubuntu 安装 Docker 教程(官方推荐方式)
✅ 步骤 1:卸载旧版本(如果有) for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done---### ✅ 步骤 2:更新 APT 索引并安装依赖项bash sudo a…...
