4. 内存映射I/O与寄存器操作
外部中断,说白了就是让芯片对外界的“动静”做出即时反应。按键按下、传感器跳变、电平翻转——这些信号来了,CPU 不用轮询,直接停下当前任务去处理。我刚开始做嵌入式时,总觉得轮询也能用,直到有一次项目里要同时响应 4 个按键和 2 个传感器,轮询把主循环拖得跟蜗牛一样。从那以后,外部中断就成了我的标配。
这一章,咱们就围绕 STM32 的 EXTI 控制器,把原理、配置、编码、清标志、防抖这些事一次讲透。Rust 代码会贯穿始终,但核心思路对任何 MCU 都通用。
4.1 EXTI 控制器原理
EXTI 全称 External Interrupt/Event Controller。它负责把 GPIO 引脚上的电平变化,映射到 NVIC(嵌套向量中断控制器)的中断通道上。
它的工作流程其实很简单:
- 信号输入:GPIO 引脚的电平进入 EXTI 模块。
- 边沿检测:EXTI 内部有上升沿触发寄存器和下降沿触发寄存器,你可以单独使能。
- 软件中断:还有一个软件中断事件寄存器,可以手动触发中断——调试时很有用。
- 中断挂起:一旦检测到有效边沿,对应的挂起寄存器位被置 1。
- 中断屏蔽:如果该中断没有被屏蔽,信号就送到 NVIC。
- NVIC 响应:NVIC 根据优先级决定是否立即响应。
关键点:EXTI 本身不处理优先级,它只负责“有没有事件发生”。优先级是 NVIC 的事。
下面这张图把 EXTI 的内部结构画清楚了:
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 负责调度,你负责写对代码。记住:中断函数要短,标志位要清,防抖不能省。