4、GPIO输入与中断:按键输入检测、软件消抖与硬件消抖、中断触发模式

好,咱们今天聊点实战的。GPIO输入,说白了就是让Jetson“听”外界在说什么。按键检测是最常见的场景,但这里头坑不少。我刚开始做嵌入式那会儿,就因为在按键消抖上偷了个懒,结果产品在客户现场疯狂误触发,差点没被售后同事骂死。嗯,咱们今天就把这块彻底讲透。

4.1 按键输入检测基础

按键的本质,就是一个机械开关。按下时导通,松开时断开。但问题在于——机械触点不是理想开关。你按下一次,实际上在几毫秒内会弹跳好几次。这就是所谓的“抖动”。

为什么会这样?因为金属片碰撞时会产生弹性形变。你想想看,拿两个金属片快速碰一下,它们会来回弹几下才稳定。这个时间通常持续5-20ms。如果不处理,你的程序会认为按键被按了十几次。

核心要点:按键检测必须做消抖处理。不做消抖的按键检测,等于没做。

我个人习惯把按键检测分成三个步骤:

  1. 电平采样——读取GPIO引脚电平
  2. 消抖处理——滤除机械抖动
  3. 状态判断——确认有效按下或释放

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自动调用的函数。写这个函数有几个铁律:

  1. 快进快出——中断里不要做耗时操作,比如打印、延时、复杂计算
  2. 不可重入——中断里不要调用可能被中断打断的函数
  3. 共享变量加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,告诉编译器“这个变量随时可能变”。
  • 中断触发后不做消抖——中断只是在电平变化时通知你,但这时候可能还在抖动。你需要在中断处理中再做一次软件消抖。
  • 中断回调里调用不可重入函数——比如mallocprintfsprintf等。这些函数内部可能使用了全局锁,中断里调用会导致死锁。

嗯,关于GPIO输入和中断,今天就聊这么多。记住一句话:按键检测,消抖是基础,中断是手段,回调是技巧。三者缺一不可。下一节咱们聊聊PWM输出,那个更有意思。