4、GPIO输入与中断:按键输入检测、软件消抖与硬件消抖、中断触发模式
好,咱们今天聊点实战的。GPIO输入,说白了就是让Jetson“听”外界在说什么。按键检测是最常见的场景,但这里头坑不少。我刚开始做嵌入式那会儿,就因为在按键消抖上偷了个懒,结果产品在客户现场疯狂误触发,差点没被售后同事骂死。嗯,咱们今天就把这块彻底讲透。
4.1 按键输入检测基础
按键的本质,就是一个机械开关。按下时导通,松开时断开。但问题在于——机械触点不是理想开关。你按下一次,实际上在几毫秒内会弹跳好几次。这就是所谓的“抖动”。
为什么会这样?因为金属片碰撞时会产生弹性形变。你想想看,拿两个金属片快速碰一下,它们会来回弹几下才稳定。这个时间通常持续5-20ms。如果不处理,你的程序会认为按键被按了十几次。
核心要点:按键检测必须做消抖处理。不做消抖的按键检测,等于没做。
我个人习惯把按键检测分成三个步骤:
- 电平采样——读取GPIO引脚电平
- 消抖处理——滤除机械抖动
- 状态判断——确认有效按下或释放
4.2 软件消抖 vs 硬件消抖
消抖有两种方式,咱们一个一个说。
4.2.1 软件消抖
软件消抖,就是靠代码来滤除抖动。最经典的方法是“延时再确认”。检测到电平变化后,等10-20ms再读一次。如果电平没变,说明是有效动作。
我在项目中遇到过一种情况:用delay()做消抖,结果整个系统响应变得迟钝。因为延时期间CPU被阻塞了。后来我改用状态机+定时器的方式,才解决了这个问题。
这里给一个我常用的软件消抖代码模板:
// 软件消抖 - 状态机方式
#define DEBOUNCE_TIME_MS 20
typedef enum {
STATE_IDLE,
STATE_PRESS_DEBOUNCE,
STATE_PRESSED,
STATE_RELEASE_DEBOUNCE
} button_state_t;
button_state_t button_state = STATE_IDLE;
uint64_t last_tick = 0;
void button_task(uint8_t current_level) {
uint64_t now = get_tick_ms();
switch(button_state) {
case STATE_IDLE:
if (current_level == LOW) { // 检测到按下
button_state = STATE_PRESS_DEBOUNCE;
last_tick = now;
}
break;
case STATE_PRESS_DEBOUNCE:
if ((now - last_tick) >= DEBOUNCE_TIME_MS) {
if (current_level == LOW) {
button_state = STATE_PRESSED;
// 触发按键按下回调
on_button_pressed();
} else {
button_state = STATE_IDLE; // 抖动,复位
}
}
break;
case STATE_PRESSED:
if (current_level == HIGH) { // 检测到释放
button_state = STATE_RELEASE_DEBOUNCE;
last_tick = now;
}
break;
case STATE_RELEASE_DEBOUNCE:
if ((now - last_tick) >= DEBOUNCE_TIME_MS) {
if (current_level == HIGH) {
button_state = STATE_IDLE;
// 触发按键释放回调
on_button_released();
} else {
button_state = STATE_PRESSED; // 抖动,复位
}
}
break;
}
}
我的经验:软件消抖的延时时间不是固定的。质量好的按键5-10ms就够了,便宜的按键可能需要20-30ms。我建议你在调试时用示波器抓一下按键波形,看看实际抖动时间,再设定消抖参数。
4.2.2 硬件消抖
硬件消抖,就是用外围电路来消除抖动。最常用的就是RC低通滤波器加施密特触发器。
电路原理很简单:一个电阻串联,一个电容对地并联。按键按下时,电容通过电阻缓慢充电/放电,把毛刺滤掉。施密特触发器再对信号整形,输出干净的电平。
典型参数:R=10kΩ,C=0.1μF。时间常数τ=RC=1ms,基本能覆盖大多数按键的抖动时间。
注意:硬件消抖不是万能的。如果按键引线太长,或者环境电磁干扰严重,RC滤波可能不够。我曾经在一个电机驱动项目里,因为没考虑电机启停时的强电磁干扰,硬件消抖完全失效。最后还是加了软件消抖做双重保险。
我个人建议:能用硬件消抖就用硬件消抖,但永远不要完全依赖硬件消抖。软件消抖作为兜底,成本几乎为零,何乐而不为?
4.3 中断触发模式
轮询检测按键,说白了就是CPU一直在问“你按了吗?你按了吗?”。效率太低。中断才是正经做法——让按键主动通知CPU。
Jetson的GPIO中断支持三种触发模式:
| 触发模式 | 触发条件 | 典型应用场景 |
|---|---|---|
| 上升沿触发 | 电平从低变高 | 检测按键释放、信号上升 |
| 下降沿触发 | 电平从高变低 | 检测按键按下、信号下降 |
| 双边沿触发 | 电平变化(无论高低) | 需要同时检测按下和释放 |
你可能会问:到底用哪种?我个人习惯是:检测按键按下用下降沿,检测按键释放用上升沿,两个都要检测就用双边沿。
但这里有个坑——如果你只检测下降沿,那按键释放你是不知道的。有些场景需要知道按键按了多久(比如长按、短按区分),那就必须用双边沿。
4.4 中断回调函数编写
中断回调函数,就是中断触发后CPU自动调用的函数。写这个函数有几个铁律:
- 快进快出——中断里不要做耗时操作,比如打印、延时、复杂计算
- 不可重入——中断里不要调用可能被中断打断的函数
- 共享变量加volatile——中断和主循环共享的变量,必须用volatile修饰
下面是一个Jetson上使用GPIO中断的完整示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>
#include <pthread.h>
// GPIO中断回调 - 注意:这个函数在中断上下文执行
// 不要在这里做耗时操作!
void gpio_irq_handler(int gpio, void *user_data) {
// 只做最必要的事:设置标志位
volatile int *flag = (volatile int *)user_data;
*flag = 1;
// 千万不要在这里做printf!
// 千万不要在这里做usleep!
// 千万不要在这里malloc!
}
// 主循环中处理中断标志
int main() {
int gpio_fd = open("/sys/class/gpio/gpio17/value", O_RDONLY);
int irq_flag = 0;
// 注册中断回调
gpio_request_irq(17, GPIO_IRQ_EDGE_FALLING,
gpio_irq_handler, &irq_flag);
while(1) {
if (irq_flag) {
irq_flag = 0; // 清除标志
// 在这里做实际的按键处理
// 比如消抖、状态机更新、业务逻辑
printf("Button pressed!\n");
// 注意:这里还需要做软件消抖
// 因为中断触发时可能还在抖动期
usleep(20000); // 等20ms
// 再读一次电平确认
}
// 做其他事情...
usleep(1000);
}
return 0;
}
关键点:中断回调里只设标志位,实际处理放在主循环里。这是嵌入式开发的黄金法则。我曾经见过一个同事在中断里直接调了printf,结果系统直接死机——因为printf本身可能触发其他中断,导致中断嵌套爆炸。
4.5 避坑指南
最后,分享几个我踩过的坑:
- 中断里做延时——千万别。中断服务程序应该像闪电一样快。延时会让整个系统响应变慢,甚至丢失其他中断。
- 忘记加volatile——中断和主循环共享的变量,编译器可能会优化掉你的读取操作。加上volatile,告诉编译器“这个变量随时可能变”。
- 中断触发后不做消抖——中断只是在电平变化时通知你,但这时候可能还在抖动。你需要在中断处理中再做一次软件消抖。
- 中断回调里调用不可重入函数——比如
malloc、printf、sprintf等。这些函数内部可能使用了全局锁,中断里调用会导致死锁。
嗯,关于GPIO输入和中断,今天就聊这么多。记住一句话:按键检测,消抖是基础,中断是手段,回调是技巧。三者缺一不可。下一节咱们聊聊PWM输出,那个更有意思。