一次中断唤醒
主线位置:中断主线全程。从
bind_interrupts!把 EXTI0 的中断向量接到 embassy-stm32,到等待上升沿的任务被唤醒、继续执行;外设一侧只讲到看清全貌的深度,细节在中断绑定和 GPIO 与 EXTI。
sequenceDiagram participant W as 等待任务 participant X as EXTI 寄存器 participant T as 触发任务 participant V as EXTI0 向量 participant E as 执行器 W->>X: 配线 清挂起 开 IMR W->>W: 登记 AtomicWaker W-->>E: Pending T->>X: 写 SWIER X->>V: 中断 抢占触发任务 V->>X: 读 PR 关 IMR 清 PR V->>E: wake 入运行队列 SEV V-->>T: 中断返回 T-->>E: 触发任务结束 E->>W: poll W->>X: 读 IMR 已关 W-->>E: Ready
程序
没有开发板,也就没有人去按按钮。QEMU 11.1.2 的 b-l475e-iot01a 给 STM32L4 的 EXTI 建了模型(stm32l4x5-exti),EXTI 有一个软件中断事件寄存器 SWIER:往某一位写 1,等同于这条线上来了一个事件。于是用另一个任务写 SWIER 来代替按键。和全卷的 QEMU 实验一样,embassy-stm32 是只注释掉 rcc/bd.rs 等 PWR 那一行的补丁版,时间驱动是模板里的 1 kHz SysTick 驱动。
// Runs first in the EXTI0 vector, before embassy-stm32's handler.
struct EntryStamp;
impl Handler<EXTI0> for EntryStamp {
unsafe fn on_interrupt() {
stamp("EXTI0 vector entry");
}
}
bind_interrupts!(struct Irqs {
EXTI0 => EntryStamp, exti::InterruptHandler<EXTI0>;
});
#[embassy_executor::task]
async fn trigger() {
Timer::after_millis(5).await;
stamp("trigger: before SWIER write");
pac::EXTI.swier(0).write(|w| w.set_line(0, true));
stamp("trigger: after SWIER write");
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
start_systick();
let mut button = ExtiInput::new(p.PA0, p.EXTI0, Pull::Down, Irqs);
Timer::after_millis(2).await;
spawner.spawn(trigger().unwrap());
stamp("waiter: before wait_for_rising_edge");
button.wait_for_rising_edge().await;
stamp("waiter: after .await");
// ...
}
pac::EXTI 要开 embassy-stm32 的 unstable-pac feature。PA0 用的是 EXTI 第 0 条线,所以绑的是 EXTI0 这个中断。EntryStamp 是一个自己写的处理函数,排在 embassy-stm32 的处理函数前面,记下中断进入的时刻。打点和计数的办法与一次 Timer::after 相同:-icount shift=8,sleep=off,两个打点之间没有睡眠时,纳秒数除以 256 就是指令条数;一次打点约 70 条指令。
bind_interrupts! 展开成什么
bind_interrupts! 定义在 embassy-stm32 里(源码注释说它用到 $crate,所以不能放进 hal-internal)。对每一个 中断 => 处理函数, ...; 它生成两样东西:
$(
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
$(#[cfg($cond_irq)])?
$(#[doc = $doc])*
unsafe extern "C" fn $irq() {
unsafe {
$(
$(#[cfg($cond_handler)])?
<$handler as $crate::interrupt::typelevel::Handler<$crate::interrupt::typelevel::$irq>>::on_interrupt();
)*
}
}
$(#[cfg($cond_irq)])?
$crate::bind_interrupts!(@inner
$(
$(#[cfg($cond_handler)])?
unsafe impl $crate::interrupt::typelevel::Binding<$crate::interrupt::typelevel::$irq, $handler> for $name {}
)*
);
)*
按这个定义,上面的 Irqs 展开后是(路径缩写):
#[derive(Copy, Clone)]
struct Irqs;
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
unsafe extern "C" fn EXTI0() {
unsafe {
<EntryStamp as typelevel::Handler<typelevel::EXTI0>>::on_interrupt();
<exti::InterruptHandler<EXTI0> as typelevel::Handler<typelevel::EXTI0>>::on_interrupt();
}
}
unsafe impl typelevel::Binding<typelevel::EXTI0, EntryStamp> for Irqs {}
unsafe impl typelevel::Binding<typelevel::EXTI0, exti::InterruptHandler<EXTI0>> for Irqs {}
- 向量函数:一个不改名(
no_mangle)的extern "C" fn EXTI0()。cortex-m-rt 的向量表__INTERRUPTS按名字引用每个中断的处理函数;没人定义的,由 stm32-metapac 21.0.0 的src/chips/stm32l475vg/device.x里的PROVIDE(EXTI0 = DefaultHandler);这类行兜底。PROVIDE只在符号没有别的定义时生效,所以只要有一个叫EXTI0的函数,向量表里那一项就指向它。 Binding实现:Binding<I, H>是一个空的unsafe trait,实现它就等于声明「中断I的向量里会调H::on_interrupt」。驱动的构造函数要一个impl Binding<...>参数,就是在编译期要这份证明。
链接后的 ELF 里能看到这两点(llvm-nm -n、llvm-objdump,release):
08000040 R __INTERRUPTS
080001e0 T EXTI0
...
08002044 T DefaultHandler
08002044 T EXTI1
08002044 T EXTI15_10
08002044 T EXTI2
向量表下标 22 的那一项(地址 0x08000058,16 个系统异常之后的 6 号中断,pac 里 EXTI0 = 6)的内容是 0x080001e1,即 EXTI0 的地址加上 Thumb 位;没有绑定的 EXTI1、EXTI2 等都和 DefaultHandler 是同一个地址。EXTI0 的反汇编只有十来条:
080001e0 <EXTI0>:
80001e0: push {r4, r6, r7, lr}
80001e2: add r7, sp, #0x8
80001e4: bl 0x80020c8 <_critical_section_1_0_acquire> @ imm = #0x1ee0
80001e8: mov r4, r0
80001ea: movw r0, #0x3c28
80001ee: movt r0, #0x800
80001f2: movs r1, #0x12
80001f4: bl 0x800087e <exti::common::stamp_cs> @ imm = #0x686
80001f8: mov r0, r4
80001fa: bl 0x80020d4 <_critical_section_1_0_release> @ imm = #0x1ed6
80001fe: pop.w {r4, r6, r7, lr}
8000202: b.w 0x8000a38 <embassy_stm32::exti::on_irq> @ imm = #0x832
前面是内联进来的 EntryStamp,最后一条直接跳到 embassy-stm32 的 on_irq。整套绑定在运行期没有任何间接层:没有处理函数表,没有函数指针。为什么要用类型层面的中断、忘了绑定时编译器报什么,见中断绑定。
embassy-stm32 0.4.0 及以前,ExtiInput::new(pin, ch, pull) 只有三个参数,EXTI 的中断向量由 embassy-stm32 自己定义:exti.rs 里一个 impl_irq! 宏对每个 EXTI 中断生成 #[interrupt] unsafe fn $e() { on_irq() }。用户想在同一个向量里做点别的(比如上面的 EntryStamp),或者自己处理某条线,都做不到:符号已经被 HAL 占了。#4922「Bindable EXTI interrupts, and removal of AnyChannel support for ExtiInput」2025-11-23 合并,首次发布于 embassy-stm32 0.5.0,CHANGELOG 记为「exti brought in line with other drivers' interrupt rebinding system」和「ExtiInput no longer accepts AnyPin/AnyChannel; AnyChannel removed entirely」。从此 ExtiInput::new 多了第四个参数 impl Binding<...>;因为要在编译期知道是哪个中断,类型擦除的 AnyChannel 也就不能再用了。0.6.0 里还留着一个已弃用的空壳 AnyChannel,它的弃用说明链回了 #4922。
逐步走
1. 构造:要绑定,要 NVIC
ExtiInput::new 吃掉引脚和 EXTI 通道两个外设单例,再要一个绑定:
pub fn new<T: ExtiPin + GpioPin>(
pin: Peri<'d, T>,
_ch: Peri<'d, T::ExtiChannel>,
pull: Pull,
_irq: impl Binding<
<<T as ExtiPin>::ExtiChannel as Channel>::IRQ,
InterruptHandler<<<T as ExtiPin>::ExtiChannel as Channel>::IRQ>,
>,
) -> Self {
_irq 在函数体里根本没用到,它只是一个类型上的证明。PA0 的 EXTI 通道是 EXTI0,EXTI0 的中断类型也叫 EXTI0(这两个名字的对应来自 build.rs 生成的表,见芯片数据与代码生成)。NVIC 那一侧不用驱动管:开了 exti feature,embassy_stm32::init 里的 exti::init 已经把所有 EXTI 中断在 NVIC 里打开了。真正决定某条线会不会产生中断的,是 EXTI 自己的中断屏蔽寄存器 IMR。
2. 第一次 poll:配线、登记、挂起
wait_for_rising_edge 新建一个 ExtiInputFuture。构造时 configure_and_enable_exti 在一个临界区里做四件事:在 SYSCFG 的 EXTICR 里选端口 A,设上升沿触发(RTSR)、关下降沿(FTSR),清掉这条线的挂起位(PR),打开 IMR。然后 .await 第一次 poll 它(poll):
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
EXTI_WAKERS[self.pin as usize].register(cx.waker());
let imr = cpu_regs().imr(0).read();
if !imr.line(self.pin as _) {
Poll::Ready(())
} else {
Poll::Pending
}
}
每条线一个 AtomicWaker(static EXTI_WAKERS: [AtomicWaker; 16]),先登记再检查,这个顺序保证了「检查之后、返回之前」来的中断也不会丢:中断要么发生在登记之前(那时 IMR 已开,处理函数会关掉 IMR,poll 读到的就是关着的),要么发生在登记之后(处理函数能找到 waker)。AtomicWaker 怎么在中断和任务之间安全地交接一个 waker,见等待队列。IMR 还开着,返回 Pending,等待任务挂起。
3. 写 SWIER:中断立刻抢占
5 ms 后触发任务被定时器唤醒,往 SWIER 的第 0 位写 1。QEMU 的 EXTI 模型把 PR 的第 0 位置上,IMR 开着,向 NVIC 发中断。线程模式的优先级最低,EXTI0 立刻抢占触发任务:trigger: before SWIER write 和 EXTI0 vector entry 之间只隔 81 条指令,去掉一次打点只剩十来条,就是那次存储和中断进入。
向量里先跑 EntryStamp,然后是 on_irq:
unsafe fn on_irq() {
cfg_no_rpr_fpr! {
let bits = EXTI.pr(0).read().0;
}
// ...
// We don't handle or change any EXTI lines above 16.
let bits = bits & 0x0000FFFF;
// Mask all the channels that fired.
cpu_regs().imr(0).modify(|w| w.0 &= !bits);
// Wake the tasks
for pin in BitIter(bits) {
EXTI_WAKERS[pin as usize].wake();
}
// Clear pending
low_level::clear_exti_pending_mask(bits);
}
L4 只有一个挂起寄存器 PR(cfg_no_rpr_fpr! 那一支;G0、U5 等系列分成上升沿、下降沿两个)。处理函数不判断是哪个任务在等、等的是什么边沿,只做三件事:把触发了的线在 IMR 里关掉,叫醒这些线上登记的 waker,清挂起位。所有 EXTI 中断最后都落到这同一个函数,InterruptHandler<T> 的泛型参数只是为了满足 Handler<T> 的签名(源码注释原话:「It technically doesn't need to be generic at all」)。
4. 唤醒:和定时器同一条路
AtomicWaker::wake 调的就是任务的 waker,也就是 wake_task:原子地置运行队列标志,压进运行队列,sev。和一次 Timer::after 的第 6 步完全相同,只是这次调它的是 EXTI 的中断处理函数,而不是时间驱动的。
此刻等待任务只是进了运行队列,还没有被 poll。中断返回后继续执行的是被打断的触发任务。
trigger: after SWIER write 和 waiter: after .await,哪一行先打出来?两者之间隔了多少条指令?触发任务写完 SWIER 的下一行就是打点;等待任务在中断里已经被唤醒了。先猜顺序,再估一下中间要经过哪些代码。
5. 再次 poll:IMR 关着就是来过
触发任务打完点就结束了。执行器这一轮 poll 处理完它,去 wfe:这条 wfe 不睡,因为中断里的 sev 已经把事件寄存器置位了。下一轮 poll 取到等待任务,ExtiInputFuture::poll 重新登记 waker,读 IMR:第 0 位已经被 on_irq 关掉,返回 Ready。wait_for_rising_edge 返回前 future 被 drop,Drop 在临界区里再关一次 IMR 的这一位。程序开头(init 之前)和最后各读了一次寄存器:
before init: IMR1 = 0xff820000
after: IMR1 = 0xff820000, PR1 = 0x00000000
第 0 位是 0:先被 on_irq 关掉,又被 Drop 关了一次;PR1 已清零。0xff820000 里置着的那些高位在 init 之前就是这样,和这个程序无关。
问: 事件来没来,为什么看 IMR,而不是看挂起寄存器 PR?PR 才是「事件发生过」的标志啊。
答: 因为 on_irq 返回前就把 PR 清掉了:不清的话,中断一返回就会再进来。等到任务被 poll 时 PR 早就是 0,看不出发生过什么。IMR 正好相反:只有 configure_and_enable_exti 会打开它,只有 on_irq(和 future 的 Drop)会关掉它,所以「这条线的 IMR 被关了」就是「我开着它之后来过一次中断」。关掉 IMR 还顺带做了另一件事:任务被 poll 之前,同一条线再来事件也不会再进中断,一次等待只消费一次中断。
每一步多少条指令
打点版(trace 打开,执行器钩子里也打点)的完整输出:
2124.500 us + 0 ns (~ 0 insn) waiter: before wait_for_rising_edge
2199.500 us + 75000 ns (~ 292 insn) exec task_exec_end
2221.250 us + 21750 ns (~ 84 insn) exec executor_idle (-> wfe)
7005.500 us +4784250 ns (~ 18688 insn) (last SysTick ISR entry)
7043.750 us + 38250 ns (~ 149 insn) wake -> task_ready_begin
7080.750 us + 37000 ns (~ 144 insn) exec poll_start
7104.500 us + 23750 ns (~ 92 insn) exec task_exec_begin
7146.000 us + 41500 ns (~ 162 insn) trigger: before SWIER write
7166.750 us + 20750 ns (~ 81 insn) EXTI0 vector entry
7205.250 us + 38500 ns (~ 150 insn) wake -> task_ready_begin
7235.500 us + 30250 ns (~ 118 insn) trigger: after SWIER write
7259.750 us + 24250 ns (~ 94 insn) exec task_exec_end
7281.500 us + 21750 ns (~ 84 insn) exec executor_idle (-> wfe)
7308.750 us + 27250 ns (~ 106 insn) exec poll_start
7332.750 us + 24000 ns (~ 93 insn) exec task_exec_begin
7384.250 us + 51500 ns (~ 201 insn) waiter: after .await
before init: IMR1 = 0xff820000
after: IMR1 = 0xff820000, PR1 = 0x00000000
前三行是等待任务的第一次 poll:配线、登记 waker、读 IMR、返回 Pending,到 task_exec_end 292 条(含一次打点)。7005 到 7104 µs 那几行是 SysTick 中断把触发任务从 5 ms 的定时器里叫醒,走的是定时器主线。从 trigger: before SWIER write 开始是这一页要看的部分。
只在任务和 EntryStamp 里打点、钩子留空的版本:
2088.500 us + 0 ns (~ 0 insn) waiter: before wait_for_rising_edge
2199.250 us + 110750 ns (~ 432 insn) driver schedule_wake
schedule_wake at = 7
queue changed = 1
7073.500 us +4874250 ns (~ 19040 insn) trigger: before SWIER write
7094.250 us + 20750 ns (~ 81 insn) EXTI0 vector entry
7142.000 us + 47750 ns (~ 186 insn) trigger: after SWIER write
7208.750 us + 66750 ns (~ 260 insn) waiter: after .await
before init: IMR1 = 0xff820000
after: IMR1 = 0xff820000, PR1 = 0x00000000
(第二行是触发任务第一次 poll 时登记 5 ms 定时器,和这一页无关。)汇总(QEMU 11.1.2 b-l475e-iot01a,PWR 一行补丁,SysTick 驱动,release opt-level = "s",-icount shift=8,sleep=off;每段已扣掉一次约 70 条的打点):
| 区间 | 指令数 | 经过的代码 |
|---|---|---|
写 SWIER → 进入 EXTI0 向量 | 约 12 | 一次存储,异常进入 |
| 向量入口 → 回到触发任务 | 约 117 | on_irq:读 PR、关 IMR、AtomicWaker::wake → wake_task → 入队、sev、清 PR;异常返回 |
| 回到触发任务 → 等待任务继续执行 | 约 190 | 触发任务结束、执行器这一轮收尾、wfe 直接返回、下一轮 poll、ExtiInputFuture::poll 返回 Ready、drop |
合计:写 SWIER → 等待任务继续 | 约 320 |
这是 QEMU 数的指令,没有算中断进入的压栈周期、总线等待、流水线,换不成真芯片上的微秒。能看出来的是结构:中断处理函数本身只有一百条上下,大部分时间花在执行器回到这个任务的路上,而那一段长短取决于当时运行队列里还有谁。tokio 的同一件事是 mio 从 epoll_wait / kevent 拿到就绪事件、反应器找到 ScheduledIo 再唤醒任务(反应器),中间隔着一次系统调用;embassy 里中断处理函数直接调 waker。
动脑筋答案
trigger: after SWIER write 先打出来,waiter: after .await 比它晚 260 条指令(钩子留空的版本)。打点版把中间的经过全记下来了:
7146.000 us + 41500 ns (~ 162 insn) trigger: before SWIER write
7166.750 us + 20750 ns (~ 81 insn) EXTI0 vector entry
7205.250 us + 38500 ns (~ 150 insn) wake -> task_ready_begin
7235.500 us + 30250 ns (~ 118 insn) trigger: after SWIER write
7259.750 us + 24250 ns (~ 94 insn) exec task_exec_end
7281.500 us + 21750 ns (~ 84 insn) exec executor_idle (-> wfe)
7308.750 us + 27250 ns (~ 106 insn) exec poll_start
7332.750 us + 24000 ns (~ 93 insn) exec task_exec_begin
7384.250 us + 51500 ns (~ 201 insn) waiter: after .await
中断在写 SWIER 之后立刻进来(EXTI0 vector entry),处理函数里等待任务进了运行队列(task_ready_begin),但 poll 它的是执行器,执行器此刻正在 poll 触发任务。所以中断返回后先回到触发任务,打出 after SWIER write,触发任务结束(task_exec_end),这一轮 poll 才收尾(executor_idle)。接着 wfe 不睡就返回,下一轮 poll(poll_start)里才轮到等待任务(task_exec_begin)。等待任务是在下一轮才被 poll 的:dequeue_all 一次只取走调用那一刻的运行队列,poll 期间新入队的任务留给下一轮。
小结
bind_interrupts!为每个中断生成一个no_mangle的extern "C" fn(向量表按名字引用它),依次调列出的处理函数,并为用户的结构体实现Binding;驱动构造函数要impl Binding<...>,在编译期证明处理函数接上了。- 0.5.0 起(#4922)EXTI 也走这套绑定,用户可以在同一个向量里放自己的处理函数。
ExtiInputFuture构造时配线、清挂起、开 IMR;poll 时先登记AtomicWaker再读 IMR,IMR 被关了就是事件来过。on_irq不管谁在等:关掉触发了的线的 IMR,唤醒这些线的 waker,清PR。唤醒走的是和定时器主线同一个wake_task。- QEMU 上(
SWIER触发):写寄存器到进中断约 12 条指令,中断处理约 117 条,回到等待任务约 190 条,合计约 320 条;等待任务在下一轮 poll 才运行,中间那条wfe因为事件寄存器已置位而不睡。