异步 Rust 在嵌入式中的应用:用 embassy 在 STM32 上跑并发任务

发布时间:2026/7/25 3:34:14
异步 Rust 在嵌入式中的应用:用 embassy 在 STM32 上跑并发任务 异步 Rust 在嵌入式中的应用用 embassy 在 STM32 上跑并发任务一、为什么一个 CLI 程序员跑去玩嵌入式说实话我买那块 STM32F411 Black Pill 开发板的时候纯粹是因为被 embassy 社区的一个 demo 视频震撼到了——32KB RAM 的芯片上跑着完整的 async/await同时驱动 LED 闪烁、读取温湿度传感器、响应 UART 指令代码竟然才 200 行。自学出身让我没有嵌入式必须用 C 写裸机循环的思维定势。当我看到 embassy 用 Rust 的 async 模型把硬件中断映射为 Future 时我的第一反应不是这能行吗而是这才对啊。这篇文章我会分享在 STM32 上用 embassy 构建一个多任务传感器采集系统的完整过程重点聊异步 Rust 在资源受限环境下的真实表现。二、embassy 的异步模型栈空间是真正的瓶颈在 PC 上写 async Rust我们从不关心栈多大。但在 STM32F411 上你只有 128KB 的 Flash 和 32KB 的 RAM。embassy 的每个 task 都需要独立的栈分配这是我踩的第一个坑。#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_stm32::gpio::{Level, Output, Speed}; use embassy_time::{Duration, Timer}; /// 定义任务栈大小 /// 每个任务有独立的静态栈需要在编译期确定大小 /// 对于 LED 闪烁这种简单任务512 字节足够 #[embassy_executor::task] async fn blink_led(pin: embassy_stm32::peripherals::PB0) { // 初始化 GPIO 引脚 let mut led Output::new(pin, Level::Low, Speed::Low); loop { // 500ms 间隔闪烁 —— 异步等待不阻塞其他任务 led.set_high(); Timer::after(Duration::from_millis(500)).await; led.set_low(); Timer::after(Duration::from_millis(500)).await; } } /// 传感器读取任务 —— 需要更大的栈2KB /// 因为涉及 I2C 协议栈的函数调用链较深 #[embassy_executor::task] async fn read_sensor( i2c: embassy_stm32::i2c::I2cstatic, embassy_stm32::mode::Blocking, ) { // BME280 温湿度传感器的 I2C 地址 const SENSOR_ADDR: u8 0x76; let mut buf [0u8; 8]; loop { // 异步读取传感器数据 —— 硬件 I2C 协处理器在后台工作 // 等待期间 CPU 可以去处理其他任务如 LED 闪烁 match i2c.read(SENSOR_ADDR, mut buf).await { Ok(_) { let temp ((buf[3] as u16) 8 | buf[4] as u16) as f32 / 100.0; let humidity buf[6] as f32; // 在实际项目中这里会通过串口或日志输出 defmt::info!(温度: {}°C, 湿度: {}%, temp, humidity); } Err(_) { defmt::warn!(传感器读取失败1秒后重试); } } // 每 5 秒读取一次 Timer::after(Duration::from_secs(5)).await; } } /// 主入口 —— embassy 的 main 也是异步的 #[embassy_executor::main] async fn main(spawner: Spawner) { // 初始化硬件抽象层 let p embassy_stm32::init(Default::default()); // 同时启动多个并发任务 // spawner.spawn() 为每个任务分配独立的栈空间 spawner.spawn(blink_led(p.PB0)).unwrap(); spawner.spawn(read_sensor(p.I2C1)).unwrap(); spawner.spawn(uart_handler(p.USART1, p.PB6, p.PB7)).unwrap(); defmt::info!(系统启动完成3个任务并发运行中); }2.1 栈深度的灾难性后果这是我在 embassy 上学到的最沉痛的一课。如果你的任务函数里有深层递归或大量局部变量而又给栈分配太小结果不是panic——而是静默的内存破坏。在嵌入式上栈溢出不会触发任何保护它只是覆盖掉你下一个任务的数据。# 我的经验法则基于 STM32F411 的 32KB RAM 任务类型 推荐栈大小 说明 LED/简单IO 512B 只需保存几个寄存器状态 UART 通信 1KB 缓冲区 协议栈 I2C/SPI 传感器 2KB 驱动栈 数据缓冲区 HTTP/TLS 客户端 8KB 对于 F411 基本不够用三、中断驱动的异步外设embassy 真正的魔法不是语法糖而是把硬件中断映射为 Future 的唤醒机制。对应代码实现/// UART 命令处理器 —— 中断驱动的异步串口通信 #[embassy_executor::task] async fn uart_handler( mut uart: embassy_stm32::usart::Uartstatic, embassy_stm32::mode::Blocking, ) { let mut buf [0u8; 64]; // 接收缓冲区 loop { // read_until_idle() 是异步的 // 底层通过 DMA 中断驱动CPU 在等待期间可执行其他任务 match uart.read_until_idle(mut buf).await { Ok(n) { let command core::str::from_utf8(buf[..n]).unwrap_or(); // 解析串口命令 match command.trim() { status { // 回复系统状态 uart.write(bSystem OK\r\n).await.ok(); } led_on { // 通过 embassy 的 channel 通知 LED 任务 // 实际代码中通过信号量/通道实现任务间通信 } _ { uart.write(bUnknown command\r\n).await.ok(); } } } Err(_) { // 超时或其他错误继续等待 } } } }四、与 FreeRTOS 的对比心得玩嵌入式的同学肯定会问为什么不直接用 FreeRTOS我也试过。我的选择逻辑非硬实时场景温湿度采集、LED 控制→ embassy代码更安全、更简洁硬实时场景电机控制、微秒级响应→ FreeRTOS 或裸机中断async 的协作式调度不适合团队里有 C 背景的工程师 → 取决于团队成员是否愿意学 Rust。对于个人项目或创业团队embassy Rust 的组合在开发效率和内存安全上有不可替代的优势。真·踩坑有次我把 UART 接收缓冲区从 64 字节改成 256 字节忘了同时调整任务栈大小。结果是 I2C 传感器的读数每隔几次就出现完全随机的数值——栈溢出的数据覆盖了传感器缓冲区的内存。不是 panic是静默的数据损坏。debug 花了整整一个周末。教训每改一次缓冲区大小都必须用defmt::println!(SP: {:x}, cortex_m::register::sp::read())检查栈溢出边界。事后我给 CI 加了一条任何改动静态分配 buffer 大小的 PR必须附带embassy::task::TaskStorage的栈使用报告。在嵌入式上没有编译器帮你检查栈——只能靠流程规范来兜底。嵌入式开发里stack overflow 的排查成本是应用层的十倍多花五分钟检查永远不亏。五、总结用 embassy 在 STM32 上写了三个月代码我的核心感受是三句话async Rust 在嵌入式上不是噱头——它确实能让你用比 C 少得多的代码写出更安全的并发逻辑。硬件中断 → Future 的映射是设计上的神来之笔。栈管理是生死线——32KB RAM 上跑 3 个并发任务每个分配多少栈空间不能拍脑袋。建议用cargo size和defmt做精细监控。程序员玩嵌入式有独特视角——你不需要忘掉上层应用的思维反而可以用并发原语状态机这些概念重新理解嵌入式编程它没有想象中那么难。如果你也在尝试 Rust 嵌入式开发我最推荐的起点是 embassy 官方示例库——从blinky开始一步步加到多任务、I2C 传感器、UART 通信整个学习曲线比用 C 学 STM32 HAL 要平滑得多。下一篇预告WASM AI 生态现状——从工具链到运行时的成熟度评估报告。