Arduino 用 C++,写起来快,但内存只有几十 KB 的单片机最怕两件事:数组越界悬垂指针——C++ 编译期不会拦你,炸了就是现场复位。Rust 的裸机模式(no_std)把所有权和借用检查带进了单片机,而 Embassy 又用 async/await 解决了「裸机多任务怎么写」这个老大难。

本文用一块二十块钱的树莓派 Pico(RP2040)带你跑通第一个 Embassy 程序:让板载 LED 每秒闪一次。

为什么是 Embassy,而不是裸写寄存器

传统裸机要么用阻塞式 delay 干等,要么上 RTOS。Embassy 的思路不一样:

  • 协作式异步执行器:每个任务是一个 async fn,在 I/O 上 .await 时让步,调度器去跑别的任务。切换成本极低,不需要每个任务一个内核栈。
  • 安全的硬件抽象层:GPIO、UART、I2C、SPI、USB 都有安全 Rust API,不用手抖去戳寄存器。
  • DMA 天然适合 async:像串口收发这种等外设的场景,async 比阻塞调用优雅太多。

一句话:比 RTOS 轻,比裸写寄存器安全

准备工具链

# 1. 安装 Rust 目标三元组(RP2040 是 Cortex-M0+)
rustup target add thumbv6m-none-eabi

# 2. 安装 probe-rs(用来烧录和串口监视,替代 OpenOCD)
cargo install probe-rs --locked

STM32 等 Cortex-M4/M7 板子用的是 thumbv7em-none-eabihf,注意别装错。

把 Pico 通过 USB 连电脑,按住 BOOTSEL 键再插线,它会变成一个 U 盘;probe-rs 烧录时也需要这个状态(部分板子会自动进 bootloader)。

项目结构

pico-blinky/
├── Cargo.toml
├── .cargo/
│   └── config.toml
├── memory.x
├── build.rs
└── src/
    └── main.rs

Cargo.toml

[package]
name = "pico-blinky"
version = "0.1.0"
edition = "2021"

[dependencies]
embassy-rp = { version = "0.1", features = ["rp2040"] }
embassy-executor = { version = "0.1", features = ["arch-cortex-m", "executor-thread", "integrated-timers"] }
embassy-time = "0.1"
static-cell = "0.2"
cortex-m = { version = "0.7", features = ["inline-asm"] }
cortex-m-rt = "0.7"
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }

[profile.release]
opt-level = "s"
debug = 0
lto = true

版本号以 crates.io 最新为准,不同版本的 features 名字可能微调,照着编译器提示改即可。

.cargo/config.toml

[build]
target = "thumbv6m-none-eabi"

[target.thumbv6m-none-eabi]
runner = "probe-rs run --chip RP2040"

memory.x

MEMORY
{
  BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
  FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
  RAM   : ORIGIN = 0x20000000, LENGTH = 264K
}

build.rs

fn main() {
    // 让链接器使用 embassy 提供的链接脚本
    println!("cargo:rustc-link-arg-bins=-Tlink.x");
}

src/main.rs —— 核心来了

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    // 初始化 RP2040 外设
    let p = embassy_rp::init(Default::default());
    // 板载 LED 在 GPIO 25
    let mut led = Output::new(p.PIN_25, Level::Low);

    loop {
        info!("led on");
        led.set_high();
        Timer::after_secs(1).await; // 异步等待,期间调度器可跑别的任务
        info!("led off");
        led.set_low();
        Timer::after_secs(1).await;
    }
}

注意 #[embassy_executor::main] 这个宏——它帮你把 async fn main 包进 Embassy 的 executor,你不用自己写 executor 启动代码。Timer::after_secs(1).await 这行是关键:它是异步等待,CPU 在等待的 1 秒里可以去干别的,而不是空转。

烧录运行

cargo run --release

第一次烧录请确认 Pico 处于 BOOTSEL 状态。成功后会看到板载 LED 一秒一闪,终端打印:

0.001 INFO led on
1.002 INFO led off
2.002 INFO led on

5 条新手避坑指南

  1. 目标三元组装错:RP2040 是 thumbv6m-none-eabi,STM32F4 是 thumbv7em-none-eabihf,装错直接编译失败。
  2. 看不到日志:必须同时有 defmt + defmt-rtt + panic-probe,且 .cargo/config.toml 里的 runner 指向正确的芯片(--chip RP2040)。
  3. 忘记按 BOOTSEL:第一次 probe-rs run 连不上板子,九成是没进 bootloader。
  4. 死循环里不 .await 会饿死任务:Embassy 是协作式调度,某个任务一直占着不放,其他任务永远没机会跑。
  5. 误以为能用 println!/Vecno_std 下没有标准库,动态分配要用 heaplessembedded-alloc 显式引入。

下一步

  • embassy-rpcyw43 包给 Pico W 加上 WiFi,做一个联网温湿度上报。
  • embassy-usb 把 Pico 变成一个 USB 键盘或串口设备。
  • 多个 async fnspawner.spawn(...) 并发跑,体会协作式多任务。

Rust 把「内存安全」从运行时提前到了编译期,而 Embassy 把它带到了连操作系统都没有的单片机上——这组合值得每个做物联网的人试一次。