按键消抖与环形缓冲区保姆级教程
这篇文章详细讲解了嵌入式开发中按键处理的两种关键技术:定时器消抖和环形缓冲区。作者通过生动的比喻和代码拆解,帮助读者理解按键抖动问题(金属弹片导致电平跳变)的解决方案——延迟确认机制,以及如何用环形缓冲区防止快速按键导致的数据丢失。文中提供了完整的代码实现,包括软定时器结构体、环形缓冲区的读写操作,并详细说明了在Keil5中的工程配置步骤。文章特别适合嵌入式初学者,通过结合理论讲解和实战演示,让读
前言
学习嵌入式按键处理时,代码里的定时器消抖、环形缓冲区逻辑,初看像 “迷宫”,我啃了好久才摸透门道。这些内容对新手有难度,但每个细节都藏着嵌入式开发的关键思维—— 从定时器延迟确认按键,到环形缓冲区用读写指针 “绕圈” 存数据,一步没懂就容易卡壳。
希望你读教程时,逐行抠逻辑、遇疑问别跳过,把 “按键咋消抖”“数据咋不丢” 这些问题吃透。嵌入式开发的魅力,就在于拆解复杂代码,挖出简洁精妙的设计。静下心,啃透这些基础,后续进阶会顺很多,咱们一起从这 “难啃” 的按键处理开始,推开嵌入式的门!
一、为啥要学这些?
想象你做了个按键控制的小灯项目:按下按键,灯却疯狂闪烁(抖动误判);快速按多次,只响应了一次(数据丢失)。这两个问题,按键消抖和环形缓冲区就是 “解药”。接下来从 0 开始,用最白话的方式讲透,确保纯小白也能懂!
二、第一节:用定时器消除按键抖动(解决 “按键乱跳” 问题)
1. 先理解 “按键抖动” 多讨厌

- 理想情况:按下按键 → 电平变低 → 松开变高,干干净净一次触发(像图里的 “理想情况”)。
- 实际情况:按下时,金属弹片会 “颤一下”,电平疯狂跳变(图里的①②③④),几毫秒内触发多次中断,程序会以为 “按了好几次”。
这就像你按一下开关,灯却闪了好几下 —— 体验极差,必须解决!
2. 解决思路:“延迟确认”(对应定时器消抖的图)

核心逻辑:第一次检测到按键时,不着急说 “按了”,而是等一小会儿(比如 10ms)。如果这 10ms 内电平又变了(抖动),就重新等;要是 10ms 后电平稳定,才确认是真的按了。
类比:你听到门铃响,先不着急开门,等 10 秒(防止恶作剧按一下就跑),如果 10 秒后门铃还在响,才确认有人。
3. 代码逐行拆解(把每个变量、函数当 “道具” 讲)
// 定义“软定时器”结构体:就像一个“智能闹钟”
struct soft_timer {
uint32_t timeout; // 闹钟响的时间(记录“未来什么时候超时”)
void *args; // 闹钟响了要传的参数(暂时用不到,先留着)
void (*func)(void *); // 闹钟响了要执行的函数(比如“记录按键”)
};
// 全局变量:按键的“智能闹钟”、按键计数(记录真的按了多少次)
struct soft_timer key_timer = {~0, NULL, key_timeout_func};
// ~0 是让timeout初始化为很大的数(相当于“闹钟还没设”)
int key_cut = 0;
// 闹钟响了要做的事:真正确认按键有效!
void key_timeout_func(void *args) {
key_cut++; // 按键计数+1(终于确定是真的按了!)
key_timer.timeout = ~0; // 把闹钟关掉(重置为“没设闹钟”)
}
// 修改闹钟时间:每次检测到按键,重新设闹钟
void mod_timer(struct soft_timer *pTimer, uint32_t timeout) {
// HAL_GetTick() 是系统跑了多少毫秒,加上timeout就是“多久后响”
pTimer->timeout = HAL_GetTick() + timeout;
}
// 主循环里检查闹钟:看时间到了没
void cherk_timer(void) {
// 如果现在时间 >= 闹钟时间,说明该执行任务了!
if (key_timer.timeout <= HAL_GetTick()) {
key_timer.func(key_timer.args); // 执行“记录按键”的操作
}
}
// 按键中断回调:检测到按键引脚变化时触发(相当于“听到门铃响”)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == GPIO_PIN_14) { // 假设按键接在这个引脚
mod_timer(&key_timer, 10); // 设闹钟:10ms后响(延迟确认)
}
}
// 主函数:初始化 + 循环
int main(void) {
HAL_Init(); // 初始化HAL库
SystemClock_Config();// 配置系统时钟
MX_GPIO_Init(); // 初始化按键引脚
OLED_Init(); // 初始化OLED屏幕(用来显示按键次数)
OLED_Clear(); // 清屏
while (1) {
cherk_timer(); // 不停检查“闹钟”响了没
// 在屏幕显示按键次数(key_cut)
OLED_PrintSignedVal(0, 6, key_cut);
}
}
人话解释每个部分:
struct soft_timer:定义一个 “智能闹钟”,存了 “什么时候响”“响了要做什么”。key_timeout_func:闹钟响了,就执行 “按键计数 + 1”(确认是真按键)。mod_timer:每次按键触发(门铃响),就重新设闹钟(延迟 10ms)。cherk_timer:主循环里不停看 “现在时间”,到了闹钟时间就执行key_timeout_func。
三、第二节:环形缓冲区原理(解决 “按键数据丢失” 问题)
1. 核心问题:按键太快会丢数据!
假设你疯狂按按键,1 秒按 10 次,但主循环 1 秒只能处理 5 次 —— 剩下 5 次就会被覆盖(丢数据)。这时候需要一个 “临时仓库” 存按键值,主循环慢慢取,这就是环形缓冲区。
2. 环形缓冲区原理(用 “跑道” 比喻,对应手绘图)

把数组想象成一个环形跑道,有两个 “跑步的人”:
-
写指针
w:负责 “存数据”,跑一圈后回到起点(像把数据放到跑道的某个位置)。 -
读指针
r:负责 “取数据”,也跑一圈(从跑道拿数据)。 -
空:
r和w碰到一起(没数据了)。 -
满:
w快追上r了(没空间存新数据了)。
类比:跑道上有 10 个格子,w 放数据,r 取数据,绕圈跑,永远不挤在一起(除非满了)。
3. 代码逐行拆解(把环形缓冲区当 “仓库” 讲)
circle_buffer.h(头文件:定义仓库规则)
#ifndef _CIRCLE_BUF_H
#define _CIRCLE_BUF_H
#include <stdint.h> // 引入标准整数类型(比如uint8_t是1字节)
// 定义“环形缓冲区”结构体:存读写指针、仓库大小、数据数组
typedef struct circle_buf {
uint32_t r; // 读指针:下一个要取数据的位置
uint32_t w; // 写指针:下一个要存数据的位置
uint32_t len; // 仓库总长度(有多少个格子)
uint8_t *buf; // 实际存数据的数组(仓库的格子)
} circle_buf, *p_circle_buf;
// 初始化仓库:告诉程序“仓库多大、用哪个数组存”
void circld_buf_init(p_circle_buf pCircleBuf, uint32_t len, uint8_t *buf);
// 从仓库取数据:成功返回0,空返回-1
int circle_buf_read(p_circle_buf pCircleBuf, uint8_t *pval);
// 往仓库存数据:成功返回0,满返回-1
int cirble_buf_write(p_circle_buf pCircleBuf, uint8_t val);
#endif
circle_buffer.c(实现文件:仓库的具体操作)
#include <stdint.h>
#include "circle_buffer.h"
// 初始化仓库:把读写指针复位,记录仓库大小和数组地址
void circld_buf_init(p_circle_buf pCircleBuf, uint32_t len, uint8_t *buf) {
pCircleBuf->r = pCircleBuf->w = 0; // 读写指针都从第一个格子开始
pCircleBuf->len = len; // 记录仓库有多少格子
pCircleBuf->buf = buf; // 记录数组地址(仓库的位置)
}
// 从仓库取数据:把r位置的数据给pval,然后r后移
int circle_buf_read(p_circle_buf pCircleBuf, uint8_t *pval) {
// r和w不一样,说明仓库里有数据
if (pCircleBuf->r != pCircleBuf->w) {
*pval = pCircleBuf->buf[pCircleBuf->r]; // 把数据放到pval里
pCircleBuf->r++; // 读指针后移一格
// 如果到仓库末尾,绕回开头(环形的关键!)
if (pCircleBuf->r == pCircleBuf->len) {
pCircleBuf->r = 0;
}
return 0; // 成功取数据
} else {
return -1; // 仓库空,取不了
}
}
// 往仓库存数据:把val放到w位置,然后w后移
int cirble_buf_write(p_circle_buf pCircleBuf, uint8_t val) {
uint32_t newt_w = pCircleBuf->w + 1; // 计算下一个存数据的位置
// 如果到仓库末尾,绕回开头
if (newt_w == pCircleBuf->len) {
newt_w = 0;
}
// newt_w和r不一样,说明仓库没满(满了的话newt_w会等于r)
if (newt_w != pCircleBuf->r) {
pCircleBuf->buf[pCircleBuf->w] = val; // 把数据存入仓库
pCircleBuf->w = newt_w; // 写指针后移一格
return 0; // 成功存数据
} else {
return -1; // 仓库满,存不了
}
}
人话解释每个部分:
circld_buf_init:初始化仓库,告诉程序 “用哪个数组、多大”,读写指针从 0 开始。circle_buf_read:从r位置取数据,取完r后移,到末尾就绕回开头。cirble_buf_write:把数据放到w位置,放完w后移,到末尾也绕回开头;如果w追上r,说明仓库满了。
四、第三节:用环形缓冲区存按键(实战,结合前两节)
1. 需求:按键太快也不丢数据!
在第一节 “定时器消抖” 的基础上,把确认后的按键值存到环形缓冲区,主循环慢慢取,这样就算疯狂按按键,数据也不会丢。
2. 代码修改点:新增环形缓冲区逻辑
#include "main.h"
#include "i2c.h"
#include "gpio.h"
#include "driver_oled.h"
#include "circle_buffer.h"
/**
* 软件定时器结构体:实现非阻塞延时和回调机制
* timeout: 超时时间戳(毫秒)
* args: 回调函数参数
* func: 超时回调函数指针
*/
struct soft_timer
{
uint32_t timeout; // 超时时间戳(单位:毫秒)
void * args; // 回调函数的参数(通用指针,可指向任意类型数据)
void (*func)(void *); // 超时回调函数指针(参数为通用指针,返回值为void)
};
// 函数声明
void key_timeout_func(void *args);
void mod_timer(struct soft_timer *pTimer, uint32_t timeout);
void cherk_timer(void);
void SystemClock_Config(void);
// 全局变量:按键计数(记录有效按键次数)
int key_cut = 0;
// 按键处理相关变量
struct soft_timer key_timer = {~0, NULL, key_timeout_func}; // 按键消抖定时器(初始未启用)
static uint8_t g_data_buf[100]; // 环形缓冲区数据存储区
static circle_buf g_key_bufs; // 环形缓冲区控制结构体
/**
* @brief 按键超时回调函数 - 消抖完成后确认按键状态
* @param args: 回调函数参数(此处未使用)
* @retval None
*/
void key_timeout_func(void *args)
{
uint8_t key_val;
key_cut++; // 有效按键计数+1
key_timer.timeout = ~0; // 重置定时器(标记为未启用)
// 读取按键状态并编码
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) == GPIO_PIN_RESET)
key_val = 0x1; // 按下状态(低电平)
else
key_val = 0x81; // 松开状态(高电平)
// 将按键状态写入环形缓冲区
cirble_buf_write(&g_key_bufs, key_val);
}
/**
* @brief 设置定时器超时时间
* @param pTimer: 定时器结构体指针
* @param timeout: 超时时间(毫秒)
*/
void mod_timer(struct soft_timer *pTimer, uint32_t timeout)
{
pTimer->timeout = HAL_GetTick() + timeout; // 计算未来超时时间点
}
/**
* @brief 检查定时器是否超时
*/
void cherk_timer(void)
{
if (key_timer.timeout <= HAL_GetTick()) // 当前时间 >= 超时时间
{
key_timer.func(key_timer.args); // 执行超时回调函数
}
}
/**
* @brief 外部中断回调函数 - 按键触发外部中断时调用
* @param GPIO_Pin: 触发中断的引脚
*/
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_14) // 确认是按键引脚触发的中断
{
mod_timer(&key_timer, 10); // 重置消抖定时器(10ms)
}
}
/**
* @brief 系统时钟配置函数(由CubeMX自动生成)
*/
void SystemClock_Config(void);
/**
* @brief 主函数 - 程序入口点
* @retval int 理论上返回程序退出状态(嵌入式系统通常不会返回)
*/
int main(void)
{
int len; // 用于记录OLED显示位置
// 系统初始化
HAL_Init(); // 初始化HAL库
SystemClock_Config(); // 配置系统时钟
// 外设初始化
circld_buf_init(&g_key_bufs, 100, g_data_buf); // 初始化环形缓冲区
MX_GPIO_Init(); // 初始化GPIO(包括按键引脚配置)
MX_I2C1_Init(); // 初始化I2C1(用于OLED通信)
// OLED初始化
OLED_Init(); // 初始化OLED显示屏
OLED_Clear(); // 清屏OLED
// 显示初始内容
OLED_PrintString(0, 0, "cnt : "); // 显示计数标签
len = OLED_PrintString(0, 2, "key val : "); // 显示按键值标签,并记录字符串长度
// 主循环
while (1)
{
cherk_timer(); // 检查定时器是否超时(处理按键消抖)
// 更新OLED显示
OLED_PrintSignedVal(len, 0, key_cut); // 显示当前按键计数
// 从环形缓冲区读取按键值并显示
uint8_t key_val = 0;
if (circle_buf_read(&g_key_bufs, &key_val) == 0) // 读取成功
{
OLED_ClearLine(len, 2); // 清除原有显示
OLED_PrintHex(len, 2, key_val, 1); // 显示新的按键值
}
// 其他主循环任务(可在此添加)
}
}
3. 流程总结(像流水线一样)
- 消抖确认:按键中断 → 定时器延时 → 确认有效按键(第一节逻辑)。
- 存入仓库:确认后,调用
cirble_buf_write把按键值存到环形缓冲区(防止丢)。 - 主循环处理:定期调用
circle_buf_read取数据,慢慢显示 / 处理(比如控制 LED)。
五、文件操作:Keil5 里咋新建文件?(纯小白步骤)
1. 复制工程
找到第一节的代码(比如0603_key_timer),复制一份,改名为0604_key_circle_buffer(对应文档里的 “修改得来”)。
2. 新建 Lib 文件夹
在工程里新建一个文件夹叫Lib(对应图里的文件结构),用来放环形缓冲区的代码。

3. 新建 circle_buffer.c 和 circle_buffer.h

- 在
Lib文件夹里,新建两个 “文本文档”,分别改名circle_buffer.c和circle_buffer.h。 - 注意:Windows 默认会加
.txt后缀,要去Lib文件夹里看(如图),把.txt删掉!(否则 Keil5 不认)
4. 加到 Keil5 工程
- 打开 Keil5 → 点击
Manage Project Items→ 新建一个分组Lib→ 把circle_buffer.c加到这个分组里 → 保存。 - 然后在
Options for Target→C/C++→Include Paths里,添加Lib文件夹的路径(让编译器能找到circle_buffer.h)。
六、常见问题解答(初学者踩坑点)
-
为啥用
~0重置定时器?~0是全 1(比如 32 位就是0xFFFFFFFF),比HAL_GetTick()大很多,相当于 “让定时器永远不超时”,表示 “现在没在计时”。 -
环形缓冲区满了咋办?
可以在写数据时判断返回值(cirble_buf_write返回 - 1 就是满了),然后做处理:比如丢弃旧数据、报错提示。 -
按键值存
0x1和0x81有啥用?
这是自定义的标记,0x1代表 “按下”,0x81代表 “松开”(也可以存按键编号、时间戳,看需求)。
七、总结:从原理到实战的完整链路
- 按键抖动:用定时器延迟确认(等抖动消失),解决 “误判多次”。
- 数据丢失:用环形缓冲区暂存数据(像仓库一样存起来),解决 “处理不过来”。
- 工程实践:在 Keil5 里新建文件、改后缀、加工程,把知识落地。
现在再看老师的图,是不是能对应上了?按键抖动的图对应第一节,环形缓冲区的图对应第二节,文件结构的图对应工程操作。
更多推荐
所有评论(0)