4、深入GPIO:按键输入与中断

按键,是嵌入式世界里最朴素的人机交互方式。你按下它,系统响应——听起来简单,但实际做起来,坑不少。我刚开始用Rust写STM32的时候,就栽在按键消抖上。今天咱们把这部分彻底理清楚。

4.1 按键消抖原理

机械按键的本质是什么?是金属弹片的物理接触。按下和释放的瞬间,弹片会反弹,产生一连串的毛刺信号。这就是抖动。

抖动的典型特征:

  • 持续时间:5~20ms
  • 频率:不固定,随机性高
  • 后果:一次按下被误判为多次

消抖的思路无非两种:硬件消抖和软件消抖。硬件上,加个RC低通滤波器,把毛刺滤掉。软件上,更灵活——检测到电平变化后,延时10~20ms再读一次。如果电平稳定,才算有效。

我的经验: 软件消抖用延时函数是最直接的办法。但注意,在中断里别用阻塞延时,否则整个系统都卡住了。我一般用状态机+定时器轮询,或者用RTOS的延时。

4.2 配置GPIO为输入模式

在STM32的HAL里,配置GPIO输入很简单。但在Rust的embedded-hal抽象下,我们需要用更类型安全的方式来做。

以STM32F4为例,用stm32f4xx-hal库配置PA0为输入:

use stm32f4xx_hal::{
    gpio::GpioExt,
    gpio::gpioa::Parts,
    gpio::Input,
    gpio::PullUp,
    pac,
};

let dp = pac::Peripherals::take().unwrap();
let gpioa = dp.GPIOA.split();
let button = gpioa.pa0.into_pull_up_input();

这里的关键点:

  • into_pull_up_input() 启用内部上拉电阻,默认高电平
  • 按键另一端接地,按下时引脚被拉低
  • 类型系统保证了引脚模式在编译期确定,不会出现运行时配置错误
配置项 说明 常用值
引脚模式 输入/输出/复用 Input
上拉/下拉 内部电阻 PullUp / PullDown
触发方式 中断触发边沿 上升沿/下降沿/双边沿

注意: 如果外部电路已经接了上拉电阻,就别再启用内部上拉。两个上拉并联会改变分压,导致电平判断不准。我曾经因为这个排查了半天。

4.3 外部中断(EXTI)配置

轮询按键太浪费CPU了。更好的做法是用外部中断——按键按下时,CPU暂停当前任务,跳转到中断服务函数处理。

EXTI的配置流程:

  1. 选择GPIO引脚连接到EXTI线
  2. 配置触发边沿(上升沿、下降沿或双边沿)
  3. 使能中断线
  4. 编写中断服务函数

在Rust中,用stm32f4xx-hal配置EXTI:

use stm32f4xx_hal::{
    interrupt,
    gpio::ExtiPin,
    pac::{self, EXTI},
};

// 配置PA0为外部中断,下降沿触发
let mut button = gpioa.pa0.into_pull_up_input();
button.make_interrupt_source(&mut dp.SYSCFG);
button.trigger_on_edge(&mut dp.EXTI, Edge::Falling);
button.enable_interrupt(&mut dp.EXTI);

这里trigger_on_edge指定了触发边沿。按键按下时,电平从高变低,所以用下降沿。如果按键接法相反(按下接高电平),就用上升沿。

EXTI线映射规则:

  • PA0、PB0、PC0... 都映射到EXTI0线
  • PA1、PB1、PC1... 映射到EXTI1线
  • 同一时刻,一条EXTI线只能连接一个引脚

4.4 在Rust中处理中断回调

Rust的中断处理跟C不太一样。C里你直接写中断函数名,Rust里需要用#[interrupt]属性宏来声明。

基本结构:

use core::cell::RefCell;
use cortex_m::interrupt::Mutex;

static BUTTON_PRESSED: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));

#[interrupt]
fn EXTI0() {
    // 清除中断标志
    // 设置按键状态
    cortex_m::interrupt::free(|cs| {
        *BUTTON_PRESSED.borrow(cs).borrow_mut() = true;
    });
}

这里有几个要点:

  • Mutex + RefCell 是Rust在中断中共享数据的标准做法
  • cortex_m::interrupt::free 保证临界区安全
  • 中断函数名必须与中断向量表中的名称一致(如EXTI0)

我的习惯: 中断里只做最轻量的事——设置标志位、清除中断标志。真正的业务逻辑放到主循环里处理。这样中断响应快,也不容易出问题。

完整的按键中断示例:

use stm32f4xx_hal::{
    gpio::{ExtiPin, GpioExt, Input, PullUp},
    pac,
    prelude::*,
};

fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let cp = cortex_m::Peripherals::take().unwrap();
    let gpioa = dp.GPIOA.split();
    let mut button = gpioa.pa0.into_pull_up_input();

    button.make_interrupt_source(&mut dp.SYSCFG);
    button.trigger_on_edge(&mut dp.EXTI, Edge::Falling);
    button.enable_interrupt(&mut dp.EXTI);

    // 使能全局中断
    unsafe { cortex_m::interrupt::enable(); }

    loop {
        // 主循环处理按键事件
        cortex_m::interrupt::free(|cs| {
            if *BUTTON_PRESSED.borrow(cs).borrow() {
                // 处理按键逻辑
                // ...
                *BUTTON_PRESSED.borrow(cs).borrow_mut() = false;
            }
        });
    }
}

嗯,这里要注意一点:unsafe块是因为使能全局中断在Rust里被认为是不安全操作。但实际用起来,只要确保中断服务函数正确,就没问题。

4.5 本章知识体系

下面这张图把按键输入与中断的完整流程串起来了:

按键输入与中断流程 按键输入与中断知识体系 机械按键 物理抖动 5~20ms 消抖处理 硬件RC / 软件延时 GPIO输入配置 上拉/下拉/模式 EXTI配置 边沿触发/使能 中断回调 设置标志/清除中断 主循环处理 读取标志/执行业务 关键要点 • 消抖是必须的,不消抖的按键程序在工业现场会出各种奇怪问题 • EXTI线是共享资源,同一线只能连一个引脚 • 中断回调要短小精悍,复杂逻辑放主循环 • Rust的Mutex+RefCell是中断安全共享数据的标准模式

总结一下: 按键输入看似简单,但消抖、配置、中断处理每个环节都有细节。用Rust写的好处是,类型系统帮你挡住了很多低级错误。比如引脚模式配置错了,编译期就报错,不会等到运行时才发现。