嵌入式异步运行时设计:embassy 与 Tokio 在 no_std 环境中的架构对比

嵌入式异步运行时设计:embassy 与 Tokio 在 no_std 环境中的架构对比 嵌入式异步运行时设计embassy 与 Tokio 在 no_std 环境中的架构对比一、嵌入式异步的传统困境嵌入式的经典编程模型是超级循环Super Loop一个loop {}中轮询所有外设状态。这种方法简单但效率极低——CPU 在绝大多数时间在空转做着有没有新数据没有。有没有新数据没有的无用功。对于电池供电的物联网设备每一毫瓦功率都关乎续航。超级循环导致 CPU 永远无法进入深度休眠——因为每 1ms 就要醒过来检查一次所有外设。实测中同样的 BLE 传感器应用使用超级循环的功耗是 12mA而使用事件驱动中断wakeup的功耗仅 0.5mA——差距 24 倍。事件驱动模式是异步运行时的核心思想当没有事件时CPU 休眠当外设产生中断时唤醒 CPU 并调度对应的处理 Task。这正是embassy和Tokio解决的问题——但它们的设计哲学完全不同。Tokio 是为std环境设计的——有操作系统、有线程、有动态内存。embassy 是为no_std环境设计的——没有操作系统、没有线程、没有堆。这导致了架构上的根本差异Tokio 使用 work-stealing 多线程调度embassy 使用单线程协作式调度和一个名为Executor的核心组件。二、embassy 与 Tokio 的架构对比调度模型对比Tokio 是抢占式多线程调度——Work-Stealing 算法在多个 Worker 线程间动态平衡负载。Task 被 Tokio 挂起时可能被迁移到另一个线程继续执行。这提供了高吞吐但引入了线程切换的开销。embassy 是协作式单线程调度——所有 Task 在同一个线程上执行。Task 必须主动.await出让控制权否则会饥饿其他 Task。Executor 的核心是一个固定大小的数组存储所有 Task 的 Future依次轮询。当一个 Task 返回Poll::Pending时Executor 跳到下一个 Task。Waker 机制对比Tokio 的 Waker 由 I/O Driver 管理当epoll报告一个 socket 可读时Driver 调用wake()将对应 Task 重新加入调度队列。embassy 的 Waker 由中断驱动当外设UART、SPI、GPIO产生中断时ISR 中设置 WakerExecutor 在下次轮询时唤醒对应 Task。embed 的关键数据结构是WakerRegistration——一个存储 Task ID 的结构中断处理函数通过它通知 Executor。内存占用对比Tokio Runtime 基线~500KB含 I/O Driver、Timer Wheel、线程栈。对于一个 STM32F10320KB RAM来说这根本放不下。embassy Executor 基线~200 bytes存储 Task Future 的数组 Waker 注册表。每个 Task 的 Future 大小由 Rust 编译器在编译期确定——静态内存分配无堆使用。三、embassy 异步外设驱动实践// Cargo.toml 依赖: // embassy-stm32 { version 0.1, features [stm32f407, defmt] } // embassy-executor 0.5 // embassy-time 0.3 // embassy-usart 0.1 // embassy-net 0.4 // static_cell 2 #![no_std] #![no_main] use embassy_executor::Spawner; use embassy_stm32::{ gpio::{Level, Output, Speed}, peripherals::{USART1, DMA2_CH7}, usart::{self, Uart, Config, DataBits, Parity, StopBits}, bind_interrupts, }; use embassy_time::{Timer, Duration}; use static_cell::StaticCell; // 中断绑定 —— embassy 要求显式声明哪些中断由哪些外设使用 // 这是编译期安全检查同一中断不能分配给多个外设 bind_interrupts!(struct Irqs { USART1 usart::InterruptHandlerembassy_stm32::peripherals::USART1; }); // 全局分配 —— StaticCell 提供类似 lazy_static 但更轻量的初始化 // 在 no_std 环境中没有 Box/Arc所有数据结构必须在编译期确定大小 static UART_BUF: StaticCell[u8; 256] StaticCell::new(); /// Task 1: LED 闪烁 —— 每 500ms 切换状态 #[embassy_executor::task] async fn led_blink(mut led: Outputstatic) { loop { led.set_high(); Timer::after(Duration::from_millis(500)).await; led.set_low(); Timer::after(Duration::from_millis(500)).await; } } /// Task 2: UART 回显 —— 读取一个字节并立即写回 #[embassy_executor::task] async fn uart_echo(mut uart: Uartstatic, embassy_stm32::mode::Async) { let mut buf [0u8; 1]; loop { // read_until_idle 等待 UART 接收数据 // 内部实现注册 Waker → 使能 RXNE 中断 → 休眠 → // 中断触发 → 唤醒 Task → 继续执行 match uart.read_until_idle(mut buf).await { Ok(_) { // 回显收到的字节 let _ uart.write(buf).await; } Err(e) { // 错误处理帧错误/噪声错误 → 忽略并继续 // 在生产代码中应记录错误类型用于调试 let _ e; } } } } /// Task 3: 传感器数据采集与网络上报 /// 展示多外设协作I2C读取 WiFi发送 #[embassy_executor::task] async fn sensor_reporter( mut i2c: embassy_stm32::i2c::I2cstatic, embassy_stm32::mode::Async, stack: static embassy_net::Stackcyw43::NetDriverstatic, ) { let mut read_buf [0u8; 6]; loop { // 1. 通过 I2C 读取温湿度传感器 // SHT30 的命令: 0x2C06 (高精度测量) match i2c.write_read(0x44, [0x2C, 0x06], mut read_buf).await { Ok(_) { // 原始数据解析: SHT30 返回 6 字节 // [0-1]: 温度 raw, [2]: CRC, [3-4]: 湿度 raw, [5]: CRC let temp_raw u16::from_be_bytes([read_buf[0], read_buf[1]]); let temperature -45.0 175.0 * (temp_raw as f32 / 65535.0); } Err(_) { // I2C 错误 —— 传感器可能未连接 // 不阻塞整体流程跳过本次采集 Timer::after(Duration::from_secs(1)).await; continue; } } // 2. 通过 WiFi 发送数据到云端 // 使用 embassy-net 提供的 TCP Socket // 实际代码需要获取 Socket → connect → write → close // (embassy-net TCP 客户端代码省略) // 3. 休眠到下一次采集 —— 10 秒间隔 // Timer::after 实现设置 RTC 闹钟 → 进入低功耗模式 // → RTC 中断唤醒 → 继续执行 Timer::after(Duration::from_secs(10)).await; } } /// 主入口 —— embassy 宏替代了 cortex_m_rt::entry #[embassy_executor::main] async fn main(spawner: Spawner) { // 外设初始化 —— embassy_stm32 的泛型外设抽象 let p embassy_stm32::init(Default::default()); // 初始化 LED let led Output::new(p.PC13, Level::High, Speed::Low); // 初始化 UART1: 115200, 8N1 let uart_config Config::default(); // UART 接收缓冲区 —— DMA 接收模式 // StaticCell 确保缓冲区在程序生命周期内有效 let rx_buf UART_BUF.init([0u8; 256]); let uart Uart::new( p.USART1, p.PA10, // TX pin: PA10 p.PA9, // RX pin: PA9 Irqs, p.DMA2_CH7, rx_buf, uart_config, ).unwrap(); // 拆分为读写两端Tx 和 Rx 可以独立传递到不同 Task let (uart_tx, uart_rx) uart.split(); // 启动 Task —— spawner.spawn 接受一个 Future // 所有 Task 在编译期确定——没有动态创建 // 这保证了 exec 的内存占用在编译期完全可知 spawner.spawn(led_blink(led)).unwrap(); spawner.spawn(uart_echo(uart_rx)).unwrap(); // 如果初始化了 WiFi启动 sensor_reporter // spawner.spawn(sensor_reporter(i2c, stack)).unwrap(); // embassy::main 宏自动创建 Executor 并启动调度循环 // Executor 在 main 函数返回后持续运行 }关键设计决策StaticCell全局静态内存分配所有缓冲区大小在编译期确定。与使用Box或Vec不同——在embassy中不存在OOM的概念因为根本不涉及动态分配。bind_interrupts!编译期中断资源绑定如果同一个中断号被声明给两个外设编译失败。这是对中断冲突这一嵌入式开发中高频 Bug 的编译期消除。#[embassy_executor::task]属性宏将async fn转换为固定大小的 Future。这与 Tokio 的 Task 不同——embassy的 Task Future 大小在编译期就确定了存储在 Executor 的静态数组中。而 Tokio 的 Task 在堆上分配。Timer::after的低功耗实现在等待期间CPU 进入 WFIWait For Interrupt模式功耗从 100mW 降至 1mW 级别。这是异步运行时的核心节能机制。四、embassy 与 Tokio 的适用边界与权衡embassy 适用场景MCU 级别的嵌入式设备ARM Cortex-M / RISC-VRAM 1MB。电池供电设备功耗是第一优先级。外设驱动的异步封装——UART、SPI、I2C、USB 等外设操作天然适合异步模型。Tokio 适用场景运行 Linux 的 ARM/x86 边缘服务器如树莓派、Jetson。需要多线程并行的 CPU 密集型应用。需要与大量 std 生态库数据库驱动、HTTP 客户端、gRPC交互的项目。主要权衡Task 内存embassy 的所有 Task Future 是静态分配的大小在编译期确定。这保证了无 OOM但限制了 Task 的最大大小——如果某个 Task 的 Future 太大如局部变量过多可能编译失败。调度公平性embassy 协作式调度要求所有 Task 必须短暂执行后.await。一个忘记.await的 Task 会饥饿所有其他 Task。Tokio 的抢占式调度无此问题。中断处理的安全性embassy 的中断处理函数编译期保证不持有锁、不阻塞 Executor。Tokio 不涉及中断——信号处理由 OS 代劳。不完全互斥实际上 embassy 可以作为 Tokio 的下层——在树莓派等 dev 上embassy 用于处理 GPIO 实时控制延迟 1μsTokio 处理网络和文件 I/O。两者通过signalfd或eventfd桥接。五、总结embassy 是专为no_std设计的异步运行时——没有线程、没有堆、没有操作系统。所有调度都在单线程上完成。Tokio 是多线程抢占式调度适合有 OS 的环境embassy 是单线程协作式调度适合裸机环境。embassy 的 Task Future 大小在编译期确定存储于静态数组——消除了 OOM 的可能性。bind_interrupts!宏实现了中断资源的编译期绑定检查消除了中断冲突的运行时 Bug。Timer::after在等待期间让 CPU 进入 WFI 低功耗模式是实现 μA 级别功耗的基础。