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 延时时间的计算与校准
延时时间怎么算?这个问题其实分两步:
- 理论值:按键数据手册上写的抖动时间,一般是5-20ms。取20ms作为安全值。
- 实际值:你的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 知识体系流程图
下面这张图,是我画的基础延时消抖法的完整流程。从按键按下到最终输出稳定状态,每一步都清清楚楚。
这张图把整个流程串起来了。你注意看那个判断菱形——两次采样不一致怎么办?我的做法是直接忽略,返回一个无效值,让上层函数去决定是重试还是保持上次状态。有些工程师喜欢在这里加个循环,一直读到两次一致为止。我个人不太推荐,万一按键坏了,程序就卡死了。
小技巧:
如果你用的是Zynq或者MicroBlaze,Vitis里其实有现成的 sleep() 函数。但我不建议在裸机程序里用它,因为 sleep() 依赖系统时钟中断,万一中断没配好,延时就不准了。自己写个空循环,心里踏实。
好了,基础延时消抖法就讲到这里。代码有了,原理清楚了,时间也会算了。下一节我们会聊更高级的「状态机消抖法」,那才是真正适合工业级应用的做法。