设计取舍
主线位置:三条主线的汇总。定时器主线上的取舍是「静态任务」「中断唤醒加
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_wake | embassy-time-driver | 时间驱动,经 time_driver_impl! |
__embassy_time_queue_item_from_waker | embassy-executor-timer-queue | 执行器(raw/mod.rs) |
__pender | 执行器的 Pender::pend | 所选的 platform-* |
_embassy_trace_*(七个) | 执行器的 trace feature | 用户 |
_critical_section_1_0_acquire / release | critical-section crate | cortex-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:每个来回NoopRawMutex511 条指令,ThreadModeRawMutex581 条,CriticalSectionRawMutex615 条。std 上NoopRawMutex每个来回 69 ns,CriticalSectionRawMutex93 ns,tokio current_thread 加 tokio 的 mpsc 是 164 到 167 ns。 - 用错的一部分在编译期拦下:
NoopRawMutex不是Sync,static CH: Channel<NoopRawMutex, ..>是E0277(原始互斥)。 - 运行期用错也会当场暴露,而不是悄悄丢消息:中断里碰
NoopRawMutex的 channel panic 在try_borrow_mut,碰ThreadModeRawMutex的 panic 在断言(同一页)。
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 里睡着。
付出。 两个以上的任务等同一个东西时,它们互相把对方挤出槽、互相叫醒,执行器不睡,拿到的顺序也不固定:
| 场景 | 实测 | 页面 |
|---|---|---|
三个发送方等一个满的 Channel | std 上前 100 ms 一共被 poll 340 万次;QEMU 上两个发送方 100 ms 各 27.5 万次 | Channel |
一个任务持锁、三个任务等一个 Mutex | 每个等待者被 poll 约一百万次,拿锁顺序不固定 | async Mutex 与 Semaphore |
同样的负载换成 FairSemaphore | 按到达顺序,每个等待者只被 poll 2 次 | 同上 |
两个任务等一个 Signal | 互相叫醒直到有值;值只给一个,另一个睡下去 | Signal、Watch 与 PubSub |
两个任务 wait_config_up | 1 秒里各被 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 字节,整个Peripherals0 字节;生命周期不产生单态化(外设单例)。 - 移走、重复借用、借用逃出作用域分别是
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),删掉 clear | Channel |
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.2 | embassy-futures |
#[task] 的 impl Trait 参数导致越界访问 | executor 0.6.1 起拒绝 | 宏 |
驱动发送队列满时 Runner 忙转 | #5870,embassy-net 0.9.1 | Stack 与 Runner |
UDP 报文比发送缓冲区还大时 send_to 永远等下去 | #3804,embassy-net 0.7.1 | TCP 与 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.adoc | tick 率用 time-tick-<frequency> 选,只有 1000 Hz、32768 Hz、1 MHz | feature 叫 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_after | 0.4.0 起都在 | 本站的定时循环搬到 embassy |
select 文档 | 「The other future is dropped」 | 输家和赢家一起在 Select 被 drop 时才 drop | embassy-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 CHANGELOG | 0.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,NoopRawMutex | 69.0–69.3 ns | 一个线程,锁什么也不做 |
embassy,CriticalSectionRawMutex | 93.0–96.6 ns | std 的临界区是一把进程级 Mutex |
| tokio current_thread | 164.3–167.2 ns | 一个线程 |
tokio 多线程,ping 在 block_on 线程 | 7.1–7.3 µs | 每条消息都跨线程唤醒 |
| tokio 多线程,两端都 spawn | 178.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 |
|---|---|---|---|---|
NoopRawMutex | 511 | 9284 | 421 | 7212 |
ThreadModeRawMutex | 581 | 9372 | — | — |
CriticalSectionRawMutex | 615 | 9324 | 449 | 7240 |
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-sectioncrate 的链接期符号调到_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 处尚未发版修复的上游问题。