Rust in Everything
基于 embassy-executor 0.10.0

一次中断唤醒

主线位置:中断主线全程。从 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。整套绑定在运行期没有任何间接层:没有处理函数表,没有函数指针。为什么要用类型层面的中断、忘了绑定时编译器报什么,见中断绑定。

📝NOTE
冷知识:EXTI 是 0.5.0 才开始要 bind_interrupts! 的

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。中断返回后继续执行的是被打断的触发任务。

❗IMPORTANT
动脑筋: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 之前就是这样,和这个程序无关。

📝NOTE
没有蠢问题

问: 事件来没来,为什么看 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一次存储,异常进入
向量入口 → 回到触发任务约 117on_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 因为事件寄存器已置位而不睡。

💬 关联讨论

发起讨论
加载中...