Rust in Everything
基于 embassy-stm32 0.6.0

GPIO 与 EXTI

主线位置:中断主线的第 2 步到最后一步。任务调 button.wait_for_rising_edge().await:驱动配置 EXTI 线、登记 waker、打开中断,任务挂起;边沿到来,bind_interrupts! 生成的 EXTI15_10 调到 on_irq,它屏蔽这条线、唤醒任务;执行器再次 poll,future 看到线已被屏蔽,返回 Ready。前一半(向量怎么接上)见中断绑定,概览版见一次中断唤醒。

sequenceDiagram
  participant T as 任务
  participant F as ExtiInputFuture
  participant R as EXTI 寄存器
  participant H as on_irq
  participant X as 执行器
  T->>F: wait_for_rising_edge
  F->>R: EXTICR RTSR 清 PR IMR=1
  X->>F: poll
  F->>F: 登记 EXTI_WAKERS 13
  F-->>X: Pending
  X->>X: WFE
  R->>H: PR13 置位 进 EXTI15_10
  H->>R: IMR13=0 写 PR 清除
  H->>X: waker.wake
  X->>F: poll
  F-->>T: IMR13 为 0 Ready

gpio.rs 1271 行;exti/ 三个文件:mod.rs 558 行、low_level.rs 134 行、blocking.rs 62 行。本页的运行结果都来自 QEMU 11.1.2 的 b-l475e-iot01a,embassy-stm32 打了注释掉 rcc/bd.rs 里 while !cr.read().dbp() {} 的补丁;时间来自模板里自己写的 1 kHz SysTick 驱动,单位是 SysTick 的 tick,不是真实硬件时间;程序在 exp-embassy/hal-qemu/src/bin/exti.rs,release。宿主是 M2 Max、macOS 27、rustc 1.99.0。

GPIO:一个 Flex,两层外壳

Flex<'d> 是唯一真正干活的引脚驱动,里面只有一个 Peri<'d, AnyPin>,可以在输入、输出、开漏、模拟之间来回切。Input 和 Output 各包一个 Flex,构造时定好方向,之后只暴露那一侧的方法。set_as_input 在 GPIO v2 上是三次读改写:

            #[cfg(gpio_v2)]
            {
                r.pupdr().modify(|w| w.set_pupdr(n, pull.to_pupdr()));
                r.otyper().modify(|w| w.set_ot(n, vals::Ot::PUSH_PULL));
                r.moder().modify(|w| w.set_moder(n, vals::Moder::INPUT));
            }

整段包在 critical_section::with 里,因为同一端口的 16 个引脚共用这几个寄存器,两个引脚的驱动在不同优先级里同时读改写会互相覆盖。set_high / set_low 不需要临界区:它们写 BSRR,一次写入只影响写 1 的那几位,硬件保证原子。Flex drop 时把引脚设成断开(v2 上是模拟模式),见外设单例与 Peri。

ExtiInput:多占一个通道令牌

ExtiInput<'d, Mode> 是一个 Input<'d> 加模式标记。为什么不把 EXTI 做进 Input?文档注释的回答:

EXTI is not built into Input itself because it needs to take ownership of the corresponding EXTI channel, which is a limited resource.

Pins PA5, PB5, PC5... all use EXTI channel 5, so you can't use EXTI on, say, PA5 and PC5 at the same time.

EXTI 只有 16 条 GPIO 线,第 n 条线由 SYSCFG_EXTICR 选择某一个端口的第 n 号引脚。ExtiInput::new 因此要两个令牌:pin: Peri<'d, T> 和 _ch: Peri<'d, T::ExtiChannel>。T::ExtiChannel 来自生成的 foreach_pin! 表(PC13 对应 EXTI13),EXTI13 只有一个令牌,PA13 和 PC13 不可能同时做成 ExtiInput,冲突在编译期就被挡住。第三个参数是中断绑定的证明,见中断绑定。构造函数体只有 Input::new(pin, pull):这时既不配置 EXTI,也不开中断。

等一个边沿:future 怎么造、怎么 poll

wait_for_rising_edge 一行:ExtiInputFuture::new(&self.pin, TriggerEdge::Rising, true).await。配置发生在 ExtiInputFuture::new,它调 configure_and_enable_exti,在一个临界区里依次做 configure_exti_pin 和打开屏蔽位:

pub(super) fn configure_exti_pin(pin: PinNumber, port: PinNumber, trigger_edge: TriggerEdge) {
    critical_section::with(|_| {
        let pin_num = pin as usize;
        // ...
        exticr_regs()
            .exticr(pin_num / 4)
            .modify(|w| w.set_exti(pin_num % 4, port as _));

        let (rising, falling) = match trigger_edge {
            TriggerEdge::Falling => (false, true),
            TriggerEdge::Rising => (true, false),
            TriggerEdge::Any => (true, true),
        };

        EXTI.rtsr(0).modify(|w| w.set_line(pin_num, rising));
        EXTI.ftsr(0).modify(|w| w.set_line(pin_num, falling));

        clear_exti_pending(pin);
    });
}

EXTICR 选端口(L4 上在 SYSCFG 里,G0 一类在 EXTI 自己里,F1 在 AFIO 里,exticr_regs 用 cfg 选),RTSR / FTSR 选沿,清掉旧的挂起位,最后 IMR 置 1 放行中断。每次等待都重新配置一遍,所以同一个 ExtiInput 可以这次等上升沿、下次等下降沿。

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
        }
    }

EXTI_WAKERS 是一个 16 项的静态数组,每条线一个 embassy-sync 的 AtomicWaker(等待队列)。判断「事件来了没有」不用另设标志:IMR 那一位就是标志。future 创建时置 1,处理函数触发后清 0,poll 读到 0 就是发生过。先登记 waker、再读 IMR,顺序不能反:反过来的话,中断恰好落在「读到 1」和「登记」之间,wake() 唤醒的是旧 waker(或者什么也没唤醒),任务就再也不会被 poll。

wait_for_high 的写法也是为了不漏事件:

    pub async fn wait_for_high(&mut self) {
        let fut = ExtiInputFuture::new(&self.pin, TriggerEdge::Rising, true);
        if self.is_high() {
            return;
        }
        fut.await
    }

先造 future(配置并放行中断),再读电平。若先读电平是低、再去配置,中间来的上升沿就丢了;现在那个沿会被 EXTI 记下,fut.await 第一次 poll 就返回。

和 tokio 对照:这个「每条线一个 waker,事件发生由硬件寄存器记住」的结构,对应 tokio 每个 I/O 资源的 ScheduledIo;区别是 tokio 的就绪位是软件维护的原子量,这里直接用 IMR。

处理函数:屏蔽、唤醒、清挂起

on_irq:

unsafe fn on_irq() {
    cfg_no_rpr_fpr! {
        let bits = EXTI.pr(0).read().0;
    }
    cfg_has_rpr_fpr! {
        let bits = EXTI.rpr(0).read().0 | EXTI.fpr(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);
}
  • 读挂起位。exti_v1(F4、L4 等)只有一个 PR;G0、U5、H5 这些有分开的上升沿 RPR 和下降沿 FPR,两个或起来。
  • 只看低 16 位:16 号以上的 EXTI 线接的是片内事件(以 L4 为例:PVD、USB OTG 唤醒、RTC 闹钟等),不归这个驱动。
  • 屏蔽触发了的线,这一步就是给 future 的「事件发生」信号;同时保证这条线在任务处理之前不会再进中断。
  • 逐位唤醒,BitIter 用 trailing_zeros 找下一位。
  • 最后对这些位写 1,清掉挂起。

InterruptHandler<T> 的 on_interrupt 就是 on_irq(),所有 EXTI 向量都落进同一个函数。EXTI15_10 进来时它也会处理 PR 里 0 到 9 号线的挂起位,这些线有自己的向量,但处理结果一样,不出错。

wake() 之后的事属于执行器:任务进运行队列、SEV 把 WFE 中的 CPU 叫醒(见状态与运行队列)。处理函数在中断上下文里跑,不 poll 任何 future。

drop 也要处理。ExtiInputFuture 的 Drop 在 drop 为真时(wait_for_* 都是真)进临界区把 IMR 那一位清 0:future 没等到就被丢掉(例如在 select 里输了),中断不应该继续开着。poll_for_rising_edge 一类方法传 false,留给手动 poll 的用法。

QEMU 上走一遍

QEMU 的 L4 EXTI 有模型,EXTI_SWIER1 写 1 会在该线上产生一次软件事件。exti.rs 让 main 在 PC13 上等两次上升沿、再在 select 里等一次下降沿(5 ms 超时);另一个任务 poke 依次触发,并在关键点读 IMR13、PR13、GPIOC_IDR 第 13 位:

#[embassy_executor::task]
async fn poke() {
  // 1) software trigger: write EXTI_SWIER1 bit 13
  Timer::after_millis(10).await;
  line13("before SWIER");
  pac::EXTI.swier(0).write(|w| w.set_line(13, true));
  line13("SWIER written");
  // 2) change PC13's pull-down to pull-up: IDR follows, does EXTI see an edge?
  Timer::after_millis(10).await;
  pac::GPIOC.pupdr().modify(|w| w.set_pupdr(13, Pupdr::PULL_UP));
  line13("pull-up");
  // 3) switch PC13 to output and drive it high
  Timer::after_millis(10).await;
  pac::GPIOC.moder().modify(|w| w.set_moder(13, Moder::OUTPUT));
  pac::GPIOC.bsrr().write(|w| w.set_bs(13, true));
  line13("output high");
  // 4) look at the line while main waits inside select
  Timer::after_millis(3).await;
  line13("inside select");
}
NVIC EXTI15_10 enabled by init: true
  [before SWIER @10 ms] IMR13=1 PR13=0 IDR13=0
  [SWIER written @10 ms] IMR13=0 PR13=0 IDR13=0
rising edge 0 at 10 ms
  [after wake @10 ms] IMR13=0 PR13=0 IDR13=0
  [pull-up @20 ms] IMR13=1 PR13=0 IDR13=1
  [output high @30 ms] IMR13=0 PR13=0 IDR13=1
rising edge 1 at 30 ms
  [after wake @30 ms] IMR13=0 PR13=0 IDR13=1
  [inside select @33 ms] IMR13=1 PR13=0 IDR13=1
timeout won at 35 ms
  [after drop @35 ms] IMR13=0 PR13=0 IDR13=1

逐行看:

  • 第一行在 ExtiInput::new 之前打印:init 已经在 NVIC 里打开了 EXTI15_10(exti::init)。
  • before SWIER:main 正挂在 wait_for_rising_edge 上,IMR13 = 1。
  • SWIER written:写 SWIER 的下一条语句读到 IMR13 = 0、PR13 = 0。中断在这两条语句之间抢占了 poke:处理函数屏蔽了线、清了挂起位、唤醒了 main。main 此时只是进了运行队列,poke 继续跑完这一段、在下一个 Timer 处让出,main 才被 poll,打印 rising edge 0。
  • pull-up:第二次等待开始,IMR13 = 1。把 PC13 从下拉改成上拉,IDR13 从 0 变成 1,但 IMR13 仍是 1,没有中断。在 QEMU 里改上下拉只改变了 IDR,EXTI 没有看到边沿;这是 QEMU 模型的行为,不能据此推断真芯片。
  • output high:把 PC13 切成输出并置高,QEMU 把输出电平送给了 EXTI,这次产生了上升沿,IMR13 立刻变 0。main 打印 rising edge 1。这是在 QEMU 里不靠 SWIER、走「引脚电平」路径触发 EXTI 的办法。
  • inside select:select(button.wait_for_falling_edge(), Timer::after_millis(5)) 里,ExtiInputFuture 已创建,IMR13 = 1。
  • after drop:超时赢了,select 返回时丢掉了 EXTI 那个 future,它的 Drop 把 IMR13 清回 0。
📝NOTE
冷知识:EXTI 的中断向量曾经被 HAL 全部占用

PR #4922(「Bindable EXTI interrupts, and removal of AnyChannel support for ExtiInput」,首次进入 embassy-stm32 v0.5.0)之前,exti.rs 里有个 impl_irq! 宏,对每个 EXTI 向量生成 #[cfg(feature = "rt")] #[interrupt] unsafe fn $e() { on_irq() }:只要开了 exti feature,HAL 就占掉所有 EXTI 向量,用户没法给某个向量换成自己的处理函数,ExtiInput::new 也只要 (pin, ch, pull) 三个参数。这个 PR 把它改成和其他驱动一样的 InterruptHandler + Binding,CHANGELOG 写的是「exti brought in line with other drivers' interrupt rebinding system」。代价是类型擦除的通道:new 要在编译期知道该证明哪个向量,AnyChannel 做不到,于是被删掉,只留下一个 #[deprecated] 的空壳,专门用来在用户代码里显示这条原因。旧版 new 里那行 assert_eq!(pin.pin(), ch.number());(「Needed if using AnyPin+AnyChannel」)也一起没了,引脚和通道的对应改由 ExtiPin::ExtiChannel 在类型上保证。

代码体积

三个只差 main 函数体的程序(size_init.rs、size_led.rs、size_exti.rs),都绑定了 EXTI15_10、都启动了 SysTick,自己写了空的 panic_handler,release(opt-level = "s"),llvm-size:

   text	   data	    bss	    dec	    hex	filename
  10372	      0	    288	  10660	   29a4	size_init
  10536	      0	    288	  10824	   2a48	size_led
  11560	      0	    296	  11856	   2e50	size_exti
程序main 里做什么.text增量
size_initinit 后永远挂起10372—
size_led加一个 Output,toggle()10536+164
size_exti再加一个 ExtiInput,循环 wait_for_rising_edge + toggle11560+1024

llvm-nm --size-sort 里 EXTI 相关的符号:configure_exti_pin 168 字节、on_irq 116、ExtiInputFuture 的 drop 76、configure_and_enable_exti 72、poll 62、new 22,.bss 里 EXTI_WAKERS 128 字节(16 个 AtomicWaker,每个 8 字节)。on_irq 和 EXTI_WAKERS 在三个程序里都有,因为三个都绑了向量;size_exti 多出的是配置函数、future 的三个方法和变大的 main 任务(任务池 POOL 从 48 字节变成 56 字节,.bss 多的 8 字节就是它)。三个程序里最大的符号都是时钟配置(rcc::_version::init 1294 字节)。

❗IMPORTANT
动脑筋:select 里超时赢了之后,IMR13 是几?

就是上面输出的最后一行。先想清楚 select 返回时谁被 drop、ExtiInputFuture 的 Drop 做了什么,再对照 inside select 那一行。

动脑筋答案

  [inside select @33 ms] IMR13=1 PR13=0 IDR13=1
timeout won at 35 ms
  [after drop @35 ms] IMR13=0 PR13=0 IDR13=1
  • 0。select 在 35 ms 由 Timer 一侧完成,返回前丢掉另一侧的 wait_for_falling_edge future,ExtiInputFuture::drop 进临界区执行 imr(0).modify(|w| w.set_line(pin, false))。
  • 33 ms 时是 1:future 已经创建并放行了中断,正在等下降沿。
  • 如果没有这个 Drop,IMR13 会留在 1,之后 PC13 再来下降沿就会进中断,on_irq 去唤醒 EXTI_WAKERS[13] 里留着的那个 waker(上一次 poll 时登记的 main 任务),main 会被多 poll 一次,而它此时并没有在等这条线。

小结

  • Flex 是唯一干活的引脚驱动,Input / Output 是外壳;改配置寄存器在临界区里读改写,置位清位走 BSRR 不加锁。
  • ExtiInput 多吃一个 EXTIn 令牌,同号引脚不能同时用 EXTI 在编译期就被拦下;构造时不碰 EXTI。
  • 每次等待都由 ExtiInputFuture::new 重新配置 EXTICR / RTSR / FTSR、清挂起、置 IMR;poll 先登记 EXTI_WAKERS[n]、再读 IMR,读到 0 即就绪。
  • on_irq 读挂起位、屏蔽触发的线、逐个 wake、清挂起;IMR 位兼做「事件已发生」的标志,不需要额外的原子量。
  • future 被 drop 时屏蔽该线;select 输掉不会留下开着的中断。
  • QEMU 上写 SWIER 或把引脚切成输出驱动高,都能触发 EXTI;改上下拉不行(QEMU 行为)。GPIO + EXTI 的程序比只点灯多 1024 字节 .text。

💬 关联讨论

发起讨论
加载中...