按键及消抖
方法一:延时阻塞
key.c:
#include "key.h"
#include "delay.h"//初始化GPIO
void key_init(void)
{GPIO_InitTypeDef gpio_initstruct;//打开时钟__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA时钟//调用GPIO初始化函数gpio_initstruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // 两个KEY对应的引脚gpio_initstruct.Mode = GPIO_MODE_INPUT; // 输入gpio_initstruct.Pull = GPIO_PULLUP; // 默认上拉,要结合实际电路,如果按下拉低接口,输入一个低电平,那这里就是上拉输入gpio_initstruct.Speed = GPIO_SPEED_FREQ_HIGH; // 高速HAL_GPIO_Init(GPIOA, &gpio_initstruct);
}//按键扫描函数
uint8_t key_scan(void)
{//检测按键是否按下if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET){//消抖delay_ms(10);//再次判断按键是否按下if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET){//如果确实是按下的状态,等待按键松开while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET);//返回按键值return 1;}}//检测按键是否按下if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET){//消抖delay_ms(10);//再次判断按键是否按下if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET){//如果确实是按下的状态,等待按键松开while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET);//返回按键值return 2;}}//返回默认值return 0;
}
key_scan()
里用 延时消抖(delay_ms(10))属于 “时间消抖法”,优点是简单,缺点是:
延时是阻塞的,CPU 在等待时不能干别的事;
多个按键时效率更低;
延时时间设置不好会影响响应速度。
实际上,按键消抖方法有很多,除了“延时消抖”,常见的还有:
🔹 1. 状态机消抖法(非阻塞,推荐)
用一个状态机记录按键状态(未按下、按下确认、按住、释放确认),每次扫描时更新状态。
扫描周期(比如 5ms 或 10ms)由 定时器中断 或 RTOS 任务来保证。
只要稳定几次扫描结果一致,才认为按下/松开。
优点:非阻塞,不用 delay,系统实时性好。
key.c:
#include "key.h"
#include "stm32f1xx_hal.h" // 确保包含 HAL 头
#include <stdint.h>/* ========================= 配置区域 ========================= */
/* 扫描周期与确认次数:* - 建议每 5~10ms 调用一次 key_scan()(可在主循环里用 HAL_GetTick() 节拍控制,或用定时器/RTOS任务)* - 连续 N 次一致才确认状态变化:N * SCAN_TICKS_MS 即为有效消抖时间*/
#define SCAN_TICKS_MS 5 // 每次扫描的理想间隔(毫秒)
#define CONFIRM_COUNT 3 // 连续 3 次一致才确认(约 15ms 消抖)/* 按键引脚(按你的原工程保持 A0/A1 上拉,低电平=按下) */
#define KEY0_PORT GPIOA
#define KEY0_PIN GPIO_PIN_0
#define KEY1_PORT GPIOA
#define KEY1_PIN GPIO_PIN_1/* 物理读取:返回 1=按下(低电平),0=松开(高电平) */
static inline uint8_t key0_raw(void) { return (HAL_GPIO_ReadPin(KEY0_PORT, KEY0_PIN) == GPIO_PIN_RESET); }
static inline uint8_t key1_raw(void) { return (HAL_GPIO_ReadPin(KEY1_PORT, KEY1_PIN) == GPIO_PIN_RESET); }/* ========================= 状态机定义 =========================* 我们用 4 态模型:* IDLE(稳定松开)* PRESS_CHECK(检测到可能按下,进入确认期)* PRESSED(稳定按下)* RELEASE_CHECK(检测到可能松开,进入确认期)* 时序:IDLE→PRESS_CHECK→PRESSED→RELEASE_CHECK→IDLE* 只有在 “PRESSED → RELEASE_CHECK → IDLE” 完成时,才认为一次完整按键发生并返回键值。*/
typedef enum {KEY_IDLE = 0, // 稳定松开KEY_PRESS_CHECK, // 按下确认中KEY_PRESSED, // 稳定按下KEY_RELEASE_CHECK // 松开确认中
} key_state_t;typedef struct {key_state_t state; // 当前状态uint8_t cnt; // 连续一致计数
} key_fsm_t;/* 两个按键的状态机 */
static key_fsm_t key0 = { KEY_IDLE, 0 };
static key_fsm_t key1 = { KEY_IDLE, 0 };/* 节拍控制:保证 key_scan() 按 SCAN_TICKS_MS 的节奏运行(防止被过于频繁调用导致“计时加快”) */
static uint32_t s_last_tick = 0;/* ========================= 用户接口实现 ========================= *//* 初始化GPIO:与原逻辑一致(上拉输入,低电平有效) */
void key_init(void)
{__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能 GPIOA 时钟GPIO_InitTypeDef gpio_initstruct = {0};gpio_initstruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // 两个 KEY 引脚gpio_initstruct.Mode = GPIO_MODE_INPUT; // 输入模式gpio_initstruct.Pull = GPIO_PULLUP; // 上拉gpio_initstruct.Speed = GPIO_SPEED_FREQ_LOW; // 输入模式速度无关,LOW 即可HAL_GPIO_Init(GPIOA, &gpio_initstruct);// 状态机复位key0.state = KEY_IDLE; key0.cnt = 0;key1.state = KEY_IDLE; key1.cnt = 0;s_last_tick = HAL_GetTick();
}/* 内部:推进单个键的状态机;返回1表示“本次完成一次按键动作(按下并松开确认)”,否则0 */
static uint8_t step_fsm(key_fsm_t *k, uint8_t raw_press)
{switch (k->state){case KEY_IDLE: // 稳定松开if (raw_press) {k->state = KEY_PRESS_CHECK;k->cnt = 1;}break;case KEY_PRESS_CHECK: // 按下确认中if (raw_press) {if (++k->cnt >= CONFIRM_COUNT) {k->state = KEY_PRESSED; // 确认按下成立k->cnt = 0;}} else {// 摇回去k->state = KEY_IDLE;k->cnt = 0;}break;case KEY_PRESSED: // 稳定按下if (!raw_press) {k->state = KEY_RELEASE_CHECK;k->cnt = 1;}break;case KEY_RELEASE_CHECK: // 松开确认中if (!raw_press) {if (++k->cnt >= CONFIRM_COUNT) {k->state = KEY_IDLE; // 确认松开成立k->cnt = 0;return 1; // ——一次完整按键动作完成(与原版功能一致:松开后返回)}} else {// 又按下了,回到按下稳定k->state = KEY_PRESSED;k->cnt = 0;}break;default:k->state = KEY_IDLE; k->cnt = 0;break;}return 0;
}/* 非阻塞按键扫描:* - 需被“周期性地”调用(推荐每 5~10ms 一次)* - 返回:0=无;1=KEY0 完成一次按键;2=KEY1 完成一次按键* - 行为与原版一致:只有“按下→松开”完整动作完成才返回键值*/
uint8_t key_scan(void)
{/* 简单节拍器:若调用过快则不推进(防止消抖时间被缩短) */uint32_t now = HAL_GetTick();if ((now - s_last_tick) < SCAN_TICKS_MS) {return 0;}s_last_tick = now;/* 读取原始电平(低电平=按下) */uint8_t k0_raw = key0_raw();uint8_t k1_raw = key1_raw();/* 推进两个键的状态机;谁先完成谁先返回 */if (step_fsm(&key0, k0_raw)) return 1;if (step_fsm(&key1, k1_raw)) return 2;return 0;
}
使用建议
在
while(1)
主循环里,每次循环都调用一次key_scan()
即可;内部已按SCAN_TICKS_MS
自节拍。若你在定时器中断/RTOS里有固定 5ms 节拍,也可以去掉内部节拍器逻辑,直接每 5ms 调一次
key_scan()
(把顶部节拍相关代码移除即可)。调参:
SCAN_TICKS_MS
:扫描周期;CONFIRM_COUNT
:确认次数;有效消抖时间约为SCAN_TICKS_MS × CONFIRM_COUNT
(默认 ≈ 15ms)。
如果还想要按下事件立即上报(而不是等松开),可以在 KEY_PRESSED
的进入处(PRESS_CHECK
确认完成时)返回一个“PRESS 事件”;并在 RELEASE_CHECK
确认完成时返回“RELEASE 事件”。
🔹 2. 定时器中断消抖
使用定时器中断(如 1ms)周期性采样按键。
若某引脚状态连续稳定一段时间(如 20ms),再确认按下或松开。
👉 思路类似状态机法,但触发源换成了硬件定时器。
key_init()
:初始化 GPIO + 配置并启动 TIM3 的 1 ms 中断采样。key_scan()
:非阻塞,读取由中断产生的“完整按键事件”(按下→松开)并返回1/2/0
。
思路:在 TIM3 1 ms中断里做采样与消抖的状态机(PRESS_CHECK / RELEASE_CHECK),当“按下并确认”再“松开并确认”完成时,置一个事件标志。主循环调用
key_scan()
只需读标志即可,无 delay、无 while 等待。
key.c:
#include "key.h"
#include "stm32f1xx_hal.h"
#include <stdint.h>/* ===================== 用户可调参数 ===================== */
/* 采样周期:1ms(由 TIM3 产生) */
#define SAMPLE_PERIOD_MS 1/* 消抖确认时间:例如 20ms(按下与松开都采用该门限) */
#define STABLE_MS 20
#define CONFIRM_TICKS (STABLE_MS / SAMPLE_PERIOD_MS) // =20/* 两个按键:PA0 / PA1,上拉输入,低电平=按下 */
#define KEY0_PORT GPIOA
#define KEY0_PIN GPIO_PIN_0
#define KEY1_PORT GPIOA
#define KEY1_PIN GPIO_PIN_1/* 物理采样:返回 1=按下(低电平),0=松开(高电平) */
static inline uint8_t key0_raw(void) { return (HAL_GPIO_ReadPin(KEY0_PORT, KEY0_PIN) == GPIO_PIN_RESET); }
static inline uint8_t key1_raw(void) { return (HAL_GPIO_ReadPin(KEY1_PORT, KEY1_PIN) == GPIO_PIN_RESET); }/* ===================== 定时器句柄 ===================== */
static TIM_HandleTypeDef htim3;/* ===================== 状态机与事件 ===================== */
/* 4 态状态机:* IDLE(稳定松开) → PRESS_CHECK(按下确认) → PRESSED(稳定按下) → RELEASE_CHECK(松开确认) → IDLE* 只有完成 PRESSED→RELEASE_CHECK→IDLE 时才记为“一次完整按键”。*/
typedef enum {KEY_IDLE = 0, // 稳定松开KEY_PRESS_CHECK, // 按下确认中KEY_PRESSED, // 稳定按下KEY_RELEASE_CHECK // 松开确认中
} key_state_t;typedef struct {key_state_t state; // 当前状态uint8_t cnt; // 连续一致的计数(用于确认)
} key_fsm_t;/* 两个键的状态机实例 */
static key_fsm_t key0_fsm = { KEY_IDLE, 0 };
static key_fsm_t key1_fsm = { KEY_IDLE, 0 };/* 中断里产生的“完整按键事件”标志(按下→松开完成) */
static volatile uint8_t key0_event = 0; // 置1表示有一次完整的 KEY0 事件待取
static volatile uint8_t key1_event = 0; // 置1表示有一次完整的 KEY1 事件待取/* ===================== 内部:推进一个键的状态机(在中断里调用) ===================== */
static void key_fsm_step(key_fsm_t *k, uint8_t raw_press, volatile uint8_t *event_flag)
{switch (k->state){case KEY_IDLE: /* 稳定松开 -> 看到“按下”则进入确认 */if (raw_press) {k->state = KEY_PRESS_CHECK;k->cnt = 1;}break;case KEY_PRESS_CHECK: /* 按下确认:连续 CONFIRM_TICKS 次都“按下”才成立 */if (raw_press) {if (++k->cnt >= CONFIRM_TICKS) {k->state = KEY_PRESSED; // 确认“稳定按下”k->cnt = 0;}} else {/* 中途又松开:判定失败,回到 IDLE */k->state = KEY_IDLE;k->cnt = 0;}break;case KEY_PRESSED: /* 稳定按下 -> 观察到“松开”则进入确认 */if (!raw_press) {k->state = KEY_RELEASE_CHECK;k->cnt = 1;}break;case KEY_RELEASE_CHECK: /* 松开确认:连续 CONFIRM_TICKS 次都“松开”才成立 */if (!raw_press) {if (++k->cnt >= CONFIRM_TICKS) {k->state = KEY_IDLE; // 确认“稳定松开”k->cnt = 0;*event_flag = 1; // ——一次完整按键(按下→松开)完成,置事件}} else {/* 中途又按下:回到稳定按下 */k->state = KEY_PRESSED;k->cnt = 0;}break;default:k->state = KEY_IDLE; k->cnt = 0;break;}
}/* ===================== TIM3 初始化:1kHz(1ms)中断 =====================* 典型 F1 时钟:SYSCLK=72MHz, APB1=36MHz,但定时器时钟在 APB1 分频!=1 时倍频到 72MHz。* 这里配置:Prescaler=7200-1 → 分频 7200,计数频率 10kHz;* Period =10-1 → 计满 10 次产生更新 → 1kHz 中断(1ms)。* 若你的时钟不同,请按实际修改。* ====================================================================== */
static void TIM3_Init_1ms(void)
{__HAL_RCC_TIM3_CLK_ENABLE();htim3.Instance = TIM3;htim3.Init.Prescaler = 7200 - 1; // 72MHz / 7200 = 10kHzhtim3.Init.CounterMode = TIM_COUNTERMODE_UP;htim3.Init.Period = 10 - 1; // 10kHz / 10 = 1kHz → 1mshtim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;HAL_TIM_Base_Init(&htim3);HAL_TIM_Base_Start_IT(&htim3);/* 使能 NVIC 中断 */HAL_NVIC_SetPriority(TIM3_IRQn, 1, 0);HAL_NVIC_EnableIRQ(TIM3_IRQn);
}/* ===================== 用户接口:GPIO + 定时器初始化 ===================== */
void key_init(void)
{/* GPIO:上拉输入,低电平=按下(保持与原工程一致) */__HAL_RCC_GPIOA_CLK_ENABLE();GPIO_InitTypeDef io = {0};io.Pin = GPIO_PIN_0 | GPIO_PIN_1;io.Mode = GPIO_MODE_INPUT;io.Pull = GPIO_PULLUP;io.Speed = GPIO_SPEED_FREQ_LOW; // 输入模式速度无关HAL_GPIO_Init(GPIOA, &io);/* 状态机与事件清零 */key0_fsm.state = KEY_IDLE; key0_fsm.cnt = 0; key0_event = 0;key1_fsm.state = KEY_IDLE; key1_fsm.cnt = 0; key1_event = 0;/* 启动 TIM3 1ms 周期中断,进行消抖采样与判定 */TIM3_Init_1ms();
}/* ===================== 中断服务:1ms 采样与消抖 ===================== */
/* HAL 的更新回调:由 TIM3_IRQHandler → HAL_TIM_IRQHandler → 触发此回调 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{if (htim->Instance == TIM3){/* 读取原始电平(低电平=按下) */uint8_t r0 = key0_raw();uint8_t r1 = key1_raw();/* 推进两个键的状态机;事件标志在状态机内部置位 */key_fsm_step(&key0_fsm, r0, &key0_event);key_fsm_step(&key1_fsm, r1, &key1_event);}
}/* TIM3 IRQHandler(放在 stm32f1xx_it.c 里更规范;若没该文件也可暂放此处) */
void TIM3_IRQHandler(void)
{HAL_TIM_IRQHandler(&htim3);
}/* ===================== 用户接口:查询一次事件 ===================== */
/* 行为与原版保持一致:* - 返回 1:KEY0 完成一次“按下→松开”* - 返回 2:KEY1 完成一次“按下→松开”* - 返回 0:无新事件* 非阻塞:不含 delay,不会卡主循环。*/
uint8_t key_scan(void)
{if (key0_event) { key0_event = 0; return 1; }if (key1_event) { key1_event = 0; return 2; }return 0;
}
使用提示
保持原来的主循环不变,周期性调用
key_scan()
即可(现在 不会阻塞 主循环)。如果在工程里使用了 SysTick 1 ms,也可以改为在
SysTick_Handler
里调用一个Key_ISR_1ms()
来推进状态机,思路完全一样;这里用 TIM3 是为了不影响 SysTick。想调整消抖时间:把
STABLE_MS
改为你需要的值(例如 10/15/30 ms),1 ms 采样会自动按CONFIRM_TICKS
计算确认门限。
🔹 3. 计数滤波法
每次扫描时对按键状态计数:
检测到按下 →
count++
检测到松开 →
count--
当
count
超过上限(如 >3)时确认“按下”,小于下限(如 <0)时确认“松开”。
👉 优点:实现简单,兼顾滤波和消抖。
key_init()
:初始化 GPIO(上拉输入,低电平按下)。key_scan()
:非阻塞,建议每 5 ms 调用一次;当且仅当完成“一次按下→松开”的完整动作时返回1/2
,否则返回0
(与原代码保持一致,但不再 delay/while 卡住主循环)。
计数滤波法要点:
每次扫描读一次原始电平(按下=1,松开=0)。
计数器
cnt
:按下则+1
,松开则-1
,并在[0..CNT_MAX]
内饱和。当
cnt >= TH_ON
认为“稳定按下”;当cnt <= TH_OFF
认为“稳定松开”(带回滞避免抖动来回翻转)。只有从稳定按下转到稳定松开时,才认定完成一次“点击”,置事件供
key_scan()
返回(与原先“按下后等待松开再返回”的行为一致)。
key.c:
#include "key.h"
#include "stm32f1xx_hal.h"
#include <stdint.h>/* ====================== 可调参数 ====================== */
/* 建议每 5 ms 调用一次 key_scan()(主循环或定时器节拍) */
#define SCAN_TICKS_MS 5/* 计数滤波参数(积分 + 回滞):* - CNT_MAX:计数上限(越大滤波越强但响应越慢)* - TH_ON :达到/超过该值判定“稳定按下”* - TH_OFF :小于/等于该值判定“稳定松开”(回滞阈值,应小于 TH_ON)* 例如:CNT_MAX=10,TH_ON=7,TH_OFF=3 → 近似相当于 ~20~40ms 的抗抖(视抖动形态)*/
#define CNT_MAX 10
#define TH_ON 7
#define TH_OFF 3/* ====================== 硬件引脚 ====================== */
/* 与原工程保持一致:PA0 / PA1 上拉输入,低电平=按下 */
#define KEY0_PORT GPIOA
#define KEY0_PIN GPIO_PIN_0
#define KEY1_PORT GPIOA
#define KEY1_PIN GPIO_PIN_1/* 原始采样:返回 1=按下(低电平),0=松开(高电平) */
static inline uint8_t key0_raw(void) { return (HAL_GPIO_ReadPin(KEY0_PORT, KEY0_PIN) == GPIO_PIN_RESET); }
static inline uint8_t key1_raw(void) { return (HAL_GPIO_ReadPin(KEY1_PORT, KEY1_PIN) == GPIO_PIN_RESET); }/* ====================== 计数滤波 + 事件 ====================== */
/* 稳定状态标记:0=稳定按下,1=稳定松开(用 1 表示松开更直观) */
typedef struct {uint8_t cnt; // 0..CNT_MAX 的积分计数uint8_t state; // 当前稳定状态:1=松开,0=按下
} key_counter_t;static key_counter_t key0 = { CNT_MAX, 1 }; // 初始偏向“松开”
static key_counter_t key1 = { CNT_MAX, 1 };static volatile uint8_t key0_event = 0; // 完整“按下→松开”事件
static volatile uint8_t key1_event = 0;static uint32_t s_last_tick = 0; // 内部节拍器,确保按 SCAN_TICKS_MS 推进/* 推进单键的计数滤波与事件判定:* - raw_press: 1=按下,0=松开* - 当从“稳定按下(0)”转变为“稳定松开(1)”时,置 *click_flag = 1*/
static void key_counter_step(key_counter_t *k, uint8_t raw_press, volatile uint8_t *click_flag)
{/* 1) 计数积分:按下(+1) / 松开(-1),并饱和在 [0..CNT_MAX] */if (raw_press) {if (k->cnt < CNT_MAX) k->cnt++;} else {if (k->cnt > 0) k->cnt--;}/* 2) 回滞阈值判定,更新稳定状态 */if (k->cnt >= TH_ON) {/* 稳定按下 */if (k->state != 0) {k->state = 0; // 进入“稳定按下”/* 此处可产生“PRESS 事件”(如果你需要立即上报按下) */}} else if (k->cnt <= TH_OFF) {/* 稳定松开 */if (k->state != 1) {/* 仅当从按下(0)回到松开(1)时,认为一次点击完成 */if (k->state == 0) {*click_flag = 1; // ——一次“按下→松开”完成}k->state = 1; // 进入“稳定松开”}}
}/* ====================== 用户接口实现 ====================== *//* 初始化GPIO:与原工程一致(上拉输入,低电平有效) */
void key_init(void)
{__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能 GPIOAGPIO_InitTypeDef gpio_initstruct = {0};gpio_initstruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // 两个 KEYgpio_initstruct.Mode = GPIO_MODE_INPUT; // 输入gpio_initstruct.Pull = GPIO_PULLUP; // 上拉gpio_initstruct.Speed = GPIO_SPEED_FREQ_LOW; // 输入速度无关HAL_GPIO_Init(GPIOA, &gpio_initstruct);/* 初始化计数与状态、事件与节拍 */key0.cnt = CNT_MAX; key0.state = 1; key0_event = 0;key1.cnt = CNT_MAX; key1.state = 1; key1_event = 0;s_last_tick = HAL_GetTick();
}/* 非阻塞扫描:* - 建议每 5 ms 调用一次(内部也做了节拍限速,过快调用会直接返回 0)* - 返回:1=KEY0 完成一次“按下→松开”;2=KEY1 完成一次“按下→松开”;0=无事件* - 与原实现“按下后等待松开再返回”的功能一致,但不阻塞。*/
uint8_t key_scan(void)
{/* 简单节拍器:确保按 SCAN_TICKS_MS 推进一次滤波(防止被高频多次调用而缩短“有效消抖时间”) */uint32_t now = HAL_GetTick();if ((now - s_last_tick) < SCAN_TICKS_MS) {/* 没到节拍,不推进滤波 */goto _check_event_and_return;}s_last_tick = now;/* 一次节拍:读取原始电平并推进两个键的计数滤波 */uint8_t r0 = key0_raw();uint8_t r1 = key1_raw();key_counter_step(&key0, r0, &key0_event);key_counter_step(&key1, r1, &key1_event);_check_event_and_return:/* 谁先完成一次点击谁先返回;保证与原代码返回语义一致 */if (key0_event) { key0_event = 0; return 1; }if (key1_event) { key1_event = 0; return 2; }return 0;
}
说明与调参建议
非阻塞:没有
delay_ms()
和while()
,主循环实时性更好。调用频率:建议每 5 ms 调用一次
key_scan()
;若你已有固定节拍(比如用定时器/RTOS),也可以移除内部节拍器,固定周期调用即可。消抖强度:
增大
CNT_MAX
或TH_ON
可增强抗抖(响应更慢);减小
TH_OFF
可增加回滞,避免临界抖动来回切换;一般保持
TH_OFF < TH_ON
,如示例TH_ON=7,TH_OFF=3
。
事件时机:当前代码仅在从稳定按下→稳定松开时上报事件(与原行为一致)。如果你需要“按下就上报(PRESS)/松开再上报(RELEASE)”,可在
k->state
切换处各自置不同事件枚举即可。
🔹 4. 硬件RC电路消抖
在按键硬件电路上加:
电阻 + 电容(RC电路),滤掉机械抖动;
或者加 施密特触发器(如 74HC14)来增强边沿稳定性。
👉 优点:软件不需要处理消抖,反应更快。缺点是需要增加硬件成本。
🔹 5. 软件滤波算法
滑动平均:保存最近 N 次按键采样结果,取多数值作为当前状态;
数字滤波:如 IIR、FIR 滤波,减少抖动影响。
接口与语义保持不变:
key_init()
初始化;key_scan()
只有在一次“按下→松开”完整动作结束时,返回1/2
(否则返回0
),与原阻塞版行为一致;不再使用
delay_ms()
/while(...)
,建议每 5 ms 调用一次key_scan()
(也可用定时器/RTOS固定节拍调用)。
算法说明(软件滤波)
维护每个键最近 N=8 次采样的“按下位历史(1=按下,0=松开)”
hist
;计算窗口内“按下”的个数
sum = popcount(hist)
;多数判决 + 回滞阈值:
sum >= MAJ_ON(6)
→ 判为“稳定按下”;sum <= MAJ_OFF(2)
→ 判为“稳定松开”;
只有当“稳定按下→稳定松开”完成时,置一次点击事件,
key_scan()
返回键值;回滞 (
MAJ_OFF < MAJ_ON
) 可避免在临界抖动时来回翻转。
仍然可以把
SCAN_TICKS_MS
、WIN_BITS
、MAJ_ON
、MAJ_OFF
调一下,达到你想要的响应/抗抖平衡。
key.c:
#include "key.h"
#include "stm32f1xx_hal.h" // HAL 头文件
#include <stdint.h>
// #include "delay.h" // 本实现不再使用延时,可删/* ======================== 可调参数 ======================== */
/* 建议每 5 ms 调用一次 key_scan()(主循环节拍或定时器/RTOS) */
#define SCAN_TICKS_MS 5/* 滑动窗口参数(软件滤波) */
#define WIN_BITS 8 /* 窗口长度:最近 8 次采样 */
#define MAJ_ON 6 /* 多数判决:>=6/8 认为“稳定按下” */
#define MAJ_OFF 2 /* 多数判决:<=2/8 认为“稳定松开”(回滞,应当 < MAJ_ON) *//* ======================== 硬件引脚 ======================== */
/* 与原工程保持一致:PA0/PA1 上拉输入,低电平=按下 */
#define KEY0_PORT GPIOA
#define KEY0_PIN GPIO_PIN_0
#define KEY1_PORT GPIOA
#define KEY1_PIN GPIO_PIN_1/* 读取原始电平:返回 1=按下(低电平),0=松开(高电平) */
static inline uint8_t key0_raw(void) { return (HAL_GPIO_ReadPin(KEY0_PORT, KEY0_PIN) == GPIO_PIN_RESET); }
static inline uint8_t key1_raw(void) { return (HAL_GPIO_ReadPin(KEY1_PORT, KEY1_PIN) == GPIO_PIN_RESET); }/* ====================== 位历史 + 事件 ====================== */
/* 稳定状态语义:0=稳定按下,1=稳定松开(用 1 表示松开更直观) */
typedef struct {uint8_t hist; // 最近 WIN_BITS 次的“按下=1/松开=0”历史(这里是 8 位)uint8_t state; // 当前稳定状态:1=松开,0=按下
} key_swf_t;static key_swf_t key0 = { 0x00, 1 }; // 初始历史按 0(=松开),稳定状态设为松开
static key_swf_t key1 = { 0x00, 1 };/* 完整“按下→松开”点击事件(由滤波判定后置位,key_scan() 读取后清零) */
static volatile uint8_t key0_event = 0;
static volatile uint8_t key1_event = 0;/* 内部节拍器,限制推进频率(防止被高频调用导致“有效消抖时间”变短) */
static uint32_t s_last_tick = 0;/* 高效 8 位 popcount(计算 hist 内 1 的个数) */
static inline uint8_t popcount8(uint8_t x)
{x = x - ((x >> 1) & 0x55);x = (x & 0x33) + ((x >> 2) & 0x33);return (uint8_t)(((x + (x >> 4)) & 0x0F));
}/* 推进一个键的滑动窗口滤波与事件判定* raw_press: 1=按下,0=松开* 过程:* 1) hist 左移一位,最低位写入 raw_press(按下=1/松开=0)* 2) 统计 1 的个数 sum = popcount(hist)* 3) 多数判决 + 回滞:sum >= MAJ_ON → 稳定按下;sum <= MAJ_OFF → 稳定松开* 4) 当“稳定按下→稳定松开”发生时,置 click_flag=1(生成一次点击事件)*/
static void key_swf_step(key_swf_t *k, uint8_t raw_press, volatile uint8_t *click_flag)
{/* 1) 移位引入当前样本 */k->hist = (uint8_t)((k->hist << 1) | (raw_press ? 1u : 0u));/* 2) 多数判决 */uint8_t sum = popcount8(k->hist);if (sum >= MAJ_ON) {/* 判为“稳定按下” */if (k->state != 0) {k->state = 0; // 进入稳定按下// 如需“按下立即上报”可在此处产生 PRESS 事件}} else if (sum <= MAJ_OFF) {/* 判为“稳定松开” */if (k->state != 1) {/* 只有从按下(0)回到松开(1)时,认为一次点击完成 */if (k->state == 0) {*click_flag = 1; // ——一次“按下→松开”完成}k->state = 1; // 进入稳定松开}}
}/* ======================== 用户接口 ======================== *//* 初始化 GPIO:与原始代码一致(上拉输入、低电平有效) */
void key_init(void)
{__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能 GPIOA 时钟GPIO_InitTypeDef gpio_initstruct = {0};gpio_initstruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // 两个 KEY 引脚gpio_initstruct.Mode = GPIO_MODE_INPUT; // 输入gpio_initstruct.Pull = GPIO_PULLUP; // 上拉gpio_initstruct.Speed = GPIO_SPEED_FREQ_LOW; // 输入模式速度无关HAL_GPIO_Init(GPIOA, &gpio_initstruct);/* 初始化滤波与事件、节拍 */key0.hist = 0x00; key0.state = 1; key0_event = 0;key1.hist = 0x00; key1.state = 1; key1_event = 0;s_last_tick = HAL_GetTick();
}/* 非阻塞扫描(软件滤波版本)* - 建议每 5 ms 调用一次;内部也做了 5 ms 的节拍限速* - 返回:1=KEY0 完成一次“按下→松开”;2=KEY1 完成一次“按下→松开”;0=无事件* - 与原代码“按下后等待松开再返回”的语义一致,但不再阻塞主循环*/
uint8_t key_scan(void)
{/* 节拍器:确保按固定间隔推进一次滤波(防止被过快调用而降低消抖时间) */uint32_t now = HAL_GetTick();if ((now - s_last_tick) < SCAN_TICKS_MS) {/* 未到节拍,仅检查是否已有事件 */goto _check_event_and_return;}s_last_tick = now;/* 采样 + 推进两个键的滤波器 */uint8_t r0 = key0_raw();uint8_t r1 = key1_raw();key_swf_step(&key0, r0, &key0_event);key_swf_step(&key1, r1, &key1_event);_check_event_and_return:/* 谁先完成一次点击谁先返回;保证与原代码返回时机一致(在“松开确认”时返回) */if (key0_event) { key0_event = 0; return 1; }if (key1_event) { key1_event = 0; return 2; }return 0;
}
使用与调参建议
调用频率:保持每 5 ms 调用一次
key_scan()
。若你已有固定节拍,可以去掉内部节拍器,固定周期调用。窗口与阈值:
增大
WIN_BITS
、MAJ_ON
,或减小MAJ_OFF
→ 抗抖更强但响应更慢;减小
WIN_BITS
,或降低MAJ_ON
、提高MAJ_OFF
→ 响应更快但抗抖变弱;一般保持
MAJ_OFF < MAJ_ON
形成回滞,避免边界抖动来回判定。
🔹 6. 操作系统事件消抖
如果在 FreeRTOS 或 RT-Thread 中:
按键扫描任务用固定周期(比如 10ms);
用 信号量/消息队列通知主任务。
这样消抖逻辑集中在一个“键盘驱动任务”里。
key_init()
:初始化 GPIO,并创建一个“按键扫描任务”,每隔 10ms 采样,做去抖状态机;当完成“按下→松开”一次完整点击时,把键值(1 或 2)投递到消息队列。key_scan()
:非阻塞,从队列里取一次事件,有就返回1/2
,没有返回0
。不再使用
delay_ms()
和while(...)
;delay.h
可保留但未使用。
算法:状态机 + 固定周期采样(10ms)。当同一状态连续 N 次(这里 N=2 ⇒ 约 20ms)保持一致,才确认“稳定按下/稳定松开”。当从“稳定按下”过渡到“稳定松开”时,判定一次完整点击,并将键值通过 FreeRTOS 队列发给主循环。
key.c:
#include "key.h"
#include "delay.h" // 本实现已不再使用 delay,可保留以兼容旧工程
#include "stm32f1xx_hal.h"/* ===== FreeRTOS 头文件(事件/任务/定时) ===== */
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include <stdint.h>/* ===================== 用户可调参数 ===================== */
/* 扫描周期:10ms(按键任务每 10ms 运行一次) */
#define SCAN_TICKS_MS 10/* 去抖确认次数:例如 2 次(2*10ms = 20ms),按下与松开都用该门限 */
#define CONFIRM_COUNT 2/* 队列容量:能缓存这么多个“完整点击事件” */
#define KEY_EVENT_QUEUE_LEN 8/* 硬件引脚:与原工程保持一致(PA0, PA1 上拉输入,低电平=按下) */
#define KEY0_PORT GPIOA
#define KEY0_PIN GPIO_PIN_0
#define KEY1_PORT GPIOA
#define KEY1_PIN GPIO_PIN_1/* 物理采样:返回 1=按下(低电平),0=松开(高电平) */
static inline uint8_t key0_raw(void) { return (HAL_GPIO_ReadPin(KEY0_PORT, KEY0_PIN) == GPIO_PIN_RESET); }
static inline uint8_t key1_raw(void) { return (HAL_GPIO_ReadPin(KEY1_PORT, KEY1_PIN) == GPIO_PIN_RESET); }/* ===================== OS 对象 ===================== */
static QueueHandle_t s_keyQueue = NULL; /* 用于把“完整点击事件”发给主循环 */
static TaskHandle_t s_keyTask = NULL; /* 按键扫描任务 *//* ===================== 去抖状态机 ===================== */
/* 4 态:* IDLE(稳定松开) → PRESS_CHECK(按下确认) → PRESSED(稳定按下)* → RELEASE_CHECK(松开确认) → IDLE* 只有通过 PRESSED→RELEASE_CHECK→IDLE,才认为一次“按下→松开”的完整点击。*/
typedef enum {KEY_IDLE = 0, // 稳定松开KEY_PRESS_CHECK, // 按下确认中KEY_PRESSED, // 稳定按下KEY_RELEASE_CHECK // 松开确认中
} key_state_t;typedef struct {key_state_t state; // 当前状态uint8_t cnt; // 连续一致计数(用于确认)
} key_fsm_t;/* 两个键的状态机实例 */
static key_fsm_t key0_fsm = { KEY_IDLE, 0 };
static key_fsm_t key1_fsm = { KEY_IDLE, 0 };/* 推进单键状态机:* raw_press: 1=按下, 0=松开* 完成一次“按下→松开”时,通过队列投递键值 event_val(1 或 2)*/
static void key_fsm_step(key_fsm_t *k, uint8_t raw_press, uint8_t event_val)
{switch (k->state){case KEY_IDLE: /* 稳定松开 → 看到“按下”则进入确认 */if (raw_press) { k->state = KEY_PRESS_CHECK; k->cnt = 1; }break;case KEY_PRESS_CHECK: /* 按下确认:连续 CONFIRM_COUNT 次均为按下才成立 */if (raw_press) {if (++k->cnt >= CONFIRM_COUNT) { k->state = KEY_PRESSED; k->cnt = 0; }} else {k->state = KEY_IDLE; k->cnt = 0; /* 回退 */}break;case KEY_PRESSED: /* 稳定按下 → 观察到“松开”进入确认 */if (!raw_press) { k->state = KEY_RELEASE_CHECK; k->cnt = 1; }break;case KEY_RELEASE_CHECK: /* 松开确认:连续 CONFIRM_COUNT 次均为松开才成立 */if (!raw_press) {if (++k->cnt >= CONFIRM_COUNT) {k->state = KEY_IDLE; k->cnt = 0;/* ——一次完整点击完成:发事件给主循环 —— */if (s_keyQueue) { uint8_t v = event_val; xQueueSend(s_keyQueue, &v, 0); }}} else {k->state = KEY_PRESSED; k->cnt = 0; /* 回退 */}break;default:k->state = KEY_IDLE; k->cnt = 0;break;}
}/* ===================== 按键扫描任务 =====================* 周期:每 10ms 运行一次* 逻辑:读取原始电平 → 推进两个键的状态机 → 若形成“完整点击”,将键值投递到队列*/
static void KeyScanTask(void *arg)
{(void)arg;for (;;){/* 1) 采样两键原始电平(低电平=按下) */uint8_t r0 = key0_raw();uint8_t r1 = key1_raw();/* 2) 推进两路状态机;完成点击时由内部发队列 */key_fsm_step(&key0_fsm, r0, 1); // 键0 → 事件值 1key_fsm_step(&key1_fsm, r1, 2); // 键1 → 事件值 2/* 3) 固定周期休眠(10ms) */vTaskDelay(pdMS_TO_TICKS(SCAN_TICKS_MS));}
}/* ===================== 用户接口:初始化 ===================== */
void key_init(void)
{/* 1) GPIO 初始化(与原工程保持一致) */__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能 GPIOA 时钟GPIO_InitTypeDef gpio_initstruct = {0};gpio_initstruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // 两个 KEY 引脚gpio_initstruct.Mode = GPIO_MODE_INPUT; // 输入gpio_initstruct.Pull = GPIO_PULLUP; // 上拉gpio_initstruct.Speed = GPIO_SPEED_FREQ_LOW; // 输入模式速度无关HAL_GPIO_Init(GPIOA, &gpio_initstruct);/* 2) 状态机复位 */key0_fsm.state = KEY_IDLE; key0_fsm.cnt = 0;key1_fsm.state = KEY_IDLE; key1_fsm.cnt = 0;/* 3) 创建事件队列(缓存多个“完整点击”) */s_keyQueue = xQueueCreate(KEY_EVENT_QUEUE_LEN, sizeof(uint8_t));/* 4) 创建按键扫描任务(优先级略高于空闲任务即可) */xTaskCreate(KeyScanTask, "key_scan", 256 /*栈字(4B)*/, NULL,tskIDLE_PRIORITY + 1, &s_keyTask);/* 注意:请确保在 main() 里调用 vTaskStartScheduler() 启动调度器 */
}/* ===================== 用户接口:获取一次事件 ===================== */
/* 行为保持与原代码一致:* - 返回 1:KEY0 完成一次“按下→松开”* - 返回 2:KEY1 完成一次“按下→松开”* - 返回 0:当前无事件* 非阻塞:0 超时读取队列*/
uint8_t key_scan(void)
{if (!s_keyQueue) return 0;uint8_t v = 0;if (xQueueReceive(s_keyQueue, &v, 0) == pdPASS) {return v; // 1 或 2}return 0;
}
使用说明
在系统初始化流程中调用
key_init()
,随后启动 RTOS:vTaskStartScheduler()
。主循环或你的业务任务里,像以前一样周期性调用
key_scan()
:返回
1/2
代表相应按键完成一次按下→松开;返回
0
表示暂无事件;
调参:
改
SCAN_TICKS_MS
控制扫描周期;改
CONFIRM_COUNT
控制按下/松开确认次数(SCAN_TICKS_MS * CONFIRM_COUNT
≈ 去抖时间,默认 ≈ 20ms)。
如果更想用软件定时器而不是任务,也很容易:把 KeyScanTask
换成 xTimerCreate(..., pdMS_TO_TICKS(10), pdTRUE, ...)
的回调里做同样的状态机推进与 xQueueSend
即可,API 保持不变。
✅ 总结:
简单项目:延时法足够。
稍复杂的单片机项目:状态机法 / 计数法(非阻塞)。
追求稳定 + 硬件条件允许:RC 滤波 + 软件确认最佳。
RTOS 系统:定时扫描 + 消息队列更优雅。