嵌入式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.hpp中poll_events()方法的完整解读。为什么需要状态机上一篇的非阻塞消抖代码核心逻辑是这样的if(current!last_raw){last_rawcurrent;last_change_timeHAL_GetTick();}if((HAL_GetTick()-last_change_time)debounce_ms){if(last_raw!last_stable){last_stablelast_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 个状态BootSync、BootPressed、BootReleaseDebouncing是专门处理启动时按钮已被按住这个边界情况的。状态转换图┌──────────────────────────────────────────────────┐ │ │ ▼ │ ┌──────────┐ 按下 ┌──────────────┐ 稳定 ┌─────────┐ 释放 ┌────────────────┐ │ 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()时。它做了三件事用第一次采样值初始化raw_pressed_和stable_pressed_如果按钮已经是按下状态设置boot_locked_ true——进入启动锁定根据采样结果跳转到BootPressed或Idle为什么需要这一步因为状态机需要知道初始状态是什么。如果上电时按钮已经被按住我们不能触发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这两个状态是Pressed和DebouncingRelease的静默版本——逻辑完全一样但不触发任何事件caseState::BootPressed:// 和 Pressed 一样的消抖逻辑但释放后进入 BootReleaseDebouncing...caseState::BootReleaseDebouncing:// 和 DebouncingRelease 一样的消抖逻辑// 确认释放后boot_locked_false;stable_pressed_false;state_State::Idle;// 静默进入 Idle不触发 Releasedreturn;为什么不让Pressed和DebouncingRelease同时承担启动锁的功能因为那样需要在每个状态中都加if (boot_locked_)的判断逻辑变得更复杂。独立出两个状态虽然多了一对状态但每个状态的逻辑更纯粹——要么只处理正常流程要么只处理启动流程。完整状态转换表当前状态输入条件下一状态动作BootSync高电平—Idle初始化无锁定BootSync低电平—BootPressed初始化设置 boot_lockedIdle低电平—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 行的代码在以下场景会出问题区分按下和释放你需要两个方向的消抖——按下要消抖释放也要消抖。if-else版本只做了一次稳定检查没有区分方向。消抖期间信号反弹抖动不是简单的等 20ms 就稳定了。信号可能在 5ms 时反弹一次、10ms 时再反弹一次。每次反弹都需要重置计时器。状态机明确处理了这个情况。启动边界上电时按钮状态不确定。状态机的BootSyncBootPressed路径优雅地处理了这个情况。扩展性如果将来要加长按检测或双击检测在状态机里加几个状态就行。在if-else里加会让代码更难维护。状态机的本质是用空间换时间——多写几行代码但每个状态的职责清晰、逻辑简单、不会互相干扰。我们回头看这一篇是整个按钮教程的核心。我们详细解读了button.hpp中poll_events()方法的 7 状态状态机核心路径Idle → DebouncingPress → Pressed → DebouncingRelease → Idle处理正常的按下和释放启动路径BootSync → BootPressed → BootReleaseDebouncing → Idle处理上电时按钮已按住的边界情况消抖机制每次信号反弹都重置计时器只有持续稳定才确认状态变化boot-lock启动锁确保上电时按钮被按住不会触发虚假事件理解了这个状态机button.hpp的其余部分模板参数、Concepts 回调、std::variant事件都是在它上面的封装层。接下来几篇就是逐步把这些 C 特性讲清楚。相关阅读第24篇非阻塞消抖 —— 不让 CPU 停下来等 - 相似度 100%RVO 与 NRVO编译器的返回值优化 - 相似度 58%完美转发与移动语义实战 - 相似度 58%