知识门户

返回

第 7 章:Embassy 入门——思想、对比与代价

Part2 深入embassy框架

第 7 章:Embassy 入门——思想、对比与代价#

本章要回答的问题:Embassy 的 async/await 到底是怎么工作的?它和你在第 5 章看到的手写状态机有什么关系?采用 Embassy 需要付出什么代价?

本章定位:建立对 Embassy 的整体认知——设计哲学、与传统方案的对比、真实的代价。不涉及具体 API 用法(那是第 8 章的事),也不深入运行时内部(那是第 9 章的事)。


7.1 Embassy 的设计哲学#

一句话概括#

Embassy 把”手写状态机 + 中断驱动”的嵌入式并发模式,用 Rust 的 async/await 语法重新表达,让编译器自动生成状态机代码。

这不是一个全新的发明——它的底层机制(状态机 + 协作式调度)在第 5 章已经讲过了。Embassy 的创新在于:用编译器的力量,把”程序员手动管理状态”变成了”程序员写顺序代码”。

三个核心设计原则#

原则一:零开销(Zero-Cost)#

Embassy 不引入任何运行时抽象层:

传统 RTOSEmbassy
调度器是一个运行时循环调度器是编译期生成的 poll() 调用链
每个任务有独立的栈所有任务共享一个栈
上下文切换保存/恢复寄存器没有上下文切换(状态保存在 Future 结构体中)
队列/信号量是内核对象Channel/Signal 是普通的 static 变量

验证方式:你可以对 Embassy 代码和等价的手写状态机代码做反汇编对比——机器码几乎相同(第 9 章会展示)。

原则二:无动态分配(No Heap Allocation)#

Embassy 的所有数据结构都是静态分配的:

// Embassy 的 Channel:编译期确定大小
static CHANNEL: Channel = Channel::new();
//                                                  ↑
//                                          缓冲区大小在编译期确定
//                                          不需要 malloc

// Embassy 的任务:编译期确定栈帧大小
#[embassy_executor::task]
async fn sensor_task() {
    // 这个 async fn 被编译为一个固定大小的 Future 结构体
    // 大小在编译期完全确定
    // 不需要运行时分配
}
rust

为什么这很重要?

在嵌入式中,堆分配(malloc/free)是万恶之源:

  • 分配失败怎么办?(嵌入式没有”内存不足”的优雅处理方式)
  • 碎片化怎么办?(运行几个月后,内存碎片导致分配失败)
  • 时间不确定性怎么办?(malloc 的最坏情况时间无法预测)

Embassy 通过完全消除堆分配来避免这些问题。所有资源在编译期确定大小,在 static 变量中分配。

原则三:基于 embedded-hal-async(标准化接口)#

Embassy 的 HAL 层实现了 embedded-hal-async trait(第 6 章介绍的异步版本):

这意味着:任何依赖 embedded-hal-async 的第三方驱动,都可以在 Embassy 中直接使用。

Embassy 不是什么#

为了避免误解,明确 Embassy 不是什么:

Embassy 不是说明
不是 RTOS没有内核、没有调度器循环、没有上下文切换
不是 Linux没有进程、没有文件系统、没有系统调用
不是”Rust 的 FreeRTOS”编程模型完全不同(async vs 线程)
不是万能的不适合硬实时(用 RTIC)、不适合安全关键(暂无认证)
不是”魔法”底层就是状态机 + 中断唤醒,你在第 5 章已经理解了

7.2 从超级循环到 async/await:一个完整对比#

场景:温湿度监测器#

让我们用一个完整的例子来对比三种实现方式。需求:

  • 每 1 秒读取一次 BME280 传感器(I2C)
  • 每 100ms 刷新一次 OLED 显示(SPI)
  • 监听 UART 命令(“GET” → 返回当前数据)
  • 每 5 秒通过 WiFi 上报一次数据

方案 A:C 超级循环 + 状态机#

这段代码的问题

  1. 状态爆炸:4 个任务 × 每个 2-4 个状态 = 需要管理 10+ 个状态变量
  2. 逻辑碎片化task_report() 的”连接→发送→等待”逻辑被拆成 3 个 case,难以理解完整流程
  3. 全局变量泛滥g_datauart_cmd_buf 等全局状态,任何函数都可以修改
  4. 时序耦合:如果 task_report() 中的 wifi_connect() 变成阻塞的(等待 3 秒),所有其他任务都会卡住
  5. 新增任务困难:添加第 5 个任务需要:定义新状态枚举、写新状态机、加入主循环、处理与现有任务的交互

方案 B:C + FreeRTOS#

比超级循环好在哪里

  • 每个任务的逻辑是完整的(不需要拆成状态机)
  • wifi_connect() 阻塞 3 秒不影响其他任务
  • 任务间通信通过队列,不需要全局变量

仍然存在的问题

  • 每个任务需要独立栈(512 + 512 + 256 + 1024 = 2304 字节 RAM)
  • FreeRTOS 内核占用 ~10KB Flash
  • 上下文切换有开销
  • 栈溢出无保护(sensor_task 的 512 字节栈够用吗?只能靠经验猜)
  • 优先级配置需要仔细设计(优先级反转风险)
  • C 语言无并发安全保证(共享数据仍需手动保护)

方案 C:Rust + Embassy#

三种方案的逐维度对比#

维度C 超级循环C + FreeRTOSRust + Embassy
代码行数~200 行~100 行~60 行
逻辑完整性❌ 碎片化(状态机)✅ 完整(每任务一个函数)✅ 完整(每任务一个 async fn)
阻塞安全❌ 阻塞会卡死全部✅ 阻塞只影响当前任务✅ 阻塞只影响当前任务
RAM 开销~200B(全局变量)~2.5KB(任务栈)+ 内核~500B(Future 结构体)
Flash 开销~4KB~14KB(+内核)~6KB(+运行时)
并发安全❌ 无保证❌ 无保证(C 语言)✅ 编译器保证(借用检查)
栈溢出风险无(单栈)⚠️ 有(每任务独立栈)无(共享栈,编译期确定)
新增任务困难(新状态机)容易(新 xTaskCreate)容易(新 async fn + spawn)
调试难度低(单线程)高(多线程竞态)中(单线程协作式)
实时性好(需配合 InterruptExecutor)
可移植性❌ 绑定 HAL❌ 绑定 HAL + RTOS✅ embedded-hal-async

关键洞察:Embassy 代码 = 顺序写法 + 状态机语义#

仔细看 Embassy 版本的 report_task

async fn report_task() {
    loop {
        Timer::after(Duration::from_secs(5)).await;  // ① 等待 5 秒

        if let Ok(data) = SENSOR_CH.try_receive() {
            wifi_connect().await;                     // ② 等待连接
            wifi_send_json(data).await;               // ③ 等待发送完成
            wifi_disconnect().await;                  // ④ 等待断开
        }
    }
}
rust

这段代码读起来像是顺序执行的:等 5 秒 → 连接 → 发送 → 断开。

实际执行时,每个 .await 都是一个”让出点”——CPU 在等待期间去执行其他任务。

对比 C 超级循环的状态机

Embassy 的 async/await 就是编译器自动生成的这种状态机。 你写 4 行顺序代码,编译器生成 4 个状态的 switch/case。你不需要手动管理 report_state 变量——编译器帮你做了。

这就是为什么 Embassy 被称为”零开销”——它没有引入任何新的运行时机制,只是让编译器替你写了你本来就要手写的代码。


7.3 Embassy 的代价与权衡#

诚实的代价清单#

Embassy 不是免费的午餐。采用它需要付出以下代价:

代价一:学习曲线#

你需要学的难度时间估计
Rust 基础(所有权、trait、泛型)⭐⭐⭐2-4 周
async/await 概念⭐⭐⭐1-2 周
Embassy API(HAL、Executor、同步原语)⭐⭐1-2 周
调试异步代码⭐⭐⭐持续
总计(从 C 零基础开始)6-10 周

对比:学 FreeRTOS 大约需要 2-3 周(如果你已经会 C)。

缓解策略:本书的设计就是为了解决这个问题——Part 1 覆盖 Rust 基础,Part 2 覆盖 Embassy,每章都有 C 对比。

代价二:工具链要求#

Embassy 开发环境:
├── Rust 工具链(rustup + cargo)          ~500 MB
├── target: thumbv7em-none-eabihf          ~200 MB
├── probe-rs(调试/烧录)                  ~50 MB
├── VSCode + rust-analyzer + probe-rs 插件  ~300 MB
└── 总计                                    ~1 GB

对比 Keil MDK:
├── Keil 安装包                             ~2 GB
├── 芯片 Pack                               ~100 MB
└── 总计                                    ~2.1 GB
plaintext

工具链体积相当,但 Rust 工具链是跨平台的(Windows/macOS/Linux),而 Keil 只支持 Windows。

代价三:编译时间#

项目规模C(Keil/GCC)Rust(cargo build)Rust(cargo build —release)
空项目~1s~2s~3s
中等项目(~5K 行)~5s~15s~30s
大项目(~20K 行)~15s~45s~90s
增量编译(改 1 行)~2s~5s~10s

Rust 的编译时间确实比 C 长——这是 LLVM 优化和泛型单态化的代价。但对于嵌入式项目(通常 < 50K 行),这个差异在日常开发中可以接受。

缓解策略

  • 开发时用 cargo build(debug,快)
  • 只在烧录测试时用 cargo build --release(慢但优化好)
  • 使用 sccache 缓存编译结果
  • 使用 mold 链接器加速链接阶段

代价四:调试体验#

调试操作C(Keil/GDB)Rust(probe-rs + VSCode)
设置断点✅ 成熟✅ 支持
单步执行✅ 成熟✅ 支持
查看变量✅ 成熟✅ 支持(但 async fn 内部变量有时显示不完整)
查看寄存器✅ 成熟✅ 支持
调用栈✅ 清晰⚠️ async 任务的调用栈是”展平”的,不如 C 直观
实时表达式✅ 成熟⚠️ 有限支持
逻辑分析仪集成✅ 成熟(Keil + Logic)❌ 需要外部工具

诚实评价:Rust 的嵌入式调试体验在 2026 年已经可用,但尚未达到 Keil 的成熟度。最大的痛点是 async 任务的调试——因为一个 async fn 被编译为状态机,断点行为和 C 的线性代码不同。

缓解策略

  • 大量使用 defmt 日志(比断点更适合异步代码)
  • 使用 probe-rs 的 RTT 日志功能
  • 对于复杂问题,可以临时将 async 代码改为阻塞式调试

代价五:生态成熟度#

方面C 生态(STM32)Rust 生态(Embassy)
芯片覆盖✅ 所有型号⚠️ 主流型号覆盖,冷门型号可能缺失
外设覆盖✅ 所有外设⚠️ 常用外设覆盖,冷门外设可能需要 PAC 直接操作
第三方库✅ 极其丰富⭐⭐⭐ 快速增长中,但仍有缺口
参考设计✅ 厂商提供⚠️ 社区提供,质量参差
认证(ISO/IEC)✅ 有❌ 暂无(Ferrocene 在推进)
技术支持✅ 厂商 FAE⚠️ 社区(Matrix/GitHub)
文档✅ 完整(数据手册+参考手册)⭐⭐⭐ 改善中,但仍有缺口

诚实评价:如果你使用的是 STM32F4/F7/H7、nRF52/53、RP2040、ESP32 这些主流芯片,Embassy 的覆盖已经足够。但如果你使用的是冷门芯片或需要特殊外设(如某些加密加速器),可能需要自己写 PAC 级代码。

代价六:async 的”传染性”#

Rust 的 async 有一个被称为”函数颜色问题”(function coloring problem)的特性:

// 一旦某个函数是 async 的,调用它的函数也必须是 async 的
async fn read_sensor() -> i16 { /* ... */ }

// ❌ 不能在非 async 函数中调用 async 函数
fn sync_function() {
    let temp = read_sensor();  // 编译错误!
}

// ✅ 必须在 async 函数中调用
async fn async_function() {
    let temp = read_sensor().await;  // 正确
}
rust

这意味着:一旦你选择使用 Embassy,你的大部分代码都需要是 async 的。 这不是一个可以”局部采用”的方案——它是全栈的。

对比 C:在 C 中,你可以在超级循环中局部使用 RTOS(只把部分任务放到 RTOS 中)。Embassy 没有这种”渐进式迁移”的路径。

权衡总结:什么时候该用 Embassy,什么时候不该?#

场景建议理由
新项目,I/O 密集型(传感器+通信+显示)用 Embassy最佳匹配
新项目,需要 WiFi/BLE/USB用 Embassy唯一有完整协议栈的 Rust 框架
新项目,硬实时(电机控制 < 10μs 周期)⚠️ 考虑 RTIC编译期保证更严格
已有 C 项目,需要维护 5 年以上⚠️ 谨慎评估迁移成本高,团队学习曲线
安全关键(汽车/医疗/航空)暂不适合无认证,工具链未鉴定
极简资源(< 16KB Flash)⚠️ 评估Embassy 运行时 ~2-5KB,可能占比过大
团队只有 C 经验,项目紧急不建议学习曲线会拖慢进度
团队愿意投资学习,追求长期质量强烈推荐编译器捕获的 bug 远超学习成本

7.4 Embassy 项目的高层结构#

一个 Embassy 项目的完整文件结构#

Cargo.toml:Embassy 项目的依赖#

与第 3 章裸机工程的对比#

文件/配置第 3 章裸机工程Embassy 工程新增原因
Cargo.toml5 个依赖~12 个依赖Embassy 运行时 + HAL + 同步原语
src/main.rs#[entry] fn main()#[embassy_executor::main] async fn main()入口宏不同
任务定义无(单线程)#[embassy_executor::task] async fn多任务支持
时间驱动time-driver-any featureEmbassy 需要硬件定时器作为时钟源
Panic 处理panic-halt(死循环)panic-probe(输出日志后死循环)更好的调试体验
Flash 占用~3 KB~6-8 KBEmbassy 运行时 + 异步基础设施
RAM 占用~1 KB~2-4 KB任务 Future + 通道缓冲区

#[embassy_executor::main] 做了什么?#

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // 你的初始化代码
}
rust

这个宏展开后,大致等价于:

关键理解

  • #[embassy_executor::main] 不是”启动一个操作系统”——它只是设置了一个事件循环
  • Spawner 不是”创建线程”——它只是把一个 Future 加入就绪队列
  • Executor 主循环不是”调度器”——它只是反复调用 poll()
  • 没有上下文切换、没有独立栈、没有内核对象

Executor 的内部工作原理(poll 机制、Waker、时间驱动),见第 9 章。

Embassy 的 Feature Flag 体系#

Embassy 使用大量 feature flag 来控制编译内容:

与 C 的 #ifdef 对比

C 的做法Embassy 的做法
#ifdef STM32F407xxfeatures = ["stm32f407vg"]
#ifdef USE_HAL_DRIVER依赖 embassy-stm32 crate
#ifdef DEBUGcfg(debug_assertions)(自动)
Makefile 中的 -D 定义Cargo.toml 中的 features
条件编译散落在代码中集中在 Cargo.toml 中声明

7.5 本章小结#

核心认知#

  1. Embassy 不是 RTOS——它是一个编译期代码生成框架,把 async/await 转换为状态机。没有内核、没有上下文切换、没有独立任务栈。

  2. Embassy 的”零开销”是可验证的——编译后的机器码与手写状态机几乎相同。抽象的成本为零,安全性是免费的。

  3. Embassy 的代价是真实的——学习曲线(6-10 周)、编译时间、调试体验、生态成熟度、async 传染性。这些代价需要诚实面对。

  4. Embassy 最适合 I/O 密集型场景——传感器读取、通信协议、显示刷新、网络通信。对于硬实时场景(< 10μs 周期),RTIC 可能更合适。

  5. Embassy 是全栈方案——一旦采用,你的大部分代码都将是 async 的。这不是一个可以”局部试用”的方案。

从本章到后续章节的路线图#

一个思维转变#

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

在 C 中,你告诉 CPU “做什么”(写寄存器、调用函数)。 在 Embassy 中,你告诉 CPU “等什么”(.await),CPU 自己决定在等待期间做什么。

这个思维转变——从”命令式”到”声明式”——是从 C 到 Embassy 最核心的认知跳跃。一旦你接受了”我不需要控制每一微秒 CPU 在做什么,我只需要声明我的任务在等什么”,Embassy 的编程模型就会变得极其自然。


下一章:第 8 章——第一个 Embassy 工程:GPIO、UART、SPI、I2C 与 Timer。我们将真正动手,用 Embassy 点亮 LED、读取传感器、驱动显示器。