异步嵌入式 embassy
async/await 如何在没有操作系统的裸机上运行:Future 与执行器、Timer::after、async 外设、多任务协作、自动进入低功耗,以及与传统 RTOS 的对比。
async/await 如何在没有操作系统的裸机上运行:Future 与执行器、Timer::after、async 外设、多任务协作、自动进入低功耗,以及与传统 RTOS 的对比。
async fn 声明一个可暂停的函数,.await 处是可能让出 CPU 的暂停点。等待外设(如"等 20ms""等串口收到一字节")时,任务在 .await 处挂起、让执行器去跑别的任务,条件满足后再从原处恢复。写起来像顺序代码,跑起来是并发的。async fn 编译后产生的状态机类型,实现 Future trait。它有一个 poll 方法:执行器调用它,Future 要么返回 Ready(结果),要么返回 Pending(还没好,稍后再来)。整个 async 任务就是一个大状态机,.await 是状态之间的转移点。全程零堆分配(状态机大小编译期已知)。WFI(等待中断)进入睡眠。它不像 RTOS 给每个任务分配独立线程栈,因此内存占用极小。中断到来时唤醒相关任务,执行器再次 poll。waker.wake(),告诉执行器"这个任务可以继续了"。embassy 的 HAL 在外设中断里自动调用 waker,所以 uart.read().await 能在数据到达时被精确唤醒,期间 CPU 完全空闲省电。#[embassy_executor::main] 标注异步 main,用 #[embassy_executor::task] 声明可被 spawner.spawn() 启动的独立任务。任务是静态分配的(编译期确定数量与大小),无需堆。Timer::after(Duration::from_millis(500)).await 异步延时 500ms——期间 CPU 不空转,而是让出给其他任务并可睡眠。还有 Ticker 提供固定周期节拍、Instant 表示时间点。底层由某个硬件定时器驱动全局时间基准。i2c.read(...).await、uart.read(&mut buf).await、Input::wait_for_falling_edge().await。内部用 DMA + 中断实现,等待期间不占 CPU。它同时实现 embedded-hal-async trait,可复用异步驱动生态。join(a, b).await 等两者都完成;select(a, b).await 谁先完成就返回谁(另一个被丢弃)。例如"等串口数据 或 等超时,谁先来算谁"用 select,是异步嵌入式处理"带超时的 I/O"的地道写法。这是理解 embassy 的关键。它不给每个任务分栈,而是把每个任务编译成状态机,在同一根栈上轮流推进,空闲即睡眠:
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; } }
"等串口来数据,但最多等 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 任务里写 loop { if flag {} } 这种忙等待会独占执行器、饿死其他任务,因为它永远不 .await 让出。正确做法是等一个会挂起的 Future(Timer、外设 read、信号量 Signal)。记住:async 任务只在 .await 处让出 CPU,两个 await 之间的代码是独占运行的,因此也不要在里面做长时间的阻塞计算。
| 维度 | 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().await、wait_for_falling_edge().await)内部用 DMA+中断,等待期间不占 CPU 并实现 embedded-hal-async。join 等全部完成、select 竞速取先者(用于带超时 I/O)。切记 async 任务只在 .await 处让出,绝不能忙等或做长阻塞计算。相比 RTIC(硬实时优先)与传统 RTOS(每线程独立栈),embassy 内存开销极小、写法最接近应用层,适合 I/O 密集、多外设并发场景。