Rust in Everything
基于 embassy-executor 0.10.0

设计取舍

主线位置:三条主线的汇总。定时器主线上的取舍是「静态任务」「中断唤醒加 WFE」「链接期符号做插件」;中断主线上的是「单线程执行器加 InterruptExecutor」「代码生成」「Peri 与绑定」;跨任务主线上的是「RawMutex 类型参数」和「等待者只占一个槽」;「多 crate 各自发版」横跨全卷。每一条只说收益和代价,机制在链接指向的页面里。

flowchart TB
  st["静态任务池"] --> nojh["不分配 数量定死"]
  wfe["中断唤醒 WFE"] --> drv["醒几次看驱动"]
  one["单线程执行器"] --> ie["抢占靠中断"]
  sym["链接期符号"] --> link["错误推到链接"]
  rm["RawMutex 参数"] --> mono["每种 M 一份码"]
  slot["单槽 waker"] --> busy["多人等就忙转"]
  gen["代码生成"] --> chips["一份驱动多芯片"]
  peri["Peri 与绑定"] --> ce["编译期报错"]
  rel["各自发版"] --> drift["包 tag 文档漂移"]

每一节回答三个问题:embassy 选了什么,换来了什么,付出了什么。依据只有三种:本卷各页和本页跑出来的数,各 crate 在锁定 tag 上的源码与提交记录,tokio 卷的页面。第 12 节是本页补的实验。std 实验的平台是 M2 Max、macOS 27、rustc 1.99.0;QEMU 实验是 QEMU 11.1.2 b-l475e-iot01a,embassy-stm32 用注释掉 rcc/bd.rs 里 while !cr.read().dbp() {} 那一行的补丁版(QEMU 没有 PWR 模型),指令数来自 -icount,不是真芯片上的周期数。RTOS 和 RTIC 本机没有源码也没有实验,只在结构上对照,不比实现细节。

1. 静态任务,不用堆

选择。 每个 #[embassy_executor::task] 函数背后是一个 static 的 TaskPool,pool_size 个 TaskStorage,每格是任务头加一块未初始化的 future 内存;初值全零,整个池落在 .bss(任务存储)。池满时调用任务函数就返回 SpawnError::Busy,没有 JoinHandle。tokio 的 spawn 每次在堆上分配一块,任务头、future 和尾部放在一起,引用计数和状态共用一个原子(任务的内存布局、tokio 的设计取舍)。

换来。

  • spawn 不分配。本站两个后台循环搬到 std 执行器上,spawn 是 0 次分配;tokio 每次 spawn 一次堆分配,152 字节的 future 分配 256 字节,1000 个任务是 1002 次分配(本站的定时循环搬到 embassy)。
  • RAM 在链接时就定了。thumbv7em 上一个空任务一格 48 字节,pool_size = 8 的池严格是 8 格,没有额外簿记;README 的说法是「If tasks don't fit in RAM, this is detected at compile time by the linker」(任务存储)。
  • waker 是任务头指针加一张静态 vtable,clone 只复制指针,drop 什么也不做,不用引用计数(状态与运行队列)。tokio 的 waker 要维护引用计数,因为任务内存要回收。
  • future 原地构造,不先在栈上建好再拷进 static,几百字节的 future 不会撑爆几 KB 的栈(任务存储)。

付出。

  • 任务数编译期定死。连接数这种由外部决定的数量放不进去:第 pool_size + 1 个只能拿到 Busy(本站的定时循环搬到 embassy)。
  • 内存不归还。任务结束后那一格一直占着,只能被同一个任务函数的下一次调用复用(任务存储)。
  • 每格为定时器队列预留 24 字节,用不用定时器都付。只要依赖图里有 embassy-time-queue-utils,任务头就从 16 字节变成 40 字节,空任务一格从 24 字节变成 48 字节(源码地图),而且 feature 在依赖图里统一,执行器自己并不知道(任务存储)。
  • future 有多大,池就有多大,而 future 的大小不直观:把一个 56 字节的 future 当参数传给一个只有 .await 的 async fn,结果是 120 字节(写一个 mini 执行器)。
  • 宏拒绝泛型任务、impl Trait 参数和非 'static 引用,任务参数必须能活到程序结束(宏)。
  • 开 scheduler-deadline 后截止时间的初值是 u64::MAX,初值不再全零,所有任务池从 .bss 挪进 .data,flash 多 2552 字节(调度策略)。「放得进 .bss」这个性质靠的是每个字段都恰好初始化成零,一个字段就能打破。

2. 中断直接唤醒,空闲时 WFE

选择。 Cortex-M 线程模式执行器的 run 是 loop { poll(); wfe },__pender 是一条 sev。叶子 future 登记 waker,外设的中断处理函数直接调它,wake_task 一次原子交换、压进运行队列;poll 只 poll 被唤醒的任务(各平台的执行器)。tokio 的 worker 没活干时停在 I/O 驱动里(epoll_wait / kevent),事件由驱动处理后再唤醒任务(驱动栈与 park)。

换来。

  • 唤醒路径短。QEMU 上一次定时器唤醒,从 SysTick 中断入口到任务在 .await 之后继续约 170 条指令(一次 Timer::after);写 SWIER 触发 EXTI 到等待任务继续约 320 条,中断处理函数本身约 117 条(一次中断唤醒)。中间没有系统调用,没有驱动线程,只有关中断的临界区和一次原子交换。
  • 唤醒多少次都只 poll 一次:同一个任务被唤醒三次,RUN_QUEUED 只从 0 变 1 一次(状态与运行队列)。
  • SEV 置位的是事件寄存器,poll 返回后、WFE 之前来的唤醒不会丢(各平台的执行器)。
  • 写法是顺序的 async fn,进度在编译器生成的 future 里,而不是手写主循环里的全局变量。代价可以量:同样两个周期任务,embassy 版比手写主循环多 2380 字节 .text、856 字节 .rodata、276 字节 .bss(embassy 解决什么问题)。和一个 474 行的手写执行器比,embassy 全家的 .text 是 7208 对 7308 字节(写一个 mini 执行器)。

付出。

  • 「空闲时睡」睡得多好,取决于时间驱动。本卷 QEMU 实验的 SysTick 驱动每毫秒一个中断,100 ms 的等待里执行器 poll 了 200 次,只有最后一次有任务可跑(QEMU 处理完异常后事件寄存器是置位的,每个空闲 tick 空转两圈);embassy-stm32 的 TIM 驱动只在闹钟和每 32768 tick 一次的半圈中断时醒(一次 Timer::after、时间驱动)。
  • 线程模式执行器只会 WFE,不会进更深的睡眠。进 STOP 模式要换 embassy-stm32 自己的低功耗执行器,它用 WFI,醒来后要恢复时钟和时间驱动;本卷只读了它的源码(各平台的执行器)。
  • 「No busy-loop polling」只在每个等待点只有一个等待者时成立。第 6 节的几种用法会让执行器一刻不睡:通用定时器队列满了,两个任务互相挤,到定时器到期前各被 poll 约 194 万次(定时器队列);第 6 节是其余几种。
  • RTOS 每个线程一个栈,阻塞调用由内核切换;embassy 只有一个栈,代价是 future 的大小在编译期定死(第 1 节)。仓库 README 说 embassy 比 RTOS「faster and smaller」,链接的是一篇外部文章,本机没有 RTOS 可以复核;书的 overview.adoc 反过来承认「program size may be higher than other alternatives」,和上面主循环那组数一致。

3. 一个执行器一个线程,抢占靠 InterruptExecutor

选择。 一个执行器里的任务协作式地轮流跑,Executor 不是 Send。默认的出队一次取走整批、批内后进先出,批与批之间公平(状态与运行队列)。要抢占,就再开一个 InterruptExecutor:借一个空闲中断,在中断处理函数里 poll,中断优先级就是这一组任务的优先级(各平台的执行器)。0.10.0 新增的 scheduler-priority / scheduler-deadline 只改同一个执行器里的出队顺序(调度策略)。RTIC 是把每件事写成中断处理函数、用 NVIC 优先级调度(embassy 解决什么问题);InterruptExecutor 在一个优先级里放的是一组协作的 future,而不是一个处理函数。tokio 的多线程运行时靠多个 worker 和任务窃取,它的协作式预算也不抢占(协作式预算)。

换来。

  • 抢占由硬件做,代价是一次尾链进中断。QEMU 上一个任务忙等 30 ms,同一个执行器里的高优先级任务每次晚 96366 个周期(约 24 ms);放进 UART4 上的 InterruptExecutor 后晚 214 到 222 个周期,差 450 倍(各平台的执行器)。
  • 同一个执行器里的任务之间不需要锁:NoopRawMutex 什么也不做就是安全的(第 5 节)。
  • 一批跑完才开始下一批,一个不停唤醒自己的任务挡不住别人(状态与运行队列)。

付出。

  • 一个任务不 .await,整个执行器停下。std 上搜索刷新阻塞线程 350 ms,同一个执行器里的心跳最晚 356 ms,和 tokio current_thread 一样;tokio 多线程运行时只少一个 worker,心跳最晚 2 ms(本站的定时循环搬到 embassy)。
  • 批内顺序每批翻一次:A、B、C、D 依次 spawn、各 yield 三次,顺序是 D0 C0 B0 A0 A1 B1 C1 D1 D2 C2 B2 A2(状态与运行队列)。依赖「先 spawn 的先跑」的代码会得到相反的结果。
  • 开了 scheduler-*,README 的「Fair」不再成立:优先级最高的任务连续 yield 会连跑到底,而且仍然不抢占(调度策略)。
  • 每个 InterruptExecutor 要占一个用户用不到的中断向量(STM32 没有专门的软件中断,例子借 UART4、UART5),优先级必须在 start 之前设好;跨执行器只能用 SendSpawner 送 Send 的任务,共享数据要用 CriticalSectionRawMutex(各平台的执行器、Spawner)。
  • 抢占解决的是「谁先跑」,不是「阻塞的代码不占 CPU」:忙等的任务照样占着处理器,只是被打断(本站的定时循环搬到 embassy)。

4. 链接期符号做插件,而不是 trait 泛型

选择。 几个可替换的部件都靠 extern "Rust" 声明、由另一方 #[unsafe(no_mangle)] 定义的符号接上,链接器配对(源码地图):

符号谁声明谁定义
_embassy_time_now、_embassy_time_schedule_wakeembassy-time-driver时间驱动,经 time_driver_impl!
__embassy_time_queue_item_from_wakerembassy-executor-timer-queue执行器(raw/mod.rs)
__pender执行器的 Pender::pend所选的 platform-*
_embassy_trace_*(七个)执行器的 trace feature用户
_critical_section_1_0_acquire / releasecritical-section cratecortex-m 的 critical-section-single-core、std 实现或 HAL

TICK_HZ 走同一个思路,只是它是常量,用 161 个 tick-hz-* feature 选(Instant 与 Duration)。tokio 的定时器由运行时自己的时间驱动处理(驱动栈与 park),tokio::time::sleep 创建时要找当前线程上的运行时句柄,找不到就 panic(本站的定时循环搬到 embassy)。

换来。

  • 没有类型参数。Driver 只有 now 和 schedule_wake,Timer、Instant 都不带驱动的类型参数,库可以直接用时间;全程序只有一个驱动,任意两个 Instant 比较才有意义(时间驱动)。
  • crate 之间不互相依赖:executor 和 time 在 Cargo 层面互不依赖,HAL 依赖的是 embassy-time-driver 而不是执行器(源码地图)。
  • 同一份 CriticalSectionRawMutex,在单核 Cortex-M 上是读写 PRIMASK 的几条指令,在 std 上是一把全局 Mutex,在 RP2040 上是硬件自旋锁,embassy-sync 不用改(原始互斥)。

付出。

  • 错误推到链接期,而且看链接器:没有驱动是 Undefined symbols: _embassy_time_now;两个驱动在同一个 crate 是编译错误,分在两个 crate 时 rust-lld 报 duplicate symbol,macOS 的 ld64 在 debug 下只给一条警告、release 下一声不吭地选一个(时间驱动)。模块文档说「linking will fail」,后半句在 macOS 上不成立。
  • 开了 trace 就要把七个钩子全部定义,少一个是链接错误,缺几个取决于程序用到哪些(调度策略)。
  • waker 和执行器之间的约定只能在运行期检查:集成定时器队列从 waker 反查任务头,waker 不是 embassy 执行器造的就 panic,没链接执行器则是链接错误(定时器队列)。
  • feature 在依赖图里统一:两个 crate 选了不同的 tick-hz-* 是 E0428 the name TICK_HZ is defined multiple times(怎么读这一卷)。
  • 不开 LTO 时,跨 crate 的这几个符号是真的函数调用,内联不过去。第 12 节的实验:模板的 ticker 程序里有三处 bl _embassy_time_now / bl _embassy_time_schedule_wake;开 lto = "fat" 后这两个符号整个消失,.text 从 10560 字节降到 8460 字节。time_driver_impl! 两头的 #[inline] 是 embassy-time-driver 0.2.1 加的(#3840);这个实验说明,embassy-time 里的调用方(Instant::now、Timer::poll)要开 LTO 才内联得过去。本卷各页的体积数都没开 LTO。

5. RawMutex 类型参数

选择。 embassy-sync 的每个类型都带一个 M: RawMutex 参数。RawMutex 只有一个关联常量 INIT 和一个闭包式的 lock,允许重入,只保证 & 访问,所以可变状态要再包一层 RefCell。三种实现:关中断的 CriticalSectionRawMutex、什么也不做的 NoopRawMutex、断言在线程模式的 ThreadModeRawMutex(原始互斥)。tokio 的 Mutex、Semaphore 没有这个参数,底座是一个原子计数加一把锁保护的等待链表(批量信号量)。

换来。

  • 同一份 Channel 代码用在三种上下文,开销按上下文付。第 12 节在 QEMU 上量了一个 Channel<M, u32, 1> 的 ping-pong:每个来回 NoopRawMutex 511 条指令,ThreadModeRawMutex 581 条,CriticalSectionRawMutex 615 条。std 上 NoopRawMutex 每个来回 69 ns,CriticalSectionRawMutex 93 ns,tokio current_thread 加 tokio 的 mpsc 是 164 到 167 ns。
  • 用错的一部分在编译期拦下:NoopRawMutex 不是 Sync,static CH: Channel<NoopRawMutex, ..> 是 E0277(原始互斥)。
  • 运行期用错也会当场暴露,而不是悄悄丢消息:中断里碰 NoopRawMutex 的 channel panic 在 try_borrow_mut,碰 ThreadModeRawMutex 的 panic 在断言(同一页)。
📝NOTE
冷知识:RawMutex 是为稳定版 Rust 改出来的

PR #613「Rust stable support」2022-02-12 合并,它带来的提交 6c925b234 叫「blocking_mutex: refactor to work on stable. No GATs, and can be constructed in const.」。在这之前,类型参数是一个 MutexKind trait,里面是泛型关联类型 type Mutex<T>: super::Mutex<Data = T>,CriticalSection、ThreadMode、Noop 是三个空枚举,Channel 写成 where M: MutexKind;那时 embassy crate 开着 #![feature(generic_associated_types)],只能用 nightly。改成今天的 RawMutex 之后,「锁」本身是一个值,Mutex<R, T> 是 R 加 UnsafeCell<T>,不需要泛型关联类型;const INIT: Self 让 static 里能造出它。PR 说明写的是「This PR adds (limited) stable Rust support!」,并列了还缺的东西,其中一条是「Some stuff is not constructible in const (requires const_fn_trait_bound), although there's an (ugly) workaround for the generic Mutex.」。当时 embassy-sync 还没有拆出来,首个包含这次改动的 embassy-sync tag 是 v0.1.0(2022-10-26)。

付出。

  • 每换一种 M 就单态化一份代码。第 12 节的 ping-pong,.text 是 9284(Noop)、9324(CS)、9372(ThreadMode)字节,ThreadModeRawMutex 的 .rodata 还多 196 字节的 panic 信息;原始互斥 一页的同类程序差 232 字节。
  • 类型签名带着 M 和容量:Sender<'ch, M, T, N> 写进结构体字段很啰嗦,于是另有一套 DynamicSender(Channel)。
  • 选错 M 的后果只有一部分在编译期:ThreadModeRawMutex 是 Sync 的,放进 static 毫无问题,中断里碰它才 panic。
  • CriticalSectionRawMutex 在 std 上是一把进程级的锁,所有用它的 channel 共用(原始互斥)。在 Cortex-M 上它经第 4 节的链接期符号调 _critical_section_1_0_acquire,不开 LTO 时每次加锁都是一次函数调用:上面 615 对 511 的差,开 lto = "fat" 后从 104 条缩到 28 条(第 12 节)。

6. 等待者只占一个槽

选择。 Channel 的每一侧、Mutex、RwLock、GreedySemaphore、Signal 都只有一个 waker 槽:WakerRegistration 是一个 Option<Waker>,换成另一个任务的 waker 时先把旧的叫醒;MultiWakerRegistration<N> 满了就全部叫醒;只有 FairSemaphore 按票号排队(等待队列、async Mutex 与 Semaphore)。tokio 的 Semaphore、Mutex、RwLock 共用一条锁保护的侵入式等待链表,严格先来先得(批量信号量)。

换来。 不分配,大小固定,可以是 static:Channel<CriticalSectionRawMutex, u32, 4> 在 thumbv7em 上 48 字节(Channel)。只有一个等待者时,一次 wake 正好叫醒它,执行器在 WFE 里睡着。

付出。 两个以上的任务等同一个东西时,它们互相把对方挤出槽、互相叫醒,执行器不睡,拿到的顺序也不固定:

场景实测页面
三个发送方等一个满的 Channelstd 上前 100 ms 一共被 poll 340 万次;QEMU 上两个发送方 100 ms 各 27.5 万次Channel
一个任务持锁、三个任务等一个 Mutex每个等待者被 poll 约一百万次,拿锁顺序不固定async Mutex 与 Semaphore
同样的负载换成 FairSemaphore按到达顺序,每个等待者只被 poll 2 次同上
两个任务等一个 Signal互相叫醒直到有值;值只给一个,另一个睡下去Signal、Watch 与 PubSub
两个任务 wait_config_up1 秒里各被 poll 约 1434 万次Stack 与 Runner
RwLock 有读者持续进来写者饿死async Mutex 与 Semaphore

WakerRegistration 自己的注释承认这一点:「If two tasks are waiting on the same thing concurrently, this will cause them to wake each other in a loop fighting over this WakerRegistration. This wastes CPU but things will still work.」在电池供电的板子上,「wastes CPU」就是功耗。

7. HAL 用代码生成覆盖整个产品线

选择。 embassy-stm32 不为每款芯片手写外设表。芯片 feature 在 stm32-metapac 21.0.0 里选中一份元数据(1533 个芯片目录、414 个按版本共享的寄存器块文件),2762 行的 build.rs 把它写成 cfg、_generated.rs、_macros.rs 和 memory.x(芯片数据与代码生成)。

换来。

  • 一份驱动源码覆盖整个产品线:0.6.0 列了 1525 个芯片 feature;usart/mod.rs 一个文件用 usart_v1 到 usart_v4 覆盖 F1、F4、L4、G0(同一页)。
  • 引脚复用、DMA 请求号、RCC 使能位、中断归属都变成生成的 trait 实现,用错是编译错误,报错指回 _generated.rs 的具体行,并列出合法的引脚(PA10、PB7)(同一页)。L475 上生成 169 个外设单例、315 条引脚实现、94 条 DMA 实现、80 个中断。

付出。

  • 体量。embassy-stm32 的 src/ 有 83273 行,是本卷其他 crate 加起来的 2.4 倍(源码地图);metapac 解压后 242 MB;L475 一款芯片生成 4434 行代码(芯片数据与代码生成)。
  • 生成的代码不在仓库里,要到 OUT_DIR 去读;字节数随本机的 rustfmt 配置变(同一页)。build.rs 里还留着五张宏表,旁边的注释是「DO NOT ADD more macros like these. These turned to be a bad idea!」。
  • 数据描述的是芯片手册,不是硬件当下的状态:init 里的就绪等待都没有超时,硬件不回应就死等;QEMU 上 PWR 的 DBP、FLASH 的 LATENCY、F4 的 HSIRDY 三处永不退出(init 与时钟)。
  • 同一个外设版本 cfg 下仍有模型差异:QEMU 的 USART 只接受 32 位访问,驱动对 TDR 的 8 位写触发总线错误(USART 与 DMA)。这是 QEMU 的问题,不是驱动的,但它说明驱动的正确性绑在「数据和真硬件一致」上。

同一套做法在另外两个 HAL 上各有变化。embassy-nrf 0.10.0(和本卷同一批发布)没有 build.rs,芯片描述是 src/chips/ 下 16 个手写文件,时间驱动用 24 位的 RTC1,time-driver-rtc1 把 tick-hz-32_768 定死,calc_now 用和 stm32 一样的奇偶修正、每 2^23 tick 一个 period,注释算出 34865 年才回绕。embassy-rp 0.10.0 的 TIMER 本身就是 64 位、1 MHz 的计数器(time-driver 定死 tick-hz-1_000_000),now 按高、低、高三次读出两半,不需要 period 扩展,build.rs 只有 39 行。

8. 外设所有权和中断绑定放在编译期

选择。 embassy_stm32::init 返回的 Peripherals 是 169 个零大小的令牌;驱动构造函数吃掉 Peri<'d, T>,借用检查器保证一个引脚只进一个驱动(外设单例)。bind_interrupts! 生成名字等于中断名的向量函数和 unsafe impl Binding,驱动要一个 impl Binding<..> 参数当证明(中断绑定)。

换来。

  • 零开销:Peri<PB14> 0 字节,&mut PB14 是 4 字节,整个 Peripherals 0 字节;生命周期不产生单态化(外设单例)。
  • 移走、重复借用、借用逃出作用域分别是 E0382、E0499、E0597;漏绑、绑错向量是 E0277,报错里同时列出需要的和已有的绑定(外设单例、中断绑定)。
  • 运行期没有中断注册表:向量表那一项由链接器直接指向宏生成的函数,EXTI0 的反汇编最后一条就是跳进 on_irq(一次中断唤醒)。唯一的运行期检查是 init 第二次调用时 panic。

付出。

  • 后门是 unsafe 的 steal、clone_unchecked,正确性交给调用者(外设单例)。
  • 类型擦除和编译期证明互相排斥:EXTI 改成要绑定之后(#4922,0.5.0),ExtiInput 不再接受 AnyChannel,因为构造时要在编译期知道是哪个向量(一次中断唤醒)。
  • Priority 总按 4 个优先级位生成 16 级,在只有 2 位或 3 位的芯片上相邻几级会合并(中断绑定)。
  • bind_interrupts! 用了 $crate,放不进 embassy-hal-internal,每个 HAL 各有一份(stm32、nrf、rp)。

9. 几十个 crate 各自发版

选择。 没有总版本号。仓库里 34 个 embassy-* crate 有 tag,一共 197 个;本卷锁定的那一批里,84444a19e 一个提交上打了 27 个 tag,之后 queue-utils 0.3.2、net 0.9.1、usb-driver 0.2.2 又各自单独发了补丁版(怎么读这一卷)。

换来。 小 crate 可以独立演进:embassy-executor-timer-queue 的文档写明拆出来是为了「decouple the timer queue's release cycle from embassy-executor」(源码地图);net 0.9.1 只改了 12 行就单独发布(Stack 与 Runner)。embassy-futures、embassy-sync 不依赖执行器,用在 tokio 下也行。

付出。

  • crates.io 上的包和 tag 不完全一致:embassy-embedded-hal 0.6.0 的包是在把 embassy-hal-internal 依赖改成 0.5.0 之前发的,一个新项目里于是同时有 hal-internal 0.4.0 和 0.5.0;executor 包里写的 embassy-time-driver 是 0.2.1,tag 上是 0.2.2(源码地图)。
  • CHANGELOG 和发布对不上:executor 0.10.0 的 CHANGELOG 段落标着 2026-03-10,比 platform-* 改名的合并早两天,里面没有这次改名,还写着旧名字;旧名字没有别名,照旧文档写 arch-std 的项目在依赖解析时就失败(各平台的执行器)。第 11 节是这一类的完整清单。
  • 依赖图里的 feature 统一跨 crate 生效(第 4 节的 TICK_HZ、第 1 节的定时器队列项),出问题时报错的 crate 往往不是选错 feature 的那个。

10. 上游问题记录

本卷各页复现过的上游问题(不是设计如此,是和文档或自己的约定不一致),以 2026-10-27 的 main(c2413d599)为准分三类。「已发版修复」指锁定的版本里已经修好、本卷是在旧版本或复原旧代码上复现的。

0.x 发布版上有,main 上也没改:

问题页面
PriorityChannel::remove_if 腾出空位不叫醒等待的发送方Channel
开 scheduler-deadline 后所有任务池进 .data(截止时间初值 u64::MAX,注释说是零)调度策略
通用定时器队列满了提前唤醒最后一项(ConstGenericQueue::schedule_wake),任务互相挤、活锁忙转定时器队列
stm32 TIM 驱动在默认 1 MHz 下 4.46 年回绕,达不到 Driver::now 要求的一万年;预分频除不尽不检查(main 上文件改名 tim.rs,「136 years」的注释和 psc division overflow 的检查都还在)时间驱动
as_nanos 在 32768 Hz 下 9.1 年溢出,release 下静默给错数Instant 与 Duration
USB 字符串描述符的断言差一字节(64 字节的 control_buf 放不下 31 个字符),超过 255 字节时 bLength 被截断设备栈
ConnectError::TimedOut 从不构造,set_timeout 到期返回 ConnectionReset(main 上 tcp.rs 随 xarxa 重写,TimedOut 仍只出现在定义和错误映射里)TCP 与 UDP 套接字
drop TcpSocket / TcpConnection 不发 FIN(main 上 drop 仍直接删槽,换成 xarxa 后的线上行为本页没有复核)TCP 与 UDP 套接字

main 上已修(或已删掉),还没有发版:

问题main 上的改动页面
没开平台 feature 时宏的报错、各平台的 compile_error! 还写 arch-*#6631(2026-07-27),提交 c4a30fd66各平台的执行器
BufferedUartTx::write 空间不够时返回 Pending 却不登记 waker#6535(2026-07-17)USART 与 DMA
PriorityChannel::poll_ready_to_send 恒为 Pending#5996(2026-04-30)Channel
zerocopy_channel::Sender::clear 造出别名 &mut#6004(2026-05-01),删掉 clearChannel
OnceLock 用 Relaxed,只在单核上成立#6000(2026-05-08)async Mutex 与 Semaphore
FairSemaphore 排头的等待者被取消时丢一次唤醒#6964(2026-09-14)async Mutex 与 Semaphore
rtos-trace 的 TaskTracker 在同一任务第二次 spawn 后成环bc866462b(2026-09-01)整个删掉 rtos-trace调度策略
全速设备也回答 DEVICE_QUALIFIER#5981(2026-04-28)设备栈
CDC-ACM 的 flush 写满整包后不补空包50ed5dd11(2026-10-22)类
packet-trace 加 log 编不过([u8]: LowerHex,0.5.0 起)daac2c6ab(2026-08-29)换成 xarxa 时这个 feature 不存在了驱动接口

已发版修复:

问题修复页面
queue-utils 0.3.0 移出 u64::MAX 节点时没清 waker,同一任务之后的定时器全部失效#4736,0.3.1定时器队列
select_slice 不健全(被 poll 过的 future 可以被移走)#3328,embassy-futures 0.1.2embassy-futures
#[task] 的 impl Trait 参数导致越界访问executor 0.6.1 起拒绝宏
驱动发送队列满时 Runner 忙转#5870,embassy-net 0.9.1Stack 与 Runner
UDP 报文比发送缓冲区还大时 send_to 永远等下去#3804,embassy-net 0.7.1TCP 与 UDP 套接字
CDC-ACM 的 BufferedReceiver 被取消后重复交出上一包#4963,embassy-usb 0.6.0类

第一类里的八条,加上第二类里还没发版的十条,都是用锁定版本的项目今天就会碰到的。本页只记录,没有向上游提交任何东西。

11. 文档与代码不符

本卷各页核对出的上游文档、README、CHANGELOG、注释与锁定 tag 上的代码不符之处,本页逐条在 tag 上复核过,又补了 4 处(标「本页」):

文档说法代码页面
书 runtime.adoc任务让出后「enqueues the task at the end of the run queue」返回 Pending 的任务不入队;一批里后进先出embassy 解决什么问题
书 runtime.adoctick 率用 time-tick-<frequency> 选,只有 1000 Hz、32768 Hz、1 MHzfeature 叫 tick-hz-*,有 161 个本页;Instant 与 Duration
executor README「Fair: ... All other tasks get a chance to run before a given task gets polled for the second time」开 scheduler-* 后不成立调度策略
executor CHANGELOG 0.10.0「The feature name arch-cortex-ar remains the same」;run_until 加在「arch-std」上feature 已改名 platform-*(#5099),CHANGELOG 没记各平台的执行器
executor-macros 0.8.0 的报错、platform/*.rs 的 compile_error!「enable one of the arch-* features」没有 arch-* feature各平台的执行器
TaskStorage::spawn / TaskPool::spawn 注释池满时返回「a "poisoned" SpawnToken」返回 Result,Err(SpawnError::Busy)任务存储
run_enqueue 注释(三种实现)「Mark the task as run-queued if it's spawned」只看 RUN_QUEUED,不看 SPAWNED状态与运行队列
Metadata::new 注释截止时间「set to zero to allow the initializer to reside in .bss」Deadline::new_unset(),即 u64::MAX,任务池进 .data调度策略
increment_deadline 注释「Returns the deadline that was set」返回 ()调度策略
embassy-stm32 Executor 注释「Thread mode executor, using WFE/SEV」;标志「since sev() is not available on RISCV」没有 WFE 也没有 SEV,用 AtomicBool 加 WFI;注释抄自 riscv32.rs各平台的执行器
embassy-time-driver 模块文档「If there is none or multiple drivers in the crate tree, linking will fail」macOS 的 ld64 在两个驱动分在两个 crate 时只警告或不报时间驱动
stm32 gp16.rs 注释period 溢出要「136 years」只在 32768 Hz 下成立;默认 1 MHz 下 4.46 年时间驱动
next_expiration 文档「The provided callback will be called for each expired task」没有回调参数定时器队列
QueueItem 的 next 注释最后一项是「Some(dangling_pointer)」最后一项是 None;判断在不在队列里看的是 waker定时器队列
Duration::from_secs 文档「rounding up」secs * TICK_HZ,精确,没有可取整的余数Instant 与 Duration
embassy-executor-timer-queue CHANGELOG标题是「Changelog for embassy-time-queue-utils」,段落是「Unreeased」是另一个 crate,且 0.1.0 已发布本页
embassy-time CHANGELOG 0.5.0只写「Manually implement the future for with_timeout」截止时刻从第一次 poll 改成调用时算(#4123)Timer、Ticker 与超时
embassy-time CHANGELOG没有提 Ticker::reset / reset_at / reset_after0.4.0 起都在本站的定时循环搬到 embassy
select 文档「The other future is dropped」输家和赢家一起在 Select 被 drop 时才 dropembassy-futures
embassy-stm32 CHANGELOG 0.6.0没有提 BufferedUartTx::write 的行为变化改成整段或不写,空间不够时不登记 waker(#5170)USART 与 DMA
cdc_acm::USB_CLASS_CDC 文档「This should be used as device_class」照做、其余配置用默认值时 panic设备栈
embassy-usb CHANGELOG0.6.0 段写「Bump usbd-hid from 0.8.1 to 0.9.0」,「Unreleased」段写「from 0.9.0 to 0.10.0」tag 上 Cargo.toml 已是 usbd-hid = "0.10.0"本页
CDC-ACM 功能描述符bmCapabilities 是 0x02,声称支持 Serial_State 通知通知端点 _comm_ep 从没被写过类
TxRunner::tx_buf 文档「Wait until there is space for more outbound packets and return a slice they can be copied into」等的是一个待发出的帧;文档抄自 rx_buf驱动接口
embassy-net CHANGELOG 0.6「The std feature has been removed」0.9.1 有 std feature,2025-03-20 的 c7e997a8d(「embassy-net: Add std and alloc features」)加回来,CHANGELOG 没记本页

25 处里 10 处在 CHANGELOG、README 和书里,15 处在源码注释、rustdoc 和报错信息里。CHANGELOG 的问题集中在一个模式上:条目在发版前几天写好,之后合并的改动(改名、语义变化)没有补进去。本卷的规矩因此是:上游文档当资料读,每一条核对 tag 上的源码或跑一遍。

12. 实测:任务间 ping-pong 与 LTO

前面各页量的是单次唤醒;这一节量一个更常见的形状:两个任务经两个容量为 1 的 channel 来回传一个 u32,ping 发、pong 收了再发回来。每个来回包括两次 send、两次 receive、两次跨任务唤醒,走的是完整的跨任务主线。

std:embassy 对照 tokio

exp-embassy/design-std 的 pingpong(release,M2 Max、macOS 27、rustc 1.99.0)。embassy 一侧是 platform-std 执行器加 Channel<M, u32, 1>,M 取 NoopRawMutex 和 CriticalSectionRawMutex;tokio 1.54.1 一侧是 tokio::sync::mpsc::channel(1),分三种:current_thread 运行时;多线程运行时、ping 跑在 block_on 的主线程上;多线程运行时、两端都是 spawn 出来的任务。每种 20 万个来回为一轮,跑 7 轮,报每个来回的最短、中位、最长:

async fn ping<M: RawMutex>(to: &'static Ch<M>, back: &'static Ch<M>, label: &'static str) {
  let mut per_rt = Vec::new();
  for _ in 0..REPS {
    let t = StdInstant::now();
    for i in 0..N {
      to.send(i).await;
      let v = back.receive().await;
      assert_eq!(v, i);
    }
    per_rt.push(t.elapsed().as_nanos() as f64 / N as f64);
  }
  report(label, per_rt);
  std::process::exit(0);
}

pong 是 loop { let v = from.receive().await; back.send(v).await; };tokio 一侧逐行对应。五个变体依次跑,整组跑三遍(负载 4.6 到 4.8),第一遍原文:

embassy-noop   200000 round trips x 7: min 69.0 ns  median 69.2 ns  max 71.1 ns per round trip
embassy-cs     200000 round trips x 7: min 94.2 ns  median 96.6 ns  max 100.3 ns per round trip
tokio-ct       200000 round trips x 7: min 164.6 ns  median 166.5 ns  max 169.4 ns per round trip
tokio-mt       200000 round trips x 7: min 7101.5 ns  median 7252.5 ns  max 7400.1 ns per round trip
tokio-mt-spawn 200000 round trips x 7: min 192.3 ns  median 194.6 ns  max 195.5 ns per round trip

三遍的中位数范围:

变体每个来回说明
embassy,NoopRawMutex69.0–69.3 ns一个线程,锁什么也不做
embassy,CriticalSectionRawMutex93.0–96.6 nsstd 的临界区是一把进程级 Mutex
tokio current_thread164.3–167.2 ns一个线程
tokio 多线程,ping 在 block_on 线程7.1–7.3 µs每条消息都跨线程唤醒
tokio 多线程,两端都 spawn178.7–197.6 ns
  • 同样是一个线程,embassy 的来回比 tokio current_thread 快一倍多。本页没有拆这 95 ns 花在哪;结构上能确定的差别是 embassy 的 waker clone / drop 什么也不做、Channel 的状态在一把(可以为空的)锁和一个 RefCell 里,tokio 的 mpsc 是无锁的块链表加一个信号量(mpsc),任务有引用计数。
  • tokio 多线程的两行说明,单线程的快来自「不跨线程」:同一个运行时,ping 留在 block_on 的线程上、pong 在 worker 上,每个来回慢到 7 µs,两端都 spawn 之后回到两百纳秒以内。embassy 的一个执行器就是一个线程,跨执行器的通信要用 CriticalSectionRawMutex,也就要付线程间同步的钱。
  • 这组数只说明执行器和 channel 本身的开销。服务器上的任务大多在等 I/O,这几十纳秒不会是瓶颈(本站的定时循环搬到 embassy)。

QEMU:三种 RawMutex,开不开 LTO

exp-embassy/design-qemu 的 pingpong:同样的两个任务,M 由 feature 选三种;SysTick 不开中断,当一个 24 位的自由计数器用(4 MHz,一个计数 250 ns),先后量 0、1000、2000 个来回。QEMU 11.1.2 b-l475e-iot01a,PWR 一行补丁,release opt-level = "s",-icount shift=8,sleep=off:每条指令 256 ns,所以「计数 × 250 / 256」就是指令数;全程没有中断,wfe 每次都因为 sev 立刻返回。每个程序跑两次,输出逐字相同。用 2000 减 1000 去掉固定开销:

M不开 LTO:指令 / 来回.text开 lto = "fat":指令 / 来回.text
NoopRawMutex51192844217212
ThreadModeRawMutex5819372——
CriticalSectionRawMutex61593244497240
❗IMPORTANT
动脑筋:同一个 ping-pong,NoopRawMutex 换成 CriticalSectionRawMutex,每个来回多几条指令?开 LTO 之后呢?

CriticalSectionRawMutex 在单核 Cortex-M 上是 cortex-m 的 critical-section-single-core:存 PRIMASK、cpsid i,出来时写回 PRIMASK,每次只有几条指令。先猜两个数,再看文末。

另一个实验看第 4 节的链接期符号:QEMU 模板原样的 ticker(两个周期任务,SysTick 驱动),不开和开 lto = "fat" 各编一次,llvm-nm 看符号、llvm-objdump 数调用:

== ticker-nolto
080005de T _embassy_time_now
080005fc T _embassy_time_schedule_wake
 80016b0:      	bl	0x80005de <_embassy_time_now> @ imm = #-0x10d6
 80016f0:      	bl	0x80005de <_embassy_time_now> @ imm = #-0x1116
 800170e:      	bl	0x80005fc <_embassy_time_schedule_wake> @ imm = #-0x1116
.text                10560   134218120
== ticker-lto
.text                 8460   134218120

不开 LTO 时,embassy-time 里的 Instant::now 和 Timer::poll 经 bl 调到二进制 crate 里 time_driver_impl! 定义的函数;开 LTO 后两个符号都内联掉了,.text 少 2100 字节(20%),两个程序在 QEMU 上的输出逐字相同。__embassy_time_queue_item_from_waker 开了 LTO 仍是一次 bl。

总表

取舍换来的付出的证据
1. 静态任务spawn 不分配;RAM 链接时定;waker 无引用计数任务数定死;内存不还;每格 24 字节定时器项;future 大小不直观任务存储、本站的定时循环搬到 embassy
2. 中断唤醒 + WFE定时器唤醒约 170 条指令,EXTI 约 320 条;只 poll 被唤醒的任务醒几次看时间驱动(SysTick 下 100 ms 空 poll 200 次);线程执行器不进 STOP一次 Timer::after、一次中断唤醒
3. 单线程执行器 + InterruptExecutor硬件抢占:214–222 周期对 96366 周期;同执行器内无锁一个任务阻塞全停(356 ms 对 2 ms);批内后进先出;scheduler-* 不公平也不抢占各平台的执行器、调度策略
4. 链接期符号没有驱动类型参数;executor 与 time 互不依赖错误推到链接期,ld64 可能不报;钩子要全定义;不开 LTO 是真调用(.text 10560 对 8460)时间驱动、本页实验
5. RawMutex 参数同一个 Channel 用在三种上下文;Noop 来回 511 条指令、69 ns每种 M 一份代码;CS 不开 LTO 多 104 条;ThreadMode 用错只在运行期报原始互斥、本页实验
6. 单槽 waker不分配,大小固定多个等待者互相叫醒、CPU 不睡,顺序不固定;只有 FairSemaphore 排队Channel、async Mutex 与 Semaphore
7. 代码生成1525 个芯片 feature 一份驱动;用错引脚是编译错误stm32 源码 83273 行;生成代码要去 OUT_DIR 读;就绪等待没有超时芯片数据与代码生成、init 与时钟
8. Peri 与绑定0 字节令牌;所有权和绑定错误都在编译期unsafe 后门;擦除类型与绑定互斥;每个 HAL 一份宏外设单例、中断绑定
9. 各自发版小 crate 独立演进,补丁版单独发包与 tag 不一致、两个 hal-internal;CHANGELOG 漏改名源码地图
10. 上游问题—8 处发布版和 main 上都在,10 处 main 已修未发版本页第 10 节
11. 文档—25 处文档与代码不符本页第 11 节

动脑筋答案

多 104 条;开 LTO 后只多 28 条。原始输出(2000 个来回那一行减 1000 个来回那一行,再乘 250 / 256):

mutex: embassy_sync::blocking_mutex::raw::NoopRawMutex
0 round trips: 7 SysTick counts
1000 round trips: 523236 SysTick counts
2000 round trips: 1046535 SysTick counts
mutex: embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex
0 round trips: 7 SysTick counts
1000 round trips: 629732 SysTick counts
2000 round trips: 1259527 SysTick counts

开 lto = "fat":

mutex: embassy_sync::blocking_mutex::raw::NoopRawMutex
0 round trips: 7 SysTick counts
1000 round trips: 431073 SysTick counts
2000 round trips: 862215 SysTick counts
mutex: embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex
0 round trips: 7 SysTick counts
1000 round trips: 459746 SysTick counts
2000 round trips: 919560 SysTick counts
  • 不开 LTO:Noop 每个来回 523299 / 1000 × 250 / 256 ≈ 511 条,CS 约 615 条。从开 LTO 前后的对比看,差出的 104 条大部分不是关中断本身,而是调用:CriticalSectionRawMutex::lock 经 critical-section crate 的链接期符号调到 _critical_section_1_0_acquire / release,这两个函数各只有几条指令(mrs r0, primask、cpsid i),但跨 crate 内联不过去,二进制里有 6 处 bl 调它们,每次还要压栈、出栈。
  • 开 LTO:两个符号从二进制里消失,cpsid 直接内联在 5 处,差距缩到 28 条(421 对 449)。Noop 自己也从 511 条降到 421 条:执行器、channel、waker 之间跨 crate 的调用都能内联了。
  • ThreadModeRawMutex(不开 LTO)是 581 条,介于两者之间:它不关中断,每次加锁读一次 ICSR 判断 VECTACTIVE,再加一个断言。

所以第 5 节说的「开销按上下文付」,在不开 LTO 的构建里,付的大头是第 4 节的链接期符号带来的函数调用。

小结

  • 静态任务:spawn 不分配,RAM 在链接时就定了,waker 不用引用计数;代价是任务数和 future 大小都要事先定下,每格还为定时器队列付 24 字节。
  • 中断直接唤醒、空闲时 WFE:一次唤醒一两百条指令,没有驱动线程;CPU 醒几次取决于时间驱动,多个等待者会让它一刻不睡。
  • 一个执行器一个线程:同执行器内不需要锁;抢占要靠 InterruptExecutor,同样负载下延迟差 450 倍,一个阻塞的任务会让整个执行器停下。
  • 链接期符号做插件:没有驱动类型参数,crate 之间不互相依赖;错误推到链接期,ld64 甚至不报,不开 LTO 时每个符号都是一次真调用。
  • RawMutex 类型参数:同一个 Channel 用在三种上下文,std 上一个来回 69 ns,比 tokio current_thread 快一倍多;每种 M 单态化一份,用错 ThreadModeRawMutex 只在运行期报。
  • 单槽 waker:不分配、大小固定;多个等待者互相叫醒,执行器不睡,只有 FairSemaphore 排队。
  • 代码生成和 Peri:一份驱动覆盖 1525 个芯片 feature,引脚、绑定、所有权错误都在编译期;代价是体量和要去 OUT_DIR 读的生成代码。
  • 各自发版:小 crate 独立演进;代价是包与 tag 不一致、两个 hal-internal,以及本卷核对出的 25 处文档与代码不符和 18 处尚未发版修复的上游问题。

💬 关联讨论

发起讨论
加载中...