前言

学习嵌入式按键处理时,代码里的定时器消抖、环形缓冲区逻辑,初看像 “迷宫”,我啃了好久才摸透门道。这些内容对新手有难度,但每个细节都藏着嵌入式开发的关键思维—— 从定时器延迟确认按键,到环形缓冲区用读写指针 “绕圈” 存数据,一步没懂就容易卡壳。

希望你读教程时,逐行抠逻辑、遇疑问别跳过,把 “按键咋消抖”“数据咋不丢” 这些问题吃透。嵌入式开发的魅力,就在于拆解复杂代码,挖出简洁精妙的设计。静下心,啃透这些基础,后续进阶会顺很多,咱们一起从这 “难啃” 的按键处理开始,推开嵌入式的门!

一、为啥要学这些?

想象你做了个按键控制的小灯项目:按下按键,灯却疯狂闪烁(抖动误判);快速按多次,只响应了一次(数据丢失)。这两个问题,按键消抖环形缓冲区就是 “解药”。接下来从 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. 流程总结(像流水线一样)

  1. 消抖确认:按键中断 → 定时器延时 → 确认有效按键(第一节逻辑)。
  2. 存入仓库:确认后,调用 cirble_buf_write 把按键值存到环形缓冲区(防止丢)。
  3. 主循环处理:定期调用 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.ccircle_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)。

六、常见问题解答(初学者踩坑点)

  1. 为啥用~0重置定时器?
    ~0是全 1(比如 32 位就是0xFFFFFFFF),比HAL_GetTick()大很多,相当于 “让定时器永远不超时”,表示 “现在没在计时”。

  2. 环形缓冲区满了咋办?
    可以在写数据时判断返回值(cirble_buf_write返回 - 1 就是满了),然后做处理:比如丢弃旧数据、报错提示。

  3. 按键值存0x10x81有啥用?
    这是自定义的标记,0x1代表 “按下”,0x81代表 “松开”(也可以存按键编号、时间戳,看需求)。

七、总结:从原理到实战的完整链路

  1. 按键抖动:用定时器延迟确认(等抖动消失),解决 “误判多次”。
  2. 数据丢失:用环形缓冲区暂存数据(像仓库一样存起来),解决 “处理不过来”。
  3. 工程实践:在 Keil5 里新建文件、改后缀、加工程,把知识落地。

现在再看老师的图,是不是能对应上了?按键抖动的图对应第一节,环形缓冲区的图对应第二节,文件结构的图对应工程操作。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐