嵌入式C开发第22篇非阻塞消抖 —— 不让 CPU 停下来等仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下clone me!: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP静态网页体验极大改进点击这里直接阅览https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/非常抱歉超级久没有更新这个系列了笔者最近忙于工作抽不开身加上其他项目要维护终于想起来这个太久没更新了现在这里赶紧补上。PS上一篇在5月2号发布的是HAL GPIO读取芯片。C的部分在这些内容讲解完成之后展开阻塞消抖的代价上一篇最后我们试了一个最简单的消抖方案// 阻塞式消抖if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET){HAL_Delay(20);// 阻塞 20msif(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET){// 确认按下HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_13);// 等待释放防止按住不放时重复触发while(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET){}}}这个方案确实能消除大部分抖动问题。但它的代价是HAL_Delay(20)把 CPU 冻结了 20 毫秒。20ms 听起来不长。如果你只是控制一盏 LED等就等了无所谓。但真实项目中你的主循环可能要做很多事情——读取传感器数据、更新显示、处理通信协议。如果你在每次检测按钮时都阻塞 20ms其他任务的实时性就被破坏了。更糟的是最后的while循环——如果用户一直按住按钮不放CPU 就一直卡在这个循环里其他任务完全停止。这已经不是延迟了这是挂死。我们需要一种不阻塞 CPU 的消抖方式。HAL_GetTick免费的时钟HAL_GetTick()返回自系统启动以来经过的毫秒数。它是一个 32 位无符号整数从 0 开始每毫秒加 1大约 49.7 天后溢出归零对于嵌入式项目来说基本可以忽略。uint32_tnowHAL_GetTick();// 例如返回 12345表示系统已运行 12.345 秒HAL_GetTick()的底层实现在hal_mock.c中——SysTick_Handler()中断每 1ms 触发一次调用HAL_IncTick()递增一个全局计数器。这个计数器就是我们获取时间的来源。用HAL_GetTick()做消抖的核心思想是记录状态变化发生的时间下次循环时检查是否已经过了足够长的时间而不是停下来等。非阻塞消抖算法基本思路1. 每次循环采样当前引脚状态 2. 如果和上次记录的稳定状态不同 a. 记录变化发生的时间 (debounce_start) b. 标记正在消抖 3. 如果正在消抖且已经过了 debounce_ms a. 再次采样确认 b. 如果确认状态确实变了更新稳定状态 c. 触发事件 4. 如果在消抖期间状态又变了回来 a. 取消消抖这是假信号用 ASCII 状态图表示┌──────────┐ 状态变化 ┌──────────────┐ 确认变化 ┌──────────┐ │ 稳定 │──────────→│ 消抖中 │──────────→│ 新稳定 │ │ (高/低) │ │ (等待时间到) │ │ (高/低) │ └──────────┘←──────────└──────────────┘ └──────────┘ 状态回弹 (假信号)C 语言实现#includestm32f1xx_hal.hintmain(void){HAL_Init();/* 系统时钟配置省略 */__HAL_RCC_GPIOA_CLK_ENABLE();__HAL_RCC_GPIOC_CLK_ENABLE();/* PA0 上拉输入 */GPIO_InitTypeDef btn_init{0};btn_init.PinGPIO_PIN_0;btn_init.ModeGPIO_MODE_INPUT;btn_init.PullGPIO_PULLUP;HAL_GPIO_Init(GPIOA,btn_init);/* PC13 推挽输出 */GPIO_InitTypeDef led_init{0};led_init.PinGPIO_PIN_13;led_init.ModeGPIO_MODE_OUTPUT_PP;led_init.SpeedGPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(GPIOC,led_init);/* 消抖状态变量 */uint8_tstable_pressed0;// 当前稳定的按钮状态0松开1按下uint32_tdebounce_start0;// 状态变化时的时间戳constuint32_tdebounce_ms20;// 消抖等待时间while(1){/* 采样当前引脚状态 */uint8_tcurrent(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET)?1:0;if(current!stable_pressed){/* 状态发生了变化 */debounce_startHAL_GetTick();stable_pressedcurrent;// 简化处理直接更新}/* 这里有一个问题——上面的实现并没有真正等待确认 * 我们只是记录了时间戳但没有用它来判断 * 让我们修正 */}}等等上面的代码有问题。我只记录了时间戳但没有用它来做判断。让我重新写一个正确的版本/* 消抖状态变量 */uint8_tlast_stable0;// 上次确认的稳定状态uint8_tlast_raw0;// 上次原始采样值uint32_tlast_change_time0;// 原始值最后一次变化的时间constuint32_tdebounce_ms20;/* 初始化采样 */last_raw(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET)?1:0;last_stablelast_raw;while(1){uint8_tcurrent(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET)?1:0;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(last_stable){/* 按钮按下 */HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_RESET);// LED 亮}else{/* 按钮松开 */HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_SET);// LED 灭}}}/* 这里可以做其他任务 —— CPU 没有被阻塞 */}逐行解读状态变量last_stable上次确认的稳定按钮状态。只有在原始信号稳定了 20ms 之后才会更新。last_raw最近的原始采样值。每次采到不同的值就更新。last_change_time原始值最后一次变化的时间戳。核心逻辑每次循环采样current。如果current和last_raw不同说明信号在跳变——更新last_raw并重置计时器。如果距离上次变化已经过了debounce_ms20ms且原始值和稳定值不同——确认状态真的变了更新稳定值并触发事件。为什么这能消抖抖动期间信号快速跳变每次跳变都重置计时器。只有当信号连续 20ms 保持不变时计时器才会到期状态才被确认。抖动的 5-20ms 跳变会被计时器的不断重置过滤掉。为什么不阻塞整个逻辑只用了HAL_GetTick()做时间戳比较一次减法 一次比较没有HAL_Delay()。主循环以全速运行每次循环只花几个微秒。你完全可以在while(1)循环的空余位置加入其他任务——LED 闪烁、传感器读取、通信处理——都不会被按钮消抖打断。溢出的安全性有一个细节值得注意HAL_GetTick() - last_change_time使用的是无符号整数减法。即使HAL_GetTick()溢出归零了这个减法的结果仍然正确——因为无符号整数减法的模运算性质。例如last_change_time 0xFFFFFFF0HAL_GetTick() 0x00000010溢出后差值是0x00000010 - 0xFFFFFFF0 0x00000020 32。32ms正确。所以你不需要担心 49.7 天的溢出问题。这比手动处理溢出要简洁得多也是嵌入式开发中使用无符号整数做时间差的一个标准技巧。这个方案还有问题吗非阻塞消抖解决了HAL_Delay()的阻塞问题但还不够完善没有按下和释放的事件概念上面的代码在稳定值变化时做操作但没有明确的按下事件和释放事件——你需要自己判断是从 0 变到 1 还是 1 变到 0。没有处理启动时的状态如果系统上电时按钮已经被按住了呢初始化时读到的稳定状态是按下但这不应该触发按下事件。状态变量散落在主循环里last_stable、last_raw、last_change_time这些变量和按钮逻辑紧密耦合却作为独立的局部变量存在。随着项目变复杂维护这些状态变量会很头疼。这三个问题指向同一个解决方案把消抖逻辑封装成一个状态机。状态机把所有的状态转换规则集中管理每个状态有明确的进入条件、驻留行为和退出动作。不再是散落的if-else而是一个结构化的switch-case。这就是下一篇的主题——7 状态消抖状态机我们最终方案的核心。我们回头看这一篇做了三件事解释了HAL_Delay()阻塞消抖的问题引入了HAL_GetTick()做非阻塞时间管理实现了一个可用的非阻塞消抖算法。关键收获HAL_GetTick()返回毫秒时间戳底层由 SysTick 中断驱动非阻塞消抖的核心记录变化时间检查是否稳定了足够长时间无符号整数减法天然处理溢出当前方案的不足没有事件概念、没有启动处理、状态变量散落——都指向状态机下一篇我们把散落的if-else重构成一个严谨的状态机。相关阅读RVO 与 NRVO编译器的返回值优化 - 相似度 58%OnceCallback 实战五then 链式组合 - 相似度 24%
嵌入式C++开发第22篇:非阻塞消抖 —— 不让 CPU 停下来等
发布时间:2026/5/20 9:30:09
嵌入式C开发第22篇非阻塞消抖 —— 不让 CPU 停下来等仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下clone me!: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP静态网页体验极大改进点击这里直接阅览https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/非常抱歉超级久没有更新这个系列了笔者最近忙于工作抽不开身加上其他项目要维护终于想起来这个太久没更新了现在这里赶紧补上。PS上一篇在5月2号发布的是HAL GPIO读取芯片。C的部分在这些内容讲解完成之后展开阻塞消抖的代价上一篇最后我们试了一个最简单的消抖方案// 阻塞式消抖if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET){HAL_Delay(20);// 阻塞 20msif(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET){// 确认按下HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_13);// 等待释放防止按住不放时重复触发while(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET){}}}这个方案确实能消除大部分抖动问题。但它的代价是HAL_Delay(20)把 CPU 冻结了 20 毫秒。20ms 听起来不长。如果你只是控制一盏 LED等就等了无所谓。但真实项目中你的主循环可能要做很多事情——读取传感器数据、更新显示、处理通信协议。如果你在每次检测按钮时都阻塞 20ms其他任务的实时性就被破坏了。更糟的是最后的while循环——如果用户一直按住按钮不放CPU 就一直卡在这个循环里其他任务完全停止。这已经不是延迟了这是挂死。我们需要一种不阻塞 CPU 的消抖方式。HAL_GetTick免费的时钟HAL_GetTick()返回自系统启动以来经过的毫秒数。它是一个 32 位无符号整数从 0 开始每毫秒加 1大约 49.7 天后溢出归零对于嵌入式项目来说基本可以忽略。uint32_tnowHAL_GetTick();// 例如返回 12345表示系统已运行 12.345 秒HAL_GetTick()的底层实现在hal_mock.c中——SysTick_Handler()中断每 1ms 触发一次调用HAL_IncTick()递增一个全局计数器。这个计数器就是我们获取时间的来源。用HAL_GetTick()做消抖的核心思想是记录状态变化发生的时间下次循环时检查是否已经过了足够长的时间而不是停下来等。非阻塞消抖算法基本思路1. 每次循环采样当前引脚状态 2. 如果和上次记录的稳定状态不同 a. 记录变化发生的时间 (debounce_start) b. 标记正在消抖 3. 如果正在消抖且已经过了 debounce_ms a. 再次采样确认 b. 如果确认状态确实变了更新稳定状态 c. 触发事件 4. 如果在消抖期间状态又变了回来 a. 取消消抖这是假信号用 ASCII 状态图表示┌──────────┐ 状态变化 ┌──────────────┐ 确认变化 ┌──────────┐ │ 稳定 │──────────→│ 消抖中 │──────────→│ 新稳定 │ │ (高/低) │ │ (等待时间到) │ │ (高/低) │ └──────────┘←──────────└──────────────┘ └──────────┘ 状态回弹 (假信号)C 语言实现#includestm32f1xx_hal.hintmain(void){HAL_Init();/* 系统时钟配置省略 */__HAL_RCC_GPIOA_CLK_ENABLE();__HAL_RCC_GPIOC_CLK_ENABLE();/* PA0 上拉输入 */GPIO_InitTypeDef btn_init{0};btn_init.PinGPIO_PIN_0;btn_init.ModeGPIO_MODE_INPUT;btn_init.PullGPIO_PULLUP;HAL_GPIO_Init(GPIOA,btn_init);/* PC13 推挽输出 */GPIO_InitTypeDef led_init{0};led_init.PinGPIO_PIN_13;led_init.ModeGPIO_MODE_OUTPUT_PP;led_init.SpeedGPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(GPIOC,led_init);/* 消抖状态变量 */uint8_tstable_pressed0;// 当前稳定的按钮状态0松开1按下uint32_tdebounce_start0;// 状态变化时的时间戳constuint32_tdebounce_ms20;// 消抖等待时间while(1){/* 采样当前引脚状态 */uint8_tcurrent(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET)?1:0;if(current!stable_pressed){/* 状态发生了变化 */debounce_startHAL_GetTick();stable_pressedcurrent;// 简化处理直接更新}/* 这里有一个问题——上面的实现并没有真正等待确认 * 我们只是记录了时间戳但没有用它来判断 * 让我们修正 */}}等等上面的代码有问题。我只记录了时间戳但没有用它来做判断。让我重新写一个正确的版本/* 消抖状态变量 */uint8_tlast_stable0;// 上次确认的稳定状态uint8_tlast_raw0;// 上次原始采样值uint32_tlast_change_time0;// 原始值最后一次变化的时间constuint32_tdebounce_ms20;/* 初始化采样 */last_raw(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET)?1:0;last_stablelast_raw;while(1){uint8_tcurrent(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0)GPIO_PIN_RESET)?1:0;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(last_stable){/* 按钮按下 */HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_RESET);// LED 亮}else{/* 按钮松开 */HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_SET);// LED 灭}}}/* 这里可以做其他任务 —— CPU 没有被阻塞 */}逐行解读状态变量last_stable上次确认的稳定按钮状态。只有在原始信号稳定了 20ms 之后才会更新。last_raw最近的原始采样值。每次采到不同的值就更新。last_change_time原始值最后一次变化的时间戳。核心逻辑每次循环采样current。如果current和last_raw不同说明信号在跳变——更新last_raw并重置计时器。如果距离上次变化已经过了debounce_ms20ms且原始值和稳定值不同——确认状态真的变了更新稳定值并触发事件。为什么这能消抖抖动期间信号快速跳变每次跳变都重置计时器。只有当信号连续 20ms 保持不变时计时器才会到期状态才被确认。抖动的 5-20ms 跳变会被计时器的不断重置过滤掉。为什么不阻塞整个逻辑只用了HAL_GetTick()做时间戳比较一次减法 一次比较没有HAL_Delay()。主循环以全速运行每次循环只花几个微秒。你完全可以在while(1)循环的空余位置加入其他任务——LED 闪烁、传感器读取、通信处理——都不会被按钮消抖打断。溢出的安全性有一个细节值得注意HAL_GetTick() - last_change_time使用的是无符号整数减法。即使HAL_GetTick()溢出归零了这个减法的结果仍然正确——因为无符号整数减法的模运算性质。例如last_change_time 0xFFFFFFF0HAL_GetTick() 0x00000010溢出后差值是0x00000010 - 0xFFFFFFF0 0x00000020 32。32ms正确。所以你不需要担心 49.7 天的溢出问题。这比手动处理溢出要简洁得多也是嵌入式开发中使用无符号整数做时间差的一个标准技巧。这个方案还有问题吗非阻塞消抖解决了HAL_Delay()的阻塞问题但还不够完善没有按下和释放的事件概念上面的代码在稳定值变化时做操作但没有明确的按下事件和释放事件——你需要自己判断是从 0 变到 1 还是 1 变到 0。没有处理启动时的状态如果系统上电时按钮已经被按住了呢初始化时读到的稳定状态是按下但这不应该触发按下事件。状态变量散落在主循环里last_stable、last_raw、last_change_time这些变量和按钮逻辑紧密耦合却作为独立的局部变量存在。随着项目变复杂维护这些状态变量会很头疼。这三个问题指向同一个解决方案把消抖逻辑封装成一个状态机。状态机把所有的状态转换规则集中管理每个状态有明确的进入条件、驻留行为和退出动作。不再是散落的if-else而是一个结构化的switch-case。这就是下一篇的主题——7 状态消抖状态机我们最终方案的核心。我们回头看这一篇做了三件事解释了HAL_Delay()阻塞消抖的问题引入了HAL_GetTick()做非阻塞时间管理实现了一个可用的非阻塞消抖算法。关键收获HAL_GetTick()返回毫秒时间戳底层由 SysTick 中断驱动非阻塞消抖的核心记录变化时间检查是否稳定了足够长时间无符号整数减法天然处理溢出当前方案的不足没有事件概念、没有启动处理、状态变量散落——都指向状态机下一篇我们把散落的if-else重构成一个严谨的状态机。相关阅读RVO 与 NRVO编译器的返回值优化 - 相似度 58%OnceCallback 实战五then 链式组合 - 相似度 24%