知识门户

返回

第 9 章:Embassy 运行时——Executor、Future 与 Waker

Part2 深入embassy框架

第 9 章:Embassy 运行时——Executor、Future 与 Waker#

本章要回答的问题:async/await 在裸机上到底是怎么运行的?Future 是什么?Waker 和中断有什么关系?如何验证”零开销”?

本章定位:打开 Embassy 的”引擎盖”。从 Rust 的 Future trait 讲起,逐步深入到 Executor 的调度循环、Waker 的中断唤醒机制、时间驱动的硬件实现。最后通过反汇编对比,验证”零开销”不是营销话术。


9.1 Future:async 的底层真相#

9.1.1 一个 async fn 到底是什么?#

当你在第 8 章写下这段代码时:

async fn blink() {
    loop {
        led.set_high();
        Timer::after(Duration::from_millis(500)).await;
        led.set_low();
        Timer::after(Duration::from_millis(500)).await;
    }
}
rust

编译器把它变成了什么?

答案:一个实现了 Future trait 的状态机结构体。

9.1.2 与第 5 章状态机的对应关系#

现在,把这个编译器生成的代码与第 5 章你手写的 C 状态机对比:

第 5 章 C 状态机编译器生成的 Future说明
typedef enum { STATE_0, STATE_1, ... }enum BlinkState { Start, WaitingTimer1, ... }状态枚举
static TaskState task_statestruct BlinkFuture { state: BlinkState, ... }状态变量
switch (state) { case STATE_0: ... }match &mut self.state { ... }状态分发
break;(让出 CPU)return Poll::Pending;让出控制权
全局变量保存跨步骤数据struct BlinkFuture 的字段跨 await 的局部变量
主循环中调用 task_step()Executor 调用 future.poll()调度器推进状态机

核心等式

async fn = 编译器自动生成的状态机
.await   = return Poll::Pending(让出 CPU)
Executor = 反复调用 poll() 的循环
plaintext

9.1.3 Future trait 的定义#

// core::future::Future —— Rust 标准库中的定义
pub trait Future {
    /// Future 完成时的返回值类型
    type Output;

    /// 尝试推进 Future
    ///
    /// - Poll::Ready(value):Future 完成,返回结果
    /// - Poll::Pending:Future 未完成,稍后再来
    ///
    /// 调用 poll 时,必须传入 Waker(通过 Context)
    /// 当 Future 可以从 Pending 变为 Ready 时,它应该调用 waker.wake()
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll;
}
rust

三个关键概念

概念含义类比
Poll::Pending”我还没完成,别管我了”C 状态机中的 break(让出)
Poll::Ready(v)”我完成了,结果给你”C 状态机中的”最终状态”
Waker(在 Context 中)“我完成了的时候叫你”C 中的中断回调 / 标志位

9.1.4 Pin:为什么 Future 不能移动?#

你可能注意到 poll 的签名是 self: Pin<&mut Self>,而不是普通的 &mut self

为什么?

因为 Future 内部可能包含自引用(self-reference)。考虑这个 async fn:

async fn example() {
    let data = [1u8, 2, 3, 4];       // 局部变量
    let reference = &data[0];          // 指向 data 的引用
    some_async_op().await;             // ← await 点!
    println!("{}", reference);         // await 之后仍然使用 reference
}
rust

编译器生成的状态机:

struct ExampleFuture {
    state: u8,
    data: [u8; 4],          // 跨 await 存活
    reference: *const u8,   // 指向 self.data[0]!
}
rust

如果这个结构体被移动mem::swapVec::push 等),reference 就会变成悬垂指针——它仍然指向旧的内存地址。

Pin<&mut T> 的保证是:被 Pin 包裹的值不会被移动。 这保证了自引用的安全性。

对你的影响:作为 Embassy 用户,你几乎不需要直接处理 Pin#[embassy_executor::task] 宏和 spawner.spawn() 已经帮你处理了。但理解它的存在,有助于理解为什么 Embassy 的任务是静态分配的(static 变量天然不可移动)。


9.2 Executor:调度器的主循环#

9.2.1 Executor 的本质#

Embassy 的 Executor 不是一个操作系统内核——它是一个极其简单的循环

就这么简单。 没有优先级队列,没有时间片轮转,没有上下文切换。整个”调度器”就是一个 loop + 一个队列。

9.2.2 与 FreeRTOS 调度器的对比#

方面FreeRTOS 调度器Embassy Executor
数据结构优先级链表(每个优先级一个列表)单个 FIFO 队列
调度策略抢占式(高优先级随时打断低优先级)协作式(任务主动让出)
切换机制PendSV 中断 + 上下文切换无切换(poll 返回即完成)
时间管理SysTick 中断 + tick 计数时间驱动(独立定时器)
阻塞实现任务从就绪列表移到阻塞列表Future 返回 Poll::Pending
唤醒实现xTaskResumeFromISR()waker.wake()
代码量~3000 行(portable 层)~500 行(核心逻辑)
RAM 开销每任务一个栈(256-1024B)每任务一个 Future 结构体(编译期确定)

9.2.3 任务的内存布局#

每个 Embassy 任务在内存中是什么样的?

// 当你写:
#[embassy_executor::task]
async fn blink(led: Output<'static>) {
    loop {
        led.toggle();
        Timer::after(Duration::from_millis(500)).await;
    }
}
rust

编译器 + Embassy 宏生成的内存布局:

关键差异

  • Embassy 任务的”栈”就是 Future 结构体本身——大小在编译期完全确定
  • 没有栈溢出风险(因为根本没有独立的栈)
  • 所有任务共享 main 的栈(用于 poll 调用时的临时变量)

9.2.4 #[embassy_executor::task] 宏做了什么?#

为什么任务参数必须是 'static

因为 Future 被存储在 static 变量中,它引用的所有数据必须活得和程序一样长。这就是为什么 Embassy 的外设对象(OutputUartI2c)都是 'static 生命周期——它们引用的是硬件寄存器,硬件寄存器永远存在。


9.3 Waker:中断如何唤醒 Future#

9.3.1 Waker 的角色#

回顾 Future::poll 的签名:

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll;
rust

Context 中包含一个 Waker。Waker 的作用是:

当 Future 从 Pending 变为 Ready 时,通知 Executor “这个任务可以继续了”。

在裸机上,“Future 变为 Ready”通常意味着:

  • DMA 传输完成(中断触发)
  • 定时器到期(中断触发)
  • UART 收到数据(中断触发)
  • GPIO 边沿到来(中断触发)

Waker 就是中断和 Executor 之间的桥梁。

9.3.2 Waker 的工作原理#

9.3.3 用 C 类比理解 Waker#

Embassy 的 Waker 就是把这个”设置标志 + 主循环检查”的模式自动化了:

C 的手动模式Embassy 的 Waker
volatile bool flagWaker 内部的原子状态
ISR 中 flag = trueISR 中 waker.wake()
主循环 while(!flag)Executor 的就绪队列
手动清除标志Executor 自动管理
多个标志 → 多个 if队列自动调度

9.3.4 Waker 的底层实现#

关键细节

  1. wake() 可以在中断上下文中调用(它是 unsafe 的,但 Embassy 保证了安全性)
  2. 使用原子操作防止重复入队(同一个任务可能被多个中断唤醒)
  3. sev() 指令确保 CPU 从 WFI 中醒来

9.3.5 完整示例:UART 接收的 Waker 流程#

整个过程没有上下文切换,没有独立栈,没有 RTOS 内核。 只有:中断 → 原子操作 → 队列 → poll。


9.4 时间驱动:Timer 的硬件基础#

9.4.1 Timer::after() 是怎么工作的?#

当你在第 8 章写 Timer::after(Duration::from_millis(500)).await 时,底层发生了什么?

9.4.2 时间驱动的硬件实现#

Embassy 需要一个硬件定时器作为”系统时钟”。在 Cargo.toml 中:

embassy-stm32 = { features = ["time-driver-any"] }
#                              ↑
#                    自动选择一个空闲的硬件定时器
toml

time-driver-any 会选择 TIM2/TIM3/TIM4/TIM5 中的一个(避免与 PWM 等应用冲突)。

9.4.3 与 C 的 SysTick 对比#

方面C(FreeRTOS SysTick)Embassy 时间驱动
硬件SysTick(Cortex-M 内核自带)通用定时器(TIM2/3/4/5)
周期固定 1ms tick按需编程(只在有 Alarm 时触发)
功耗每 1ms 一次中断(即使无事可做)无 Alarm 时不产生中断
精度1ms(tick 周期)取决于定时器时钟(通常 ~1μs)
溢出32 位 tick 计数(~49 天溢出)64 位时间戳(~58 万年溢出)
多定时器需要软件定时器列表内置最小堆,自动管理

功耗优势

FreeRTOS(SysTick 1ms):
- 每秒 1000 次中断
- 即使所有任务都在等 5 秒,SysTick 仍然每 1ms 触发一次
- 每次中断:~20 个时钟周期 × 1000 次/秒 = 20,000 周期/秒 的"无用功"

Embassy(按需定时器):
- 如果最近的 Alarm 在 5 秒后,定时器 5 秒内不产生中断
- CPU 可以进入深度睡眠(Stop/Standby 模式)
- 功耗降低 10-100 倍(取决于空闲时间占比)
plaintext

9.4.4 Ticker vs Timer 的内部差异#

// Timer:一次性
Timer::after(Duration::from_secs(1)).await;
// 内部:注册一个 Alarm,触发后从堆中移除

// Ticker:周期性
let mut ticker = Ticker::every(Duration::from_millis(100));
loop {
    ticker.next().await;
    // 内部:每次 poll Ready 后,自动注册下一个 Alarm
    // expires_at += period(而非 now + period)
    // 这就是为什么 Ticker 不漂移!
}
rust

Ticker 不漂移的原因

Timer 方式(有漂移):
  t=0:    开始
  t=5ms:  工作完成
  t=5ms:  Timer::after(100ms) → 到期时间 = 105ms
  t=105ms: 触发
  t=110ms: 工作完成
  t=110ms: Timer::after(100ms) → 到期时间 = 210ms  ← 漂移了 10ms!

Ticker 方式(无漂移):
  t=0:    创建 Ticker,next_expires = 100ms
  t=100ms: 触发,next_expires = 200ms(固定递增)
  t=105ms: 工作完成
  t=200ms: 触发,next_expires = 300ms  ← 无漂移!
plaintext

9.5 反汇编验证:“零开销”是真的吗?#

9.5.1 实验设计#

让我们对比两段功能等价的代码:

版本 A:Embassy async

#[embassy_executor::task]
async fn blink_async(mut led: Output<'static>) {
    loop {
        led.set_high();
        Timer::after(Duration::from_millis(500)).await;
        led.set_low();
        Timer::after(Duration::from_millis(500)).await;
    }
}
rust

版本 B:手写状态机(模拟 C 风格)

9.5.2 反汇编对比#

使用 cargo objdump --release -- -d 查看机器码:

版本 A(Embassy async)的 poll 函数

版本 B(手写状态机)的 poll 函数

9.5.3 对比结论#

指标Embassy async手写状态机差异
代码大小~120 字节~116 字节+4 字节(< 4%)
指令数~45 条~43 条+2 条
寄存器使用R4-R6 + LRR4-R6 + LR相同
栈使用16 字节(push/pop)16 字节相同
分支结构完全相同完全相同
GPIO 操作str r1, [r0, #0x14]str r1, [r0, #0x14]完全相同

结论:Embassy 的 async/await 编译后的机器码与手写状态机几乎完全相同。额外的 4 字节来自 Poll 枚举的返回值处理(Ready vs Pending),这在 C 中也需要(返回 boolenum)。

“零开销”不是营销话术——它是可验证的事实。

9.5.4 与 C HAL 的对比#

操作C(HAL 函数调用)Embassy(内联)
点亮 LEDbl HAL_GPIO_WritePin(~20 周期)str r1, [r0, #0x14](~2 周期)
原因HAL 是运行时库,函数调用有开销泛型单态化,编译期内联
额外开销参数传递、分支判断、函数返回

Embassy 不仅”零开销”——它比 C HAL 更快。 因为 Rust 的泛型单态化让所有操作在编译期内联,而 C 的 HAL 库函数是运行时调用。


9.6 InterruptExecutor:抢占式任务#

9.6.1 为什么需要 InterruptExecutor?#

标准 Executor 是协作式的——一个任务不让出 CPU,其他任务就饿死。对于大多数 I/O 任务,这不是问题(.await 点足够密集)。

但对于硬实时任务(如 100kHz 电机 PWM 控制),你需要抢占——高优先级任务必须能打断低优先级任务。

Embassy 的解决方案:InterruptExecutor——在中断中运行的 Executor

9.6.2 工作原理#

9.6.3 代码示例#

9.6.4 与 FreeRTOS 优先级的对比#

方面FreeRTOSEmbassy InterruptExecutor
优先级数量可配置(通常 5-32 级)2 级(主 + 中断)或更多(多个中断 Executor)
优先级反转可能(需要互斥量保护)不可能(中断天然不可被线程抢占)
配置复杂度每个任务设置优先级 + 调度策略只需选择中断优先级
确定性取决于调度器实现完全确定(硬件中断优先级)
开销上下文切换(~20-40 周期)中断进入/退出(~24 周期,硬件自动)

9.6.5 何时使用 InterruptExecutor?#

场景是否需要 InterruptExecutor
LED 闪烁 + 传感器读取 + UART❌ 不需要(标准 Executor 足够)
电机控制(> 10kHz 循环)✅ 需要
高速 ADC 采样(> 100kHz)✅ 需要
通信协议栈(严格时序)⚠️ 视情况(通常标准 Executor 足够)
音频处理(44.1kHz 采样)✅ 需要

经验法则:如果你的任务周期 < 1ms 且有严格截止时间,考虑 InterruptExecutor。否则,标准 Executor 的协作式调度已经足够。


9.7 多核支持(简述)#

9.7.1 RP2040 双核架构#

RP2040 有两个 Cortex-M0+ 核心。Embassy 支持在两个核心上各运行一个 Executor:

9.7.2 多核通信#

两个核心的 Executor 之间通过 embassy-sync 的通信原语交互:

多核的详细内容(RP2040 双核、ESP32 双核)将在第 15 章展开。


9.8 本章小结#

核心机制回顾#

从 C 到 Embassy 的心智模型转换#

C 的思维Embassy 的思维
”我要配置中断、写 ISR、设置标志、在主循环中检查""我 .await 一个事件,编译器帮我做剩下的"
"我要管理任务状态变量、写 switch/case""我写顺序代码,编译器生成状态机"
"我要配置 SysTick、维护 tick 计数、实现软件定时器""我 Timer::after().await,时间驱动帮我管理"
"我要小心 volatile、临界区、中断优先级""Waker 是原子操作,Executor 是单线程,无需锁"
"我要预估每个任务的栈大小""编译器计算 Future 大小,静态分配,无溢出风险”

一个关键认知#

如果你只记住本章的一件事:

Embassy 的 Executor 不是一个”操作系统”——它是一个 while 循环。Future 不是一个”线程”——它是一个结构体。Waker 不是一个”IPC 机制”——它是一个函数指针。async/await 不是”魔法”——它是编译器帮你写的 switch/case

所有这些机制的复杂度加起来,不超过 500 行核心代码。没有内核,没有调度算法,没有上下文切换。这就是为什么 Embassy 能在 64KB Flash 的 MCU 上运行,而 FreeRTOS 需要至少 32KB 内核代码。

下一步#

你现在理解了 Embassy 的运行时机制。但在实际项目中,多个任务之间需要通信

  • 传感器任务读取数据后,如何告诉显示任务?
  • UART 任务收到命令后,如何通知控制任务?
  • 多个任务如何安全地共享一个资源?

这些问题的答案在第 10 章:任务间通信——Channel、Signal、Mutex 与 PubSub


下一章:第 10 章——任务间通信:Channel、Signal、Mutex 与 PubSub。我们将学习 Embassy 的同步原语,并与 FreeRTOS 的队列/信号量/事件组逐一对比。