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
Pins PA5, PB5, PC5... all use EXTI channel 5, so you can't use EXTI on, say, PA5 and PC5 at the same time.Inputitself because it needs to take ownership of the corresponding EXTI channel, which is a limited resource.
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。
处理函数:屏蔽、唤醒、清挂起
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。
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_init | init 后永远挂起 | 10372 | — |
size_led | 加一个 Output,toggle() | 10536 | +164 |
size_exti | 再加一个 ExtiInput,循环 wait_for_rising_edge + toggle | 11560 | +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 字节)。
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_edgefuture,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。