当前位置: 首页 > news >正文

嵌入式C++实战第23篇:7 状态消抖状态机 —— 本系列的核心

嵌入式C++实战第23篇:7 状态消抖状态机 —— 本系列的核心

仓库已经开源!仍然在持续建设中,喜欢的话点个⭐!相关的链接如下:

clone me!: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP

静态网页体验极大改进,点击这里直接阅览:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/

承接上一篇:非阻塞消抖能工作,但状态变量散落、没有事件概念、没处理启动边界。这一篇用一个 7 状态的有限状态机解决所有问题。这是button.hpppoll_events()方法的完整解读。


为什么需要状态机

上一篇的非阻塞消抖代码,核心逻辑是这样的:

if(current!=last_raw){last_raw=current;last_change_time=HAL_GetTick();}if((HAL_GetTick()-last_change_time)>=debounce_ms){if(last_raw!=last_stable){last_stable=last_raw;// 触发事件}}

能工作,但有问题。这个if-else结构把"消抖等待"、“状态确认”、"事件触发"混在一起,没有清晰的边界。随着需求增加——要区分按下和释放、要处理启动时按钮已按住、要在消抖期间正确处理信号反弹——if-else会越堆越乱。

状态机把这段逻辑拆成了离散的状态和明确的转换规则。每个状态只关心"我在这里,输入是什么,下一个状态去哪里"。不再是"一堆条件判断纠缠在一起",而是"一张清晰的状态转换图"。


7 个状态

我们的状态机有 7 个状态,定义在button.hpp的私有enum class State中:

enumclassState{BootSync,// 启动同步:第一次采样,确定初始状态Idle,// 空闲:按钮松开,等待按下DebouncingPress,// 消抖中(按下方向):等待信号稳定Pressed,// 已确认按下:按钮正在被按住DebouncingRelease,// 消抖中(释放方向):等待信号稳定BootPressed,// 启动锁定:上电时按钮已被按住BootReleaseDebouncing,// 启动释放消抖:启动锁定后的释放消抖};

先别被 7 个状态吓到。核心流程只有 4 个状态:Idle → DebouncingPress → Pressed → DebouncingRelease → Idle,和上一篇的非阻塞逻辑一一对应。额外的 3 个状态(BootSyncBootPressedBootReleaseDebouncing)是专门处理"启动时按钮已被按住"这个边界情况的。

状态转换图

┌──────────────────────────────────────────────────┐ │ │ ▼ │ ┌──────────┐ 按下 ┌──────────────┐ 稳定 ┌─────────┐ 释放 ┌────────────────┐ │ Idle │───────→│DebouncingPress│───────→│ Pressed │───────→│DebouncingRelease│ │ (松开中) │←───────│ (消抖中) │ │(按住中) │←───────│ (消抖中) │ └──────────┘ 反弹 └──────────────┘ └─────────┘ 反弹 └────────────────┘ ↑ │ │ 确认释放 │ 稳定 └───────────────────────────────────────────────────────┘ 启动路径(上电时按钮已按住): ┌──────────┐ ┌──────────────┐ ┌───────────────────────┐ │ BootSync │──按下──→│ BootPressed │──释放──→│ BootReleaseDebouncing │ │ (初始同步)│ │ (启动锁定中) │ │ (启动释放消抖) │ └──────────┘ └──────────────┘ └───────────────────────┘ │ 稳定 ▼ ┌──────────┐ │ Idle │ │ (解锁,无事件)│ └──────────┘

逐状态解读

State::BootSync — 启动同步

caseState::BootSync:raw_pressed_=sample;stable_pressed_=sample;debounce_start_=now_ms;boot_locked_=sample;state_=sample?State::BootPressed:State::Idle;return;

这是状态机的初始状态(state_的默认值是State::BootSync)。它只执行一次——第一次调用poll_events()时。

它做了三件事:

  1. 用第一次采样值初始化raw_pressed_stable_pressed_
  2. 如果按钮已经是按下状态,设置boot_locked_ = true——进入"启动锁定"
  3. 根据采样结果跳转到BootPressedIdle

为什么需要这一步?因为状态机需要知道"初始状态是什么"。如果上电时按钮已经被按住,我们不能触发Pressed事件——用户并没有"按下"按钮,按钮从一开始就是按住的。

State::Idle — 空闲

caseState::Idle:if(sample){raw_pressed_=true;debounce_start_=now_ms;state_=State::DebouncingPress;}return;

空闲状态意味着按钮当前是松开的。只关心一件事:有没有检测到按下信号?如果有,记录时间戳,进入消抖状态。

这个状态什么都不输出,不触发任何事件。它只是在"等"。

State::DebouncingPress — 按下消抖

caseState::DebouncingPress:if(sample!=raw_pressed_){raw_pressed_=sample;debounce_start_=now_ms;}if(!sample){state_=State::Idle;return;}if((now_ms-debounce_start_)<debounce_ms){return;}stable_pressed_=true;state_=State::Pressed;cb(Pressed{});return;

这是消抖的核心。三个判断,对应三种情况:

情况 1:信号反弹了。sample != raw_pressed_说明信号在抖动中跳回来了。更新raw_pressed_并重置计时器——重新开始计时。

情况 2:信号明确回到了低电平。!sample意味着按钮又松开了——这次按下是假信号,回到Idle

情况 3:信号持续为高,且已经稳定了debounce_ms确认按下!更新稳定状态,跳转到Pressed,触发Pressed事件。

这三个判断的顺序很关键。先检查反弹(情况 1),再检查回到低(情况 2),最后检查超时确认(情况 3)。这个顺序确保了:

  • 抖动期间每次反弹都重置计时器
  • 如果信号明确回到了初始电平,立即放弃(不等超时)
  • 只有持续稳定才确认

State::Pressed — 已确认按下

caseState::Pressed:if(sample!=raw_pressed_){raw_pressed_=sample;debounce_start_=now_ms;state_=State::DebouncingRelease;}return;

按钮被确认按下后,只关心一件事:有没有检测到释放信号?如果有,进入释放消抖状态。

注意Pressed状态不会再次触发Pressed事件——事件只在状态转换时触发一次。这保证了无论用户按住多久,Pressed事件只触发一次。

State::DebouncingRelease — 释放消抖

caseState::DebouncingRelease:{if(sample!=raw_pressed_){raw_pressed_=sample;debounce_start_=now_ms;if(sample){state_=State::Pressed;}return;}if(sample){state_=State::Pressed;return;}if((now_ms-debounce_start_)<debounce_ms){return;}stable_pressed_=false;state_=State::Idle;if(boot_locked_){boot_locked_=false;return;}cb(Released{});return;}

DebouncingPress结构对称,但方向相反。三个核心判断:

情况 1:信号反弹。重置计时器。如果反弹回了高电平(sample为 true),回到Pressed状态。

情况 2:信号明确回到了高电平。回到Pressed,这次释放是假信号。

情况 3:超时确认。稳定值为低,确认释放。但这里多了一个检查:boot_locked_

Boot-lock 检查

if(boot_locked_){boot_locked_=false;return;// 不触发 Released 事件}cb(Released{});

如果boot_locked_为 true,说明这次"释放"是启动时按钮被按住的首次释放。在这种情况下,我们不触发Released事件——因为用户从未在系统运行期间"按下"过按钮。只是把boot_locked_清零,让状态机进入正常工作模式。

这是一个很容易被忽略的边界情况。如果你的代码不对boot_locked_做特殊处理,系统上电时如果按钮恰好被按住(比如按钮卡住了,或者用户一直按着),释放按钮时就会触发一个"莫名其妙的 Released 事件"——用户什么都没做,LED 却灭了。

State::BootPressed 和 BootReleaseDebouncing

这两个状态是PressedDebouncingRelease的"静默版本"——逻辑完全一样,但不触发任何事件:

caseState::BootPressed:// 和 Pressed 一样的消抖逻辑,但释放后进入 BootReleaseDebouncing...caseState::BootReleaseDebouncing:// 和 DebouncingRelease 一样的消抖逻辑// 确认释放后:boot_locked_=false;stable_pressed_=false;state_=State::Idle;// 静默进入 Idle,不触发 Releasedreturn;

为什么不让PressedDebouncingRelease同时承担启动锁的功能?因为那样需要在每个状态中都加if (boot_locked_)的判断,逻辑变得更复杂。独立出两个状态,虽然多了一对状态,但每个状态的逻辑更纯粹——要么只处理正常流程,要么只处理启动流程。


完整状态转换表

当前状态输入条件下一状态动作
BootSync高电平Idle初始化,无锁定
BootSync低电平BootPressed初始化,设置 boot_locked
Idle低电平Idle无事发生
Idle高电平DebouncingPress记录时间戳
DebouncingPress反弹DebouncingPress重置计时器
DebouncingPress低电平Idle假信号,放弃
DebouncingPress高电平时间未到DebouncingPress继续等待
DebouncingPress高电平时间到Pressed触发 Pressed 事件
Pressed高电平Pressed无事发生
Pressed低电平DebouncingRelease记录时间戳
DebouncingRelease反弹回到高电平Pressed假信号
DebouncingRelease高电平Pressed假信号
DebouncingRelease低电平时间未到DebouncingRelease继续等待
DebouncingRelease低电平时间到 + boot_lockedIdle清除锁定,无事件
DebouncingRelease低电平时间到 + 正常Idle触发 Released 事件

启动路径的状态转换和上面对称,只是不触发任何事件。


和上一篇非阻塞代码的对比

上一篇的if-else代码大约 15 行,完成了基本的消抖。状态机版本大约 80 行,多了启动处理和事件概念。这看起来像是过度复杂化了?

不是。15 行的代码在以下场景会出问题:

  1. 区分按下和释放:你需要两个方向的消抖——按下要消抖,释放也要消抖。if-else版本只做了一次"稳定检查",没有区分方向。
  2. 消抖期间信号反弹:抖动不是简单的"等 20ms 就稳定了"。信号可能在 5ms 时反弹一次、10ms 时再反弹一次。每次反弹都需要重置计时器。状态机明确处理了这个情况。
  3. 启动边界:上电时按钮状态不确定。状态机的BootSync+BootPressed路径优雅地处理了这个情况。
  4. 扩展性:如果将来要加"长按检测"或"双击检测",在状态机里加几个状态就行。在if-else里加会让代码更难维护。

状态机的本质是用空间换时间——多写几行代码,但每个状态的职责清晰、逻辑简单、不会互相干扰。


我们回头看

这一篇是整个按钮教程的核心。我们详细解读了button.hpppoll_events()方法的 7 状态状态机:

  • 核心路径Idle → DebouncingPress → Pressed → DebouncingRelease → Idle,处理正常的按下和释放
  • 启动路径BootSync → BootPressed → BootReleaseDebouncing → Idle,处理上电时按钮已按住的边界情况
  • 消抖机制:每次信号反弹都重置计时器,只有持续稳定才确认状态变化
  • boot-lock:启动锁确保上电时按钮被按住不会触发虚假事件

理解了这个状态机,button.hpp的其余部分(模板参数、Concepts 回调、std::variant事件)都是在它上面的封装层。接下来几篇就是逐步把这些 C++ 特性讲清楚。


相关阅读

  1. 第24篇:非阻塞消抖 —— 不让 CPU 停下来等 - 相似度 100%
  2. RVO 与 NRVO:编译器的返回值优化 - 相似度 58%
  3. 完美转发与移动语义实战 - 相似度 58%
http://www.jsqmd.com/news/863211/

相关文章:

  • 【无标题】dfgndm,ng,dg,
  • 科技中介机构如何提升服务效率与转化率?
  • 《无人机维修培训哪家好:排名前五专业深度测评》 - 服务品牌热点
  • 智领安全・云启新境|锐捷安全云办公 4.0 焕新升级,重塑企业数字办公基石
  • 谁能推荐几个能替代进口品牌的光学筛选机直驱电机供应商?
  • Unity Lua调试实战:Rider+EmmyLua断点调试全链路配置指南
  • AI 与大模型新闻日报20260521
  • FreeMove:Windows系统磁盘空间优化的智能解决方案
  • ToastFish:Windows通知栏背单词神器,碎片化时间高效记忆方案
  • 连续四年荣登百强榜,人力窝以科技驱动人力资源服务新范式
  • Cobalt Strike流量识别与协议逆向实战指南
  • Unity Lua调试5大痛点实战解决方案:Rider+EmmyLua全链路断点调试
  • 获800万美元种子轮融资,「shapes」用AI打破社交困局,重新定义社交入场方式
  • 3043. 最长公共前缀的长度(Leetcode 每日一题)
  • 【Midjourney拍立得风格终极指南】:3步零代码复刻宝丽来胶片质感,92%用户首次尝试即出片
  • C++头文件组织策略
  • 答题pk小程序软件程序代码怎么选
  • 手机上还有免费编辑pdf文本的软件?!
  • 【AI教育政策观察】梳理近半年国内高校AI检测政策的落地趋势与实操细节
  • 交互式振动传感器工作原理
  • 税务平台国密登录四段式加密链路实战解析
  • 微信支付商户证书序列号错误排查全指南
  • 纯思路干货|SpringBoot大学生管理系统开发全流程(无代码,课设毕设直接用)
  • ElevenLabs福建话语音生成技术深度拆解(仅限内测通道验证的4项方言适配关键参数)
  • 游戏引擎选型实战指南:聚焦团队匹配与项目生命周期
  • 3分钟让Windows任务栏变透明:TranslucentTB完全指南
  • IOC 容器 H.Iocable
  • QMCDecode终极指南:3步快速解锁QQ音乐加密格式,实现音频自由播放
  • QQ音乐加密音频一键解密:3步让Mac用户重获音乐自由
  • Godot纸牌游戏框架:状态语义化与规则声明式设计