摘要:在嵌入式开发中,一个可靠且高效的按键检测程序是许多人接触的第一个模块。本文将带你深入剖析一种基于 FIFO(先进先出)队列和状态机的按键检测框架,对比传统检测方式的优劣,并提供完整的、可直接应用于 STM32 等单片机的代码实现。

一、如何进行按键检测
检测按键通常有中断方式和 GPIO 查询方式两种。从工程实践的角度,更推荐使用查询方式。
1. 从裸机的角度分析
- 中断方式:可以快速响应按键动作,但必须处理机械抖动带来的误触发。如果每个按键独立占用一个 IO 引脚,则需要为每个 IO 配置中断,过多的中断可能影响系统稳定性和实时性,且跨平台移植相对困难。
- 查询方式:最大的缺点是需要程序定期扫描,会占用一定的 CPU 时间。但实际上,一次简单的电平读取消耗的资源微乎其微。更重要的是,按键事件的具体处理逻辑可以放在主循环中,与检测逻辑解耦,程序结构更清晰。
2. 从操作系统的角度分析
- 中断方式:在 RTOS 中应谨慎使用,过多的中断处理会打破系统的可预测性,只有对实时性要求极高的事件才适合用中断。
- 查询方式:对于用户按键这类非实时性要求极高的输入,查询方式是首选。现代 RTOS 通常能很好地处理周期性的任务,一个设计良好的按键扫描任务其 CPU 占用率可以轻松控制在 1% 以下。
二、最简单的按键检测程序
首先,我们来看一种经典且广泛使用的按键检测代码,它简单直接,利用静态变量和延时消抖。
#define KEY0_PRES 1 //KEY0
#define KEY1_PRES 2 //KEY1
#define WKUP_PRES 3 //WK_UP
u8 KEY_Scan(u8 mode)
{
static u8 key_up=1;//按键松开标志
if(mode)key_up=1; //支持连按
if(key_up&&(KEY0==0||KEY1==0||WK_UP==1))
{
delay_ms(10);//去抖动
key_up=0;
if(KEY0==0)return KEY0_PRES;
else if(KEY1==0)return KEY1_PRES;
else if(WK_UP==1)return WKUP_PRES;
}else if(KEY0==1&&KEY1==1&&WK_UP==0)key_up=1;
return 0;// 无按键按下
}
int main(void)
{
u8 t=0;
delay_init(); //延时函数初始化
LED_Init(); //初始化与LED连接的硬件接口
KEY_Init(); //初始化与按键连接的硬件接口
LED=0; //点亮LED
while(1)
{
t=KEY_Scan(0); //得到键值
switch(t)
{
case KEY0_PRES: //如果KEY0按下
LED=!LED;
break;
default:
delay_ms(10);
}
}
}
这段代码能工作,但在复杂的应用场景下(如需要区分按下、弹起、长按、连发)就显得力不从心,且其阻塞式的消抖等待 (delay_ms(10)) 会影响系统响应。下面介绍的基于 FIFO 和状态机的方案,则能优雅地解决这些问题。
三、为什么要了解 FIFO
要理解 FIFO(先进先出)机制在按键检测中的应用,首先要明白它能解决什么问题。在裸机编程中,掌握 FIFO 和状态机是构建健壮、可维护程序的重要编程思想。初级程序员往往纠结于每一行代码,而高级程序员更关注程序的整体框架和逻辑。一个好的框架能让你的程序结构清晰,易于扩展和维护。
四、什么是 FIFO
FIFO 即“先进先出”队列。例如串口发送数据时,先存入缓存的数据会先被发送出去。实现一个 FIFO 通常需要一个数组作为缓冲区,以及读、写两个指针。

如上图所示,一个 FIFO 结构体至少应包含三个成员:缓冲区数组 Buf、读指针 Read 和写指针 Write。
typedef struct
{
uint8_t Buf[10]; /* 缓冲区 */
uint8_t Read; /* 缓冲区读指针*/
uint8_t Write; /* 缓冲区写指针 */
}KEY_FIFO_T;
初始时,Read = Write = 0,表示队列为空。写入数据时,数据放入 Buf[Write],然后 Write++;读出数据时,数据来自 Buf[Read],然后 Read++。当指针到达数组末尾时,会绕回开头(环形缓冲区)。
关键点:当 Write != Read 时,表示有新的数据(按键事件)待处理。如果 Write == Read,则表示缓冲区为空。
我们以 5 个字节的 FIFO 为例,模拟按键检测过程。初始状态:

依次按下并松开 K1, K2 后,FIFO 中记录了四个事件(按下和弹起):

主程序调用 KEY_FIFO_Get() 读取并处理一个事件后,Read 变为 1:

重要提示:如果 FIFO 写满了(Write 追上了 Read),新数据会覆盖旧数据。因此,缓冲区大小需要根据实际应用中按键事件的产生和处理速度来合理设置,通常 10 个字节的缓冲区对一般应用已足够。这种数据结构和内存管理思想是计算机基础中重要的一环。
五、按键 FIFO 的优点
- 可靠性:能够可靠记录每一个按键事件(按下、弹起、长按),不会因为主程序繁忙而遗漏。
- 非阻塞:读取按键事件的函数可以设计为非阻塞形式,无需等待消抖延时,提高了系统响应效率。
- 低资源占用:按键检测逻辑可以放在一个低优先级的定时器中断或任务中周期执行,与主程序逻辑分离,降低了系统耦合度和资源消耗。
六、按键 FIFO 的实现
1. 定义 FIFO 结构体
在 key.h 中定义结构体类型:
typedef struct
{
uint8_t Buf[10]; /* 缓冲区 */
uint8_t Read; /* 缓冲区读指针*/
uint8_t Write; /* 缓冲区写指针 */
}KEY_FIFO_T;
在 key.c 中定义实例:
static KEY_FIFO_T s_tKey;/* 按键FIFO变量,结构体 */
2. 将键值写入 FIFO
/*
**********************************************************
* 函 数 名: KEY_FIFO_Put
* 功能说明: 将1个键值压入按键FIFO缓冲区。可用于模拟一个按键。
* 形 参: _KeyCode : 按键代码
* 返 回 值: 无
**********************************************************
*/
void KEY_FIFO_Put(uint8_t _KeyCode)
{
s_tKey.Buf[s_tKey.Write] = _KeyCode;
if (++s_tKey.Write >= KEY_FIFO_SIZE)
{
s_tKey.Write = 0;
}
}
3. 从 FIFO 读出键值
/*
***********************************************************
* 函 数 名: KEY_FIFO_Get
* 功能说明: 从按键FIFO缓冲区读取一个键值。
* 形 参: 无
* 返 回 值: 按键代码
************************************************************
*/
uint8_t KEY_FIFO_Get(void)
{
uint8_t ret;
if (s_tKey.Read == s_tKey.Write)
{
return KEY_NONE;
}
else
{
ret = s_tKey.Buf[s_tKey.Read];
if (++s_tKey.Read >= KEY_FIFO_SIZE)
{
s_tKey.Read = 0;
}
return ret;
}
}
4. 按键事件枚举与按键参数结构体
为了区分不同的按键事件(按下、弹起、长按),我们使用枚举定义键值:
typedef enum
{
KEY_NONE = 0, /* 0 表示按键事件 */
KEY_1_DOWN, /* 1键按下 */
KEY_1_UP, /* 1键弹起 */
KEY_1_LONG, /* 1键长按 */
KEY_2_DOWN, /* 2键按下 */
KEY_2_UP, /* 2键弹起 */
KEY_2_LONG, /* 2键长按 */
KEY_3_DOWN, /* 3键按下 */
KEY_3_UP, /* 3键弹起 */
KEY_3_LONG, /* 3键长按 */
}KEY_ENUM;
每个按键需要一个结构体来管理其状态、滤波计数器、长按时间等参数,这便是状态机的实现基础:
typedef struct
{
/* 下面是一个函数指针,指向判断按键手否按下的函数 */
uint8_t (*IsKeyDownFunc)(void); /* 按键按下的判断函数,1表示按下 */
uint8_t Count; /* 滤波器计数器 */
uint16_t LongCount; /* 长按计数器 */
uint16_t LongTime; /* 按键按下持续时间, 0表示不检测长按 */
uint8_t State; /* 按键当前状态(按下还是弹起) */
uint8_t RepeatSpeed; /* 连续按键周期 */
uint8_t RepeatCount; /* 连续按键计数器 */
}KEY_T;
static KEY_T s_tBtn[3] = {0}; // 假设有3个按键
5. 按键初始化
初始化函数负责设置 FIFO 指针和每个按键的参数及对应的 GPIO 读取函数。
void KEY_Init(void)
{
KEY_FIFO_Init(); /* 初始化按键变量 */
KEY_GPIO_Config(); /* 初始化按键硬件 */
}
static void KEY_FIFO_Init(void)
{
uint8_t i;
/* 对按键FIFO读写指针清零 */
s_tKey.Read = 0;
s_tKey.Write = 0;
/* 给每个按键结构体成员变量赋一组缺省值 */
for (i = 0; i < HARD_KEY_NUM; i++)
{
s_tBtn[i].LongTime = 100;/* 长按时间 0 表示不检测长按键事件 */
s_tBtn[i].Count = 5/ 2; /* 计数器设置为滤波时间的一半 */
s_tBtn[i].State = 0;/* 按键缺省状态,0为未按下 */
s_tBtn[i].RepeatSpeed = 0;/* 按键连发的速度,0表示不支持连发 */
s_tBtn[i].RepeatCount = 0;/* 连发计数器 */
}
/* 判断按键按下的函数 */
s_tBtn[0].IsKeyDownFunc = IsKey1Down;
s_tBtn[1].IsKeyDownFunc = IsKey2Down;
s_tBtn[2].IsKeyDownFunc = IsKey3Down;
}
/* 按键电平读取函数示例 */
static uint8_t IsKey1Down(void)
{
if (HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET)
return 1;
else
return 0;
}
// ... 其他按键的 IsKey2Down, IsKey3Down 函数类似
6. 核心:按键扫描与状态检测
这是整个框架的核心,它在定时器中断(如每10ms)中被调用,实现按键消抖、状态识别和事件上报。
void KEY_Scan(void)
{
uint8_t i;
for (i = 0; i < HARD_KEY_NUM; i++)
{
KEY_Detect(i);
}
}
static void KEY_Detect(uint8_t i)
{
KEY_T *pBtn;
pBtn = &s_tBtn[i];
if (pBtn->IsKeyDownFunc())
{//这个里面执行的是按键按下的处理
if (pBtn->Count < KEY_FILTER_TIME)
{//按键滤波前给 Count 设置一个初值
pBtn->Count = KEY_FILTER_TIME;
}
else if(pBtn->Count < 2 * KEY_FILTER_TIME)
{//实现 KEY_FILTER_TIME 时间长度的延迟
pBtn->Count++;
}
else
{
if (pBtn->State == 0)
{
pBtn->State = 1;
/* 发送按钮按下的消息 */
KEY_FIFO_Put((uint8_t)(3 * i + 1));
}
if (pBtn->LongTime > 0)
{
if (pBtn->LongCount < pBtn->LongTime)
{
/* 发送按钮持续按下的消息 */
if (++pBtn->LongCount == pBtn->LongTime)
{
/* 键值放入按键FIFO */
KEY_FIFO_Put((uint8_t)(3 * i + 3));
}
}
else
{
if (pBtn->RepeatSpeed > 0)
{
if (++pBtn->RepeatCount >= pBtn->RepeatSpeed)
{
pBtn->RepeatCount = 0;
/* 长按键后,每隔10ms发送1个按键 */
KEY_FIFO_Put((uint8_t)(3 * i + 1));
}
}
}
}
}
}
else
{//这个里面执行的是按键松手的处理或者按键没有按下的处理
if(pBtn->Count > KEY_FILTER_TIME)
{
pBtn->Count = KEY_FILTER_TIME;
}
else if(pBtn->Count != 0)
{
pBtn->Count--;
}
else
{
if (pBtn->State == 1)
{
pBtn->State = 0;
/* 发送按钮弹起的消息 */
KEY_FIFO_Put((uint8_t)(3 * i + 2));
}
}
pBtn->LongCount = 0;
pBtn->RepeatCount = 0;
}
}
逻辑解读:
KEY_FILTER_TIME 为消抖所需的时间单位数(例如5,代表50ms)。
Count 用于实现消抖:当检测到电平变化,Count 会递增或递减,只有稳定达到阈值,才确认状态变化。
State 记录按键的稳定状态(0:弹起,1:按下)。
- 状态从 0 变为 1 时,上报“按下”事件(键值
3*i+1)。
- 在按下状态下,
LongCount 累加,达到 LongTime 阈值时,上报“长按”事件(键值 3*i+3),并可配置连发。
- 状态从 1 变为 0 时,上报“弹起”事件(键值
3*i+2)。
七、在主程序中使用
在主循环中,我们只需非阻塞地读取 FIFO 中的键值并处理即可。
int main(void)
{
uint8_t KeyCode;/* 按键代码 */
KEY_Init();
while (1)
{
/* 按键滤波和检测由后台systick中断服务程序实现,我们只需要调用KEY_FIFO_Get读取键值即可。 */
KeyCode = KEY_FIFO_Get(); /* 读取键值, 无键按下时返回 KEY_NONE = 0 */
if (KeyCode != KEY_NONE)
{
switch (KeyCode)
{
case KEY_1_DOWN: /* K1键按下 */
printf("K1键按下\r\n");
break;
case KEY_1_UP: /* K1键弹起 */
printf("K1键弹起\r\n");
break;
// ... 处理其他按键事件
default:
/* 其它的键值不处理 */
break;
}
}
// 主循环可以执行其他任务,不受按键消抖阻塞
}
}

总结
本文详细介绍了一种基于 FIFO 和状态机的 STM32 按键检测框架。相比传统的简单扫描,它具有事件驱动、非阻塞、功能完善(支持按下、弹起、长按、连发)和资源占用低的优点。理解并运用这种框架,能显著提升嵌入式系统中人机交互模块的可靠性和代码质量。这种将具体硬件操作、状态判断和事件处理分离的思想,在更复杂的开源实战项目中同样具有很高的参考价值。希望这篇深入的分析能帮助你在嵌入式开发的道路上更进一步。完整的示例代码可以通过下方链接获取。
代码仓库地址:
https://gitee.com/zhiguoxin/Wechat-Data.git
本文涉及的状态机、环形缓冲区等编程思想是嵌入式开发的核心基础之一,欢迎在云栈社区交流讨论更多实战经验。