Rust in Everything
基于 embassy-executor 0.10.0

各平台的执行器

主线位置:定时器主线的「执行器无事可做,WFE 睡眠 → 闹钟中断 → 唤醒任务(进运行队列 + SEV)→ 再次 poll」。本页讲谁在调用 poll、没事做时 CPU 停在哪条指令上、wake 怎么把它叫醒;以及怎么用第二个执行器做优先级抢占。

flowchart TB
  wake["wake_task"] --> enq["enqueue"]
  enq -->|队列原来为空| pend["__pender"]
  pend --> sev["SEV 线程模式"]
  pend --> nvic["NVIC 挂起中断"]
  pend --> cv["条件变量 std"]
  sev --> loopt["poll 然后 WFE"]
  nvic --> isr["中断里 poll"]
  cv --> loops["poll 然后 wait"]

代码在 embassy-executor/src/platform/:cortex_m.rs 232 行,std.rs 107 行,其余五个文件各 58 到 87 行。embassy-stm32 0.6.0 的 src/executor.rs(273 行)和 src/low_power.rs(395 行)只读源码,本卷没有在硬件或 QEMU 上跑它。QEMU 实验在 exp-embassy/exec-qemu:QEMU 11.1.2 b-l475e-iot01a,embassy-stm32 打了一行补丁(注释掉 rcc/bd.rs 里的 while !cr.read().dbp() {},PWR 在 QEMU 里没有模型),时间驱动是模板里自己写的 1 kHz SysTick 驱动,release,opt-level = "s"。

平台只提供两样东西

raw::Executor 自己不会睡、也不会醒。它的文档把分工写得很清楚:

/// - To get the executor to do work, call `poll()`. This will poll all queued tasks (all tasks
///   that "want to run").
/// - You must supply a pender function, as shown below. The executor will call it to notify you
///   it has work to do. You must arrange for `poll()` to be called as soon as possible.
// ...
/// In particular, you must NOT call `poll` directly from the pender callback, as this violates
/// the requirement for `poll` to not be called reentrantly.

平台于是只写两样东西:一个主循环(poll 一次,然后用平台的办法睡到有事为止),和一个导出名为 __pender 的函数(被叫到时让主循环醒过来)。raw::Executor::new(context) 的 context 是一个 *mut (),原样传给 __pender,平台用它区分是哪个执行器。__pender 是链接期符号,所以一个程序里只能有一个平台;lib.rs 用 check_at_most_one! 宏保证七个 platform-* feature 最多开一个,再按 feature 选文件、pub use platform::*,于是 embassy_executor::Executor 指向所选平台的那个类型。

一个都不开时,#[embassy_executor::main] 换成 main_unspecified,报错(exp-embassy/exec-noplat,只开了 executor-thread):

error: No architecture selected for embassy-executor. Make sure you've enabled one of the `arch-*` features in your Cargo.toml.
       
       Alternatively, if you would like to use a custom executor implementation, specify it with the `executor` argument.
       For example: `#[embassy_executor::main(entry = ..., executor = "some_crate::Executor")]

信息里的 arch-* 在 0.10.0 已经不存在了。照着它去开 arch-std,cargo 在解析依赖时就拒绝:

package `exec-noplat` depends on `embassy-executor` with feature `arch-std` but `embassy-executor` does not have that feature.
help: available features: _any_trace, _platform, defmt, embassy-time-driver, executor-interrupt, executor-thread, log, metadata-name, nightly, platform-avr, platform-cortex-ar, platform-cortex-m, platform-riscv32, platform-spin, platform-std, platform-wasm, rtos-trace, scheduler-deadline, scheduler-priority, trace, turbowakers

来历见下面的冷知识。

Cortex-M 线程模式:WFE / SEV

cortex_m.rs 的 Executor::run:

        pub fn run(&'static mut self, init: impl FnOnce(Spawner)) -> ! {
            init(self.inner.spawner());

            loop {
                unsafe {
                    self.inner.poll();
                    asm!("wfe");
                };
            }
        }

__pender 对线程模式执行器(context == THREAD_PENDER,即 usize::MAX)只执行一条 sev。主循环里没有任何「有没有活」的判断:poll 处理完调用时队列里的任务就返回,然后直接 WFE。

📝NOTE
没有蠢问题

问: poll 返回之后、WFE 之前,如果一个中断唤醒了任务,执行器会不会一直睡到下一个中断,把这个任务晾在队列里?

答: 不会,这正是用 WFE 而不用 WFI 的原因。SEV 不是「叫醒正在睡的核」,而是置位一个事件寄存器(event register);WFE 执行时如果事件寄存器已经置位,就清掉它、立即返回,不睡。中断里的 wake 走到 __pender 执行 SEV,哪怕 CPU 那时还没走到 WFE,事件也被记下了,下一条 WFE 马上返回,主循环再 poll 一次。代价是偶尔多转一圈:事件寄存器在别的情况下也会被置位,WFE 可能在没有新任务时返回,poll 发现队列为空,再睡一次。

platform-cortex-m 的 feature 文档列了三条限制:只能用在单核芯片上(例外是多核之间连了事件信号、一个核的 SEV 能叫醒另一个核的 WFE),只能裸机(在 RTOS 线程里跑要用 RTOS 的通知原语),只能在特权模式。

InterruptExecutor:借一个中断当调度器

线程模式执行器跑在最低优先级上,一个任务不 .await 地算 30 ms,同一执行器里的其他任务就等 30 ms。InterruptExecutor 把 poll 放进一个中断处理函数里,中断优先级就是这个执行器里所有任务的优先级:

        pub fn start(&'static self, irq: impl InterruptNumber) -> crate::SendSpawner {
            if critical_section::with(|cs| self.started.borrow(cs).replace(true)) {
                panic!("InterruptExecutor::start() called multiple times on the same executor.");
            }

            unsafe {
                (&mut *self.executor.get())
                    .as_mut_ptr()
                    .write(raw::Executor::new(irq.number() as *mut ()))
            }

            let executor = unsafe { (&*self.executor.get()).assume_init_ref() };

            unsafe { NVIC::unmask(irq) }

            executor.spawner().make_send()
        }

context 是中断号。同一个 __pender 看到不是 THREAD_PENDER 的值,就去挂起那个中断:ARMv7-M 及以上写 NVIC 的 STIR(软件触发中断寄存器),ARMv6-M 没有 STIR,用 NVIC::pend 写挂起寄存器。中断处理函数要用户自己写,里面只调 on_interrupt(),后者就是 executor.poll()。中断返回后如果又有任务被唤醒,__pender 再次挂起中断,硬件会再进一次。

start 返回 SendSpawner:中断处理函数相当于另一个「线程」,从 main 往里 spawn 就是跨线程送任务(见 Spawner)。文档要求「You must set the interrupt priority before calling this method. You MUST NOT do it after.」,start 里一旦 unmask,中断就可能马上以当时的优先级进来。STM32 没有专门的软件中断,embassy-stm32 的 multiprio 示例借的是 UART4、UART5 的向量,本页的实验也借 UART4。

实测:高优先级任务晚多少

exec-qemu/src/bin/prio.rs 的结构(摘录):

static EXECUTOR_HIGH: InterruptExecutor = InterruptExecutor::new();
static EXECUTOR_LOW: StaticCell<Executor> = StaticCell::new();

#[interrupt]
unsafe fn UART4() {
  unsafe { EXECUTOR_HIGH.on_interrupt() }
}

#[entry]
fn main() -> ! {
  systick::start();
  let executor = EXECUTOR_LOW.init(Executor::new());
  if !cfg!(feature = "same-exec") {
    interrupt::UART4.set_priority(Priority::P6);
    let spawner = EXECUTOR_HIGH.start(interrupt::UART4);
    spawner.spawn(high().unwrap());
  }
  executor.run(|spawner| {
    if cfg!(feature = "same-exec") {
      spawner.spawn(high().unwrap());
    }
    spawner.spawn(low().unwrap());
  });
}

low 在线程模式执行器里循环:embassy_time::block_for(30ms)(忙等,不 .await),再 Timer::after_millis(1).await。high 循环 20 次:Timer::at(now + 7ms).await,醒来后立即读一个细粒度时钟,算出比定时器到期晚了多少。到期发生在 SysTick 中断把 tick 计数加到目标值的那一刻,所以「晚了多少」= 当前的 tick × 4000 + (4000 − 1 − SYST_CVR) − 目标 tick × 4000,单位是 SysTick 的输入时钟周期(4 MHz MSI)。SysTick 的优先级是复位值 0(最高),UART4 设成 6,线程模式最低。

QEMU 用 -icount shift=8,sleep=off 跑,每条指令推进 256 ns 虚拟时间,大致相当于一颗 4 MHz、每周期一条指令的核,结果完全确定:每种配置跑两次,两次输出逐字相同。下面的数都是 QEMU 的虚拟时间,不是硬件时间。

配置high 在哪low 忙等晚到(周期)折合
1InterruptExecutor(UART4,P6)有最小 214,最大 222,平均 215约 54 µs
2InterruptExecutor无266(20 次都一样)约 67 µs
3同一个线程模式执行器有见动脑筋
4同一个线程模式执行器无256(20 次都一样)64 µs

配置 1 的原始输出:

same-exec=false noload=false: high late min 214 max 222 avg 215 cycles over 20 rounds; low ran 4 busy rounds

low 在 high 跑完 20 轮(140 ms)期间完成了 4 轮忙等,high 每次都在两百多个周期内运行:SysTick 处理函数遍历定时器队列、wake 到 __pender 写 STIR、SysTick 返回后尾链(tail-chaining)进 UART4、poll、high 从 .await 返回。线程模式里正在忙等的 low 被直接抢占。配置 2 和 4 比配置 1 慢几十个周期,是因为空闲时 CPU 停在 WFE 上,唤醒路径不同;没有负载时两种执行器差不多。

❗IMPORTANT
动脑筋:配置 3 里 high 每次晚多少?

high 和 low 在同一个线程模式执行器里:low 每轮忙等 30 ms、睡 1 ms,high 每次定 7 ms 的定时器。20 轮下来,high 的最小、最大晚到时间各是多少个周期?

为了这个实验,-icount 的 sleep 参数踩过一次坑:一开始用默认的 -icount shift=8(sleep=on),配置 2 的结果在 214 到 3931 个周期之间乱跳,逐次打印每轮的值是 2530、558、3598、2671、4198……。QEMU 的 sleep=on 让 CPU 空闲时虚拟时钟跟着宿主机的真实时间走;从这组结果看,QEMU 里的 WFE 确实让 CPU 进入了空闲(CPU 几乎一直在忙等的配置 1 在两种参数下结果相同),于是 SysTick 中断在虚拟时间上的到达点取决于宿主机当时的调度(本机负载在 20 以上)。改成 sleep=off 后,空闲期间虚拟时钟直接跳到下一个定时器事件,结果变成每次 266。

其他平台

feature主循环__penderexecutor-interrupt
platform-cortex-mpoll;wfesev,或挂起中断支持
platform-cortex-arpoll;wfe()sev()compile_error!
platform-riscv32poll;临界区里看标志,没活就 wfi原子标志置 truecompile_error!
platform-avr关中断,swap 标志;有活就开中断、poll,没活就开中断、sleep原子标志置 truecompile_error!
platform-stdpoll;Signaler::wait(Mutex<bool> + Condvar)signal(),notify_onecompile_error!
platform-wasm不循环:start 后返回promise.then(closure),排一个微任务去 pollcompile_error!
platform-spin一直 poll空函数compile_error!

几点值得对着源码看:

  • RISC-V 没有 SEV,riscv32.rs 用一个 AtomicBool 代替事件寄存器。检查标志和 wfi 放在同一个临界区里:中断关着时检查、关着中断执行 wfi,有挂起的中断时 wfi 照样返回,出了临界区中断才被处理,注释写「if an interrupt occurred while waiting, it will be serviced here」。不关中断的话,「看了标志是 false」和「执行 wfi」之间来的中断会被错过。
  • std 的 Executor 在 new 里 Box::leak 一个 Signaler,把它的地址当 context,所以 std 上可以有多个执行器、各在一个线程。0.10.0 加了 run_until,每轮 poll 后检查一个 done 闭包,可以退出主循环。本卷的 std 实验都跑在这个执行器上,它和 tokio 的 current_thread 调度器一样是单线程的,但没有 I/O 驱动,只有一个条件变量。
  • platform-spin 的 feature 文档自己说「Using this is not recommended」:__pender 什么也不做,主循环永不睡。
  • platform-* 里的 compile_error! 字符串仍然写着 arch-std、arch-riscv32 这些旧名字。
📝NOTE
冷知识:arch-* 改名 platform-* 是在 0.10.0 的 CHANGELOG 写完之后

0.9.x 的 feature 叫 arch-std、arch-cortex-m……,目录叫 src/arch/。提交 ed661a3c8(「executor: rename arch-* to platform-*」,PR #5099,2026-03-12 合并,首次进入 embassy-executor 0.10.0)把 feature、目录和仓库里所有示例的 Cargo.toml 一起改了名。可 0.10.0 的 CHANGELOG 条目日期是 2026-03-10,比这个 PR 早两天,里面没有这次改名,反而写着「Migrate cortex-ar to aarch32-cpu. The feature name arch-cortex-ar remains the same」,而 0.10.0 实际的 feature 是 platform-cortex-ar。改名也没改到宏:上面那条「enabled one of the arch-* features」的报错来自 embassy-executor-macros 0.8.0 的 main.rs,各平台文件里 compile_error! 的字符串也还是旧名。从 0.9 升级的项目,第一个错误是 cargo 的「does not have that feature」。

embassy-stm32 的低功耗执行器

embassy-stm32 0.6.0 带了自己的平台实现,src/executor.rs 的模块文档说它「equivalent to platform-cortex-m in embassy-executor other than low-power support」。用法是开 embassy-stm32 的 executor-thread / executor-interrupt(它们打开 low-power),不开 embassy-executor 的任何 platform-* / executor-*,再在宏里指定执行器:#[embassy_executor::main(executor = "embassy_stm32::executor::Executor", entry = "cortex_m_rt::entry")]。宏的 executor 参数就是为这种情况留的,见宏。

InterruptExecutor 部分和 embassy-executor 的几乎逐字相同。线程模式不同:__pender 不执行 SEV,而是把一个 AtomicBool 置成 true;主循环用和 RISC-V 一样的「临界区里看标志」结构,只是睡觉那一步换成 low_power::sleep(cs):

                    critical_section::with(|cs| {
                        // if there is work to do, loop back to polling
                        if SIGNAL_WORK_THREAD_MODE.load(core::sync::atomic::Ordering::SeqCst) {
                            SIGNAL_WORK_THREAD_MODE.store(false, core::sync::atomic::Ordering::SeqCst);
                        } else {
                            // if not, sleep waiting for interrupt
                            crate::low_power::sleep(cs);
                        }
                    });

low_power::sleep 先调 configure_pwr:按当前 RCC 状态(哪些外设还开着)决定能进哪一级 STOP 模式,进不了就只做普通睡眠;能进就让时间驱动 pause_time、设置 PWR 的低功耗模式、置 SLEEPDEEP。然后 dsb; wfi,醒来后 on_wakeup_irq_or_event 恢复时钟和时间驱动。也就是说,同样是「没活就睡」,stm32 的版本睡得更深,代价是醒来要重新配置时钟。STOP 模式要求时间驱动能暂停和恢复,模块文档还说只有下一个定时器事件至少在 config.min_stop_pause 之后时才会尝试进入;时钟和时间驱动的细节见 init 与时钟 和时间驱动。

这个文件是从 cortex_m.rs 和 riscv32.rs 拼出来的,文档没跟上:Executor 的文档注释还是「Thread mode executor, using WFE/SEV」,而代码里没有 WFE 也没有 SEV;那个 AtomicBool 的注释是「global atomic used to keep track of whether there is work to do since sev() is not available on RISCV」,原样抄自 riscv32.rs。

动脑筋答案

same-exec=true noload=false: high late min 96366 max 96366 avg 96366 cycles over 20 rounds; low ran 21 busy rounds

每次都晚 96366 个周期,约 24.09 ms,20 次完全一样。稳态下的节奏是:low 忙等结束、.await 1 ms 定时器,执行器下一批才轮到早已在队列里的 high;high 定下 7 ms 后的定时器;1 ms 后 low 醒来,又忙等 30 ms;high 的定时器在 low 忙等开始后约 6 ms 到期,要等剩下的约 24 ms,low 交出 CPU 后才运行。之后每一轮都回到同一个相位,所以最小值等于最大值。和配置 1 的 214 个周期比,同样的负载,延迟差了 450 倍:线程模式执行器里没有抢占,一个任务不 .await,其他任务只能等。

小结

  • 平台只写主循环和 __pender;context 指针让同一个 __pender 区分多个执行器。
  • Cortex-M 线程模式是「poll;WFE」,__pender 是 SEV;事件寄存器保证 poll 和 WFE 之间的唤醒不会丢。
  • InterruptExecutor 在中断里 poll,__pender 写 STIR 挂起该中断;中断优先级就是任务优先级,必须在 start 之前设好。
  • QEMU 实测(虚拟时间):同一执行器里高优先级任务要等忙等的任务约 24 ms,放进 InterruptExecutor 后是 214 到 222 个周期。
  • RISC-V、AVR、stm32 低功耗执行器都用「临界区里检查标志再 wfi/sleep」代替事件寄存器。
  • 0.10.0 的 platform-* 改名发生在 CHANGELOG 写完之后,宏的报错和 compile_error! 还在用 arch-*。

💬 关联讨论

发起讨论
加载中...