Chapter 08 · Rust 嵌入式开发

异步嵌入式 embassy

async/await 如何在没有操作系统的裸机上运行:Future 与执行器、Timer::after、async 外设、多任务协作、自动进入低功耗,以及与传统 RTOS 的对比。

课程进度67%

关键概念

async / await
Rust 的异步语法。async fn 声明一个可暂停的函数,.await 处是可能让出 CPU 的暂停点。等待外设(如"等 20ms""等串口收到一字节")时,任务在 .await 处挂起、让执行器去跑别的任务,条件满足后再从原处恢复。写起来像顺序代码,跑起来是并发的。
Future
async fn 编译后产生的状态机类型,实现 Future trait。它有一个 poll 方法:执行器调用它,Future 要么返回 Ready(结果),要么返回 Pending(还没好,稍后再来)。整个 async 任务就是一个大状态机,.await 是状态之间的转移点。全程零堆分配(状态机大小编译期已知)。
执行器 (Executor)
驱动 Future 前进的调度器。embassy 的执行器在单个栈上轮流 poll 各个就绪任务,没有就绪任务时执行 WFI(等待中断)进入睡眠。它不像 RTOS 给每个任务分配独立线程栈,因此内存占用极小。中断到来时唤醒相关任务,执行器再次 poll。
Waker(唤醒器)
连接"事件"与"任务"的桥梁。当一个 Future 返回 Pending 时,它会登记一个 Waker;对应的中断发生时调用 waker.wake(),告诉执行器"这个任务可以继续了"。embassy 的 HAL 在外设中断里自动调用 waker,所以 uart.read().await 能在数据到达时被精确唤醒,期间 CPU 完全空闲省电。
embassy-executor
embassy 的核心执行器 crate。用 #[embassy_executor::main] 标注异步 main,用 #[embassy_executor::task] 声明可被 spawner.spawn() 启动的独立任务。任务是静态分配的(编译期确定数量与大小),无需堆。
embassy-time 与 Timer
跨平台时间抽象。Timer::after(Duration::from_millis(500)).await 异步延时 500ms——期间 CPU 不空转,而是让出给其他任务并可睡眠。还有 Ticker 提供固定周期节拍、Instant 表示时间点。底层由某个硬件定时器驱动全局时间基准。
embassy HAL(如 embassy-stm32)
embassy 为各芯片提供的 HAL,外设操作都是 async 的:i2c.read(...).awaituart.read(&mut buf).awaitInput::wait_for_falling_edge().await。内部用 DMA + 中断实现,等待期间不占 CPU。它同时实现 embedded-hal-async trait,可复用异步驱动生态。
join / select 组合子
在一个任务里并发等待多个 Future。join(a, b).await 等两者都完成;select(a, b).await 谁先完成就返回谁(另一个被丢弃)。例如"等串口数据 或 等超时,谁先来算谁"用 select,是异步嵌入式处理"带超时的 I/O"的地道写法。

执行器如何调度:一根栈跑多个任务

这是理解 embassy 的关键。它不给每个任务分栈,而是把每个任务编译成状态机,在同一根栈上轮流推进,空闲即睡眠:

embassy 执行器调度循环 ═══════════════════════════════════════════════════ ┌──────────────── 执行器 loop ─────────────────┐ │ │ │ poll 任务A ─┐ │ │ ├─ 返回 Pending(.await 挂起) │ │ poll 任务B ─┤ │ │ ├─ 返回 Pending │ │ poll 任务C ─┘ │ │ └─ 返回 Pending │ │ │ │ 无就绪任务 → 执行 WFI(CPU 睡眠,超省电) │ └────────────────────┬─────────────────────────┘ │ 中断到来(如 Timer 到期) ▼ 对应 Future 的 Waker.wake() │ ▼ 执行器醒来,只 poll 被唤醒的那个任务 → 继续 对比 RTOS:无需 N 个线程栈,内存占用小一个数量级
async 延时 vs 阻塞延时

delay.delay_ms(500)(第 5 章)是阻塞的:CPU 空转 500ms 什么都做不了。Timer::after(...).await异步的:任务挂起、执行器去跑其他任务、没人干活时 CPU 睡眠。同样一行"延时 500ms",前者浪费 CPU,后者让出并省电——这就是 async 在嵌入式的核心价值:用顺序代码的写法拿到并发和低功耗。

多任务示例:闪灯 + 按键 + 传感器并发

三件事同时进行:LED 每 500ms 闪一次、按键按下打印、每秒读一次传感器。用三个独立 async 任务,代码像同步一样直白:

#![no_std]
#![no_main]
use panic_probe as _;
use defmt_rtt as _;
use embassy_executor::Spawner;
use embassy_time::{Timer, Duration};
use embassy_stm32::gpio::{Level, Output, Speed, Input, Pull};

// 独立任务:闪灯。#[task] 让它可被 spawn,静态分配无堆。
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
    loop {
        led.toggle();
        // 异步延时:期间 CPU 让给别的任务 / 睡眠
        Timer::after(Duration::from_millis(500)).await;
    }
}

// 独立任务:等按键下降沿,即时响应无需轮询
#[embassy_executor::task]
async fn watch_button(mut button: Input<'static>) {
    loop {
        button.wait_for_falling_edge().await;  // 挂起直到按下
        defmt::info!("button pressed!");
        Timer::after(Duration::from_millis(50)).await;  // 消抖
    }
}

// 异步 main:拿到 Spawner 后启动各任务
#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());

    let led = Output::new(p.PC13, Level::High, Speed::Low);
    let button = Input::new(p.PA0, Pull::Up);

    // 启动两个独立任务;它们各自并发运行
    spawner.spawn(blink(led)).unwrap();
    spawner.spawn(watch_button(button)).unwrap();

    // main 自己也可以是个"任务",这里每秒打印心跳
    loop {
        defmt::info!("heartbeat");
        Timer::after(Duration::from_secs(1)).await;
    }
}

select:带超时的异步 I/O

"等串口来数据,但最多等 1 秒"是嵌入式极常见的需求。用 select 让两个 Future 竞速:

use embassy_futures::select::{select, Either};
use embassy_time::{Timer, Duration};

async fn read_with_timeout(uart: &mut Uart<'static>, buf: &mut [u8]) {
    let recv = uart.read(buf);                              // Future A:收数据
    let timeout = Timer::after(Duration::from_secs(1));      // Future B:超时

    match select(recv, timeout).await {
        Either::First(_)  => defmt::info!("got data"),   // A 先完成
        Either::Second(_) => defmt::warn!("timeout"),    // B 先完成
    }
    // 未完成的那个 Future 被丢弃(drop),自动取消,无泄漏
}
async 任务不能"忙等"

在 async 任务里写 loop { if flag {} } 这种忙等待会独占执行器、饿死其他任务,因为它永远不 .await 让出。正确做法是等一个会挂起的 Future(Timer、外设 read、信号量 Signal)。记住:async 任务只在 .await 处让出 CPU,两个 await 之间的代码是独占运行的,因此也不要在里面做长时间的阻塞计算。

embassy vs RTIC vs RTOS

维度embassy (async)RTIC (中断驱动)传统 RTOS (FreeRTOS)
并发模型async/await 协作式硬件中断优先级抢占式线程
内存开销极小(单栈状态机)极小(无线程栈)较大(每线程独立栈)
写法体验顺序代码,最接近应用层任务 + 资源声明线程 + 队列/信号量
I/O 等待await 挂起,天然低功耗中断回调阻塞挂起线程
适合场景I/O 密集、多外设并发硬实时、确定性优先成熟生态、复杂任务
数据竞争单执行器内无竞争编译期分析保证需手动加锁
本章小结

embassy 让 async/await 在无 OS 的裸机上运行:async fn 编译成零堆分配的状态机(Future),.await 是可让出 CPU 的挂起点。执行器在单根栈上轮流 poll 就绪任务,无任务就绪时执行 WFI 睡眠省电;Waker 由外设中断触发 wake() 精确唤醒等待中的任务。#[embassy_executor::main] + #[embassy_executor::task] + spawner.spawn() 声明并启动静态分配的并发任务。Timer::after(...).await 是异步延时(让出而非空转,天然低功耗),embassy HAL 的外设操作(i2c.read().awaitwait_for_falling_edge().await)内部用 DMA+中断,等待期间不占 CPU 并实现 embedded-hal-asyncjoin 等全部完成、select 竞速取先者(用于带超时 I/O)。切记 async 任务只在 .await 处让出,绝不能忙等或做长阻塞计算。相比 RTIC(硬实时优先)与传统 RTOS(每线程独立栈),embassy 内存开销极小、写法最接近应用层,适合 I/O 密集、多外设并发场景。