4. 内存映射I/O与寄存器操作

外部中断,说白了就是让芯片对外界的“动静”做出即时反应。按键按下、传感器跳变、电平翻转——这些信号来了,CPU 不用轮询,直接停下当前任务去处理。我刚开始做嵌入式时,总觉得轮询也能用,直到有一次项目里要同时响应 4 个按键和 2 个传感器,轮询把主循环拖得跟蜗牛一样。从那以后,外部中断就成了我的标配。

这一章,咱们就围绕 STM32 的 EXTI 控制器,把原理、配置、编码、清标志、防抖这些事一次讲透。Rust 代码会贯穿始终,但核心思路对任何 MCU 都通用。

4.1 EXTI 控制器原理

EXTI 全称 External Interrupt/Event Controller。它负责把 GPIO 引脚上的电平变化,映射到 NVIC(嵌套向量中断控制器)的中断通道上。

它的工作流程其实很简单:

  1. 信号输入:GPIO 引脚的电平进入 EXTI 模块。
  2. 边沿检测:EXTI 内部有上升沿触发寄存器和下降沿触发寄存器,你可以单独使能。
  3. 软件中断:还有一个软件中断事件寄存器,可以手动触发中断——调试时很有用。
  4. 中断挂起:一旦检测到有效边沿,对应的挂起寄存器位被置 1。
  5. 中断屏蔽:如果该中断没有被屏蔽,信号就送到 NVIC。
  6. NVIC 响应:NVIC 根据优先级决定是否立即响应。

关键点:EXTI 本身不处理优先级,它只负责“有没有事件发生”。优先级是 NVIC 的事。

下面这张图把 EXTI 的内部结构画清楚了:

EXTI 控制器内部结构 GPIO 引脚 边沿检测 上升/下降/双边沿 软件中断寄存器 ≥1 挂起寄存器 (PR) 屏蔽寄存器 (IMR) & → NVIC 说明: • 边沿检测和软件中断通过或门合并,任一有效即触发挂起。 • 挂起寄存器 PR 在中断响应后必须软件清零,否则不会再触发。 • 屏蔽寄存器 IMR 控制是否允许中断信号传递到 NVIC。

4.2 边沿触发与电平触发

EXTI 支持两种触发方式:边沿触发和电平触发。不过 STM32 的 EXTI 只支持边沿触发,电平触发是通过 GPIO 的输入状态配合定时器轮询实现的。嗯,这里要注意区分。

触发方式 特点 适用场景 注意事项
上升沿触发 电平从低到高跳变时触发 按键按下(高有效)、脉冲计数 按键抖动会产生多次触发
下降沿触发 电平从高到低跳变时触发 按键按下(低有效)、传感器输出 同上,需要防抖
双边沿触发 上升和下降都触发 编码器、频率测量 中断频率翻倍,注意处理速度
电平触发 电平持续有效时一直触发 (EXTI 不支持,需软件模拟) 容易陷入中断风暴

我的习惯:按键中断我一般用下降沿触发,配合内部上拉电阻。这样按键悬空时是高电平,按下才拉低,触发一次。省电又干净。

4.3 中断服务函数编写

在 Rust 中写中断服务函数,核心就是使用 #[interrupt] 属性宏。这个宏会帮你处理好保存上下文、恢复现场这些杂事。

基本结构是这样的:

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

// 定义一个全局中断标志
static INTERRUPT_FLAG: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));

// 中断服务函数
#[interrupt]
fn EXTI0() {
    // 1. 处理中断逻辑
    // 比如读取按键状态、设置标志位
    cortex_m::interrupt::free(|cs| {
        *INTERRUPT_FLAG.borrow(cs).borrow_mut() = true;
    });

    // 2. 清除中断标志位(非常重要!)
    // 具体操作见下一节
}

这里有几个要点:

  • 函数名必须与中断向量表中的名称一致。比如 EXTI0 对应 EXTI0 中断线。名字错了,编译器不会报错,但中断永远不会触发。我踩过这个坑,查了整整半天。
  • 中断函数要尽量短。只做最必要的事——设置标志、读取数据、清除标志。耗时操作放到主循环里做。
  • 使用 Mutex 保护共享数据。中断和主循环共享的变量,必须用 Mutex 包裹,否则会出现数据竞争。

警告:不要在中断服务函数里做延时、打印、动态内存分配!这些操作要么阻塞中断,要么导致死锁。我曾经在中断里加了个调试打印,结果系统直接卡死——串口发送是轮询的,中断里等轮询,主循环等中断,死锁了。

4.4 中断标志位清除

中断标志位不清除,中断会反复触发。这就像门铃一直响,你按了静音键但没松手——CPU 会一直跳进中断,主循环永远跑不了。

在 STM32 上,清除 EXTI 挂起标志位的方法是写 1 到对应的 PR 寄存器位:

use stm32f4xx_hal::pac;

fn clear_exti_interrupt(line: u8) {
    let exti = unsafe { &*pac::EXTI::ptr() };
    // 写 1 清除对应位
    exti.pr.write(|w| unsafe { w.bits(1 << line) });
}

为什么是写 1 来清除?这是 STM32 的设计惯例——写 0 无效,写 1 才清除。我第一次用的时候习惯性写 0,结果中断一直触发,还以为是硬件坏了。

在 HAL 库中,通常有封装好的方法:

// 假设使用 stm32f4xx_hal 的 ExtiPin trait
use stm32f4xx_hal::gpio::ExtiPin;

// 在中断服务函数中调用
my_pin.clear_interrupt_pending_bit();

最佳实践:清除标志位的操作要放在中断函数的最末尾。先处理完业务逻辑,再清标志。如果先清标志再处理,处理过程中又来了新中断,标志位会被再次置位,可能导致处理逻辑被覆盖。

4.5 防抖处理

机械按键按下时,触点会弹跳 5-20ms。这段时间里,电平会反复跳变。如果不做防抖,一次按键会触发几十次中断。

防抖有两种主流方法:

方法 原理 优点 缺点
硬件防抖 RC 低通滤波 + 施密特触发器 不占 CPU,响应快 增加 BOM 成本,不可调节
软件防抖 检测到边沿后延时再确认 零成本,灵活可调 占用定时器或延时

我个人更推荐软件防抖,尤其是用定时器来做,不阻塞主循环。下面是一个 Rust 实现:

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

// 按键状态机
enum ButtonState {
    Idle,           // 空闲
    PressDebounce,  // 按下防抖中
    Pressed,        // 已确认按下
    ReleaseDebounce,// 释放防抖中
}

static BUTTON_STATE: Mutex<RefCell<ButtonState>> =
    Mutex::new(RefCell::new(ButtonState::Idle));

// 定时器中断(假设 1ms 触发一次)
#[interrupt]
fn TIM2() {
    cortex_m::interrupt::free(|cs| {
        let mut state = BUTTON_STATE.borrow(cs).borrow_mut();
        match *state {
            ButtonState::PressDebounce => {
                // 20ms 后再次检查电平
                // 如果仍为低电平,确认按下
                if is_pin_low() {
                    *state = ButtonState::Pressed;
                    // 触发按键处理
                    handle_button_press();
                } else {
                    *state = ButtonState::Idle;
                }
            }
            ButtonState::ReleaseDebounce => {
                if is_pin_high() {
                    *state = ButtonState::Idle;
                } else {
                    *state = ButtonState::Pressed;
                }
            }
            _ => {}
        }
    });
}

// 外部中断服务函数
#[interrupt]
fn EXTI0() {
    cortex_m::interrupt::free(|cs| {
        let mut state = BUTTON_STATE.borrow(cs).borrow_mut();
        match *state {
            ButtonState::Idle => {
                // 第一次检测到边沿,进入防抖
                *state = ButtonState::PressDebounce;
                // 启动定时器,20ms 后检查
                start_debounce_timer(20);
            }
            _ => {}
        }
    });
    // 清除中断标志
    clear_exti_interrupt(0);
}

避坑指南:防抖时间不是越长越好。20ms 对大多数按键足够,但如果是机械开关或继电器触点,可能需要 50ms。我曾经在一个工业项目里用 10ms 防抖,结果现场振动大,按键频繁误触发。后来改成 30ms,问题解决。所以,防抖时间要根据实际硬件和环境来调。

4.6 完整示例:按键中断 + 防抖

把上面所有知识点串起来,一个完整的按键中断驱动大概长这样:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use stm32f4xx_hal::{
    pac,
    prelude::*,
    gpio::{ExtiPin, GpioExt, Pin, Output, PushPull},
    timer::Timer,
};
use core::cell::RefCell;
use cortex_m::interrupt::Mutex;

// 共享状态
static KEY_PRESSED: Mutex<RefCell<bool>> = Mutex::new(RefCell::new(false));

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let cp = cortex_m::Peripherals::take().unwrap();

    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(84.mhz()).freeze();

    // 初始化 GPIO
    let gpioa = dp.GPIOA.split();
    let mut button = gpioa.pa0.into_pull_up_input();

    // 配置外部中断:下降沿触发
    button.make_interrupt_source();
    button.trigger_on_edge(&dp.SYSCFG, stm32f4xx_hal::gpio::Edge::Falling);
    button.enable_interrupt(&dp.SYSCFG);

    // 初始化 LED(用于指示)
    let mut led = gpioa.pa5.into_push_pull_output();

    // 初始化定时器(用于防抖)
    let mut timer = Timer::new(dp.TIM2, &clocks);
    timer.start(1.khz());

    loop {
        cortex_m::interrupt::free(|cs| {
            if *KEY_PRESSED.borrow(cs).borrow() {
                // 按键被按下,翻转 LED
                led.toggle().unwrap();
                *KEY_PRESSED.borrow(cs).borrow_mut() = false;
            }
        });
    }
}

// 外部中断服务函数
#[interrupt]
fn EXTI0() {
    cortex_m::interrupt::free(|cs| {
        // 这里只设置标志,防抖由定时器处理
        // 实际项目中可以在这里启动防抖定时器
        *KEY_PRESSED.borrow(cs).borrow_mut() = true;
    });
    // 清除中断标志
    unsafe { &*pac::EXTI::ptr() }.pr.write(|w| unsafe { w.bits(1 << 0) });
}

这个例子虽然简单,但包含了中断配置、服务函数、标志清除、主循环轮询这几个核心环节。防抖部分我用了定时器轮询的方式,你也可以用硬件定时器捕获 + 软件状态机,效果更好。

总结一下:外部中断驱动,说白了就是“检测到变化 → 快速响应 → 清标志 → 防抖确认”。EXTI 负责检测,NVIC 负责调度,你负责写对代码。记住:中断函数要短,标志位要清,防抖不能省。