3、基础延时消抖法:基于延时循环的消抖原理、C语言实现、延时时间计算

按键消抖,说白了就是跟「抖动」这个物理现象斗智斗勇。你按下按键那一瞬间,金属触点会弹跳好几次,就像乒乓球落地一样。FPGA跑得飞快,几十纳秒就能采样一次,结果就是一次按键被识别成几十次。

我刚开始做嵌入式那会儿,就吃过这个亏。一个简单的按键控制LED,按一下灯亮,再按一下灯灭。结果呢?按下去灯狂闪。当时我还以为是FPGA坏了,折腾了半天才发现是没做消抖。嗯,从那以后,消抖就成了我代码里的标配。

3.1 延时消抖的核心思想

延时消抖的原理特别简单——「等一等,再确认」。你想想看,按键按下后,抖动一般持续5-20ms。只要我检测到按键电平变化,先不急着响应,等个20ms再去读一次。如果电平还是那个状态,说明按键真的按下了。如果是抖动,20ms后电平早就回去了。

说白了,就是用时间换可靠性。

核心要点:

  • 检测到电平变化 → 启动延时
  • 延时期间不采样 → 跳过抖动期
  • 延时结束后再次采样 → 确认状态

我在项目中遇到过一种情况:有些机械按键老化后,抖动时间能到30ms以上。所以我的习惯是,延时时间取20ms,但代码里留个可配置的宏定义,方便调试时调整。

3.2 延时循环的实现方式

在Vitis环境下做嵌入式开发,延时最直接的方式就是「空循环」。让CPU在那傻等,啥也不干,耗够时间再回来。

为什么用空循环?因为简单、可控、不依赖硬件定时器。对于按键消抖这种低频操作,CPU浪费几个毫秒完全无所谓。

来看代码:

#include "xparameters.h"
#include "xgpio.h"
#include "xil_printf.h"

// 按键相关宏定义
#define KEY_GPIO_DEVICE_ID  XPAR_AXI_GPIO_0_DEVICE_ID
#define KEY_CHANNEL         1
#define KEY_MASK            0x01        // 只使用最低位

// 延时时间配置(毫秒)
#define DEBOUNCE_TIME_MS    20

XGpio key_inst;

// 简易毫秒级延时函数
void delay_ms(int ms) {
    volatile int i, j;
    // 这个循环次数需要根据实际CPU频率校准
    // 我习惯在调试时用示波器测一下,确保精度
    for (i = 0; i < ms; i++) {
        for (j = 0; j < 10000; j++) {
            // 空循环,啥也不干
            __asm__("nop");
        }
    }
}

// 按键初始化
int key_init() {
    int status;
    status = XGpio_Initialize(&key_inst, KEY_GPIO_DEVICE_ID);
    if (status != XST_SUCCESS) {
        xil_printf("按键初始化失败!\r\n");
        return XST_FAILURE;
    }
    XGpio_SetDataDirection(&key_inst, KEY_CHANNEL, KEY_MASK);
    return XST_SUCCESS;
}

// 读取按键状态(带消抖)
u8 key_read_debounced() {
    u8 first_sample, second_sample;

    // 第一次采样
    first_sample = XGpio_DiscreteRead(&key_inst, KEY_CHANNEL) & KEY_MASK;

    // 延时,跳过抖动期
    delay_ms(DEBOUNCE_TIME_MS);

    // 第二次采样
    second_sample = XGpio_DiscreteRead(&key_inst, KEY_CHANNEL) & KEY_MASK;

    // 两次采样一致,说明按键状态稳定
    if (first_sample == second_sample) {
        return first_sample;
    } else {
        // 不一致?可能是干扰,返回上一次的有效值
        // 我一般在这里加个全局变量记录上次状态
        return 0xFF;  // 返回无效值,由上层处理
    }
}

个人经验:

代码里的 __asm__("nop") 是我故意加的。有些编译器优化太猛,会把空循环直接优化掉。加个nop指令,告诉编译器「这里真的有事干,别给我删了」。

3.3 延时时间的计算与校准

延时时间怎么算?这个问题其实分两步:

  1. 理论值:按键数据手册上写的抖动时间,一般是5-20ms。取20ms作为安全值。
  2. 实际值:你的CPU跑多快?循环一次要几个时钟周期?这些都得算清楚。

我举个例子。假设你的FPGA软核CPU跑在100MHz,一个时钟周期就是10ns。内层循环的 j < 10000,每次循环大概需要3-4个指令周期(判断、跳转、nop)。算下来:

参数 数值
CPU频率 100 MHz
时钟周期 10 ns
内层循环次数 10000
每次循环周期数 ~4 周期
内层循环耗时 10000 × 4 × 10ns = 0.4ms
外层循环次数 50次(20ms / 0.4ms)

所以,如果你想要20ms延时,外层循环跑50次就够了。但注意,这只是理论值。实际还要考虑函数调用开销、中断响应等。

避坑指南:

我曾经在Zynq上调试时,发现延时时间比预期长了整整一倍。查了半天,原来是编译器开了优化,把循环展开成了更高效的指令。后来我改用 volatile 关键字,强制编译器每次都要真的去读写变量,才把时间校准回来。

校准方法其实很简单:用示波器测一个GPIO引脚,在延时函数前后分别拉高拉低,看波形宽度。或者用Vitis里的定时器,在延时前后读计数器值。我个人习惯用后者,省得接示波器。

3.4 延时消抖的优缺点

任何方法都有两面性。延时消抖虽然简单,但也不是万能的。

  • 优点:
    • 代码量少,逻辑清晰,新手也能看懂
    • 不依赖硬件资源,纯软件实现
    • 移植性好,换个平台改个循环次数就行
  • 缺点:
    • CPU被占死,延时期间啥也干不了
    • 不适合多按键场景,每个按键都延时20ms,系统响应就慢了
    • 延时时间受CPU频率影响,换芯片要重新校准

你想想看,如果你的系统里只有一两个按键,而且对实时性要求不高,延时消抖完全够用。但如果是键盘矩阵或者工业控制面板,那就要考虑更高级的方法了。

3.5 知识体系流程图

下面这张图,是我画的基础延时消抖法的完整流程。从按键按下到最终输出稳定状态,每一步都清清楚楚。

基础延时消抖法流程图 检测到按键动作 读取当前电平(采样1) 延时 20ms 再次读取电平(采样2) 采样1 == 采样2? 是 输出按键状态 否 返回无效值/忽略 结束

这张图把整个流程串起来了。你注意看那个判断菱形——两次采样不一致怎么办?我的做法是直接忽略,返回一个无效值,让上层函数去决定是重试还是保持上次状态。有些工程师喜欢在这里加个循环,一直读到两次一致为止。我个人不太推荐,万一按键坏了,程序就卡死了。

小技巧:

如果你用的是Zynq或者MicroBlaze,Vitis里其实有现成的 sleep() 函数。但我不建议在裸机程序里用它,因为 sleep() 依赖系统时钟中断,万一中断没配好,延时就不准了。自己写个空循环,心里踏实。

好了,基础延时消抖法就讲到这里。代码有了,原理清楚了,时间也会算了。下一节我们会聊更高级的「状态机消抖法」,那才是真正适合工业级应用的做法。

可可精品资料库