Chapter 12 · Rust 嵌入式开发

实战:异步环境监测节点

把前 11 章拧成一个完整项目:用 embassy 异步架构,I2C 周期读温湿度传感器、串口上报数据、LED 指示状态、按键切换模式,全程无堆、无锁、低功耗。附完整 Cargo.toml、任务代码、架构图与量产 checklist。

课程进度100%

项目目标与用到的知识

这个"环境监测节点"每 2 秒读一次温湿度、通过串口上报、用 LED 显示健康状态、按键可切换上报频率。它把整套 Rust 嵌入式技艺串起来:

整体架构:三个并发任务
用 embassy 把系统拆成三个 #[embassy_executor::task]sensor 任务周期读 I2C 传感器并发进 Channel;report 任务从 Channel 收数据、格式化后经串口发出;ui 任务等按键中断切换频率、控制 LED 闪烁。任务间用 embassy 的 Channel/Signal 通信,无锁、无共享可变全局。
embassy Channel(任务间管道)
embassy_sync::channel::Channel 是异步多生产者多消费者队列,容量固定、无堆。sensor 任务 sender.send(reading).await(满则挂起等空位),report 任务 receiver.receive().await(空则挂起等数据)。它把"采集"与"上报"解耦,是异步嵌入式任务通信的地道方式,替代了裸机的 Mutex<RefCell>
embassy Signal(最新值通知)
embassy_sync::signal::Signal 存"最新一个值",新值覆盖旧值。适合传"当前上报周期"这种只关心最新状态的场景:ui 任务按键后 signal.signal(new_period),sensor 任务 signal.wait().await 拿到新周期。与 Channel(不丢数据的队列)互补。
async I2C 传感器读取
用 embassy-stm32 的 async I2C:i2c.write_read(ADDR, &cmd, &mut buf).await。等待传输时任务挂起、CPU 让给别的任务或睡眠。传感器驱动(如 SHT3x/BME280)建议选支持 embedded-hal-async 的版本,直接 .await;否则用阻塞驱动包一层。
串口异步上报
用 embassy 的 Uart/BufferedUartuart.write(bytes).await 底层走 DMA + 中断(呼应第 10 章),发送期间不占 CPU。数据用 heapless::String(第 9 章)在栈上格式化成一行,如 "T=23.5,H=45.2\r\n",避免任何堆分配。
按键中断驱动 UI
ui 任务用 button.wait_for_falling_edge().await(第 8 章)等待按键,无需轮询。按下后循环切换上报周期(2s→5s→10s),通过 Signal 通知 sensor 任务;同时用 LED 反馈:正常慢闪、故障快闪。
健壮性:错误处理与看门狗
I2C 读失败返回 Err,不 unwrap 崩溃,而是记日志、点故障灯、跳过本次。配合独立看门狗 IWDG:主循环定期喂狗,若某任务卡死导致长时间不喂,芯片自动复位——这是量产设备的安全网。
低功耗
embassy 执行器无任务就绪时自动 WFI 睡眠(第 8 章)。因为读传感器、串口发送、等按键全是 .await 挂起,绝大部分时间 CPU 都在睡,靠中断唤醒。相比"轮询 + delay 空转"的写法,平均功耗低一个数量级,适合电池供电节点。

系统架构图

环境监测节点 · 异步任务架构 ═══════════════════════════════════════════════════ 按键 PA0 LED PC13 │ 下降沿中断 ▲ ▼ │ 状态 ┌─────────────┐ Signal(周期) ┌──────────────────┐ │ ui 任务 │ ───────────────▶ │ sensor 任务 │ │ 切周期/控灯 │ │ 按周期读 I2C │ └─────────────┘ │ SHT3x @0x44 │ └────────┬─────────┘ │ Channel.send(Reading) ▼ 串口 TX PA9 ◀──── DMA 发送 ────┌──────────────────┐ (BufferedUart) │ report 任务 │ │ recv → 格式化行 │ │ heapless::String │ └──────────────────┘ 全程:无堆 · 无锁 · 空闲即 WFI 睡眠 · I2C 失败不崩溃

Cargo.toml

# 目标 STM32F411(Black Pill);换芯片改 embassy-stm32 的 feature
[package]
name = "env-node"
version = "0.1.0"
edition = "2021"

[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m", "executor-thread", "integrated-timers"] }
embassy-time     = { version = "0.3", features = ["tick-hz-32_768"] }
embassy-stm32    = { version = "0.1", features = ["stm32f411ce", "time-driver-any", "exti", "memory-x", "unstable-pac"] }
embassy-sync     = "0.6"
embassy-futures  = "0.1"

# 日志与 panic(第 11 章)
defmt        = "0.3"
defmt-rtt    = "0.4"
panic-probe  = { version = "0.3", features = ["print-defmt"] }

# 无堆动态集合(第 9 章)
heapless = "0.8"

# async HAL trait(第 4 章)
embedded-hal-async = "1.0"

[profile.release]
opt-level = "s"      # 优化体积
lto = true
debug = 2          # 保留符号供 defmt 解码,不进最终 Flash

主程序:三任务协作

#![no_std]
#![no_main]
use defmt_rtt as _;
use panic_probe as _;
use core::fmt::Write;
use heapless::String;
use embassy_executor::Spawner;
use embassy_time::{Timer, Duration};
use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, channel::Channel, signal::Signal};
use embassy_stm32::{gpio::{Level, Output, Speed, Input, Pull}, i2c::I2c, usart::Uart, exti::ExtiInput};

// 一条温湿度读数
#[derive(Clone, Copy, defmt::Format)]
struct Reading { temp_c: f32, hum_pct: f32 }

// 任务间通信原语:静态分配、无堆
static CHAN: Channel<ThreadModeRawMutex, Reading, 4> = Channel::new();
static PERIOD: Signal<ThreadModeRawMutex, u64> = Signal::new();

// —— sensor 任务:按周期读 I2C,发进 Channel ——
#[embassy_executor::task]
async fn sensor_task(mut i2c: I2c<'static, embassy_stm32::mode::Async>) {
    let mut period_ms: u64 = 2000;
    loop {
        // 若 ui 改了周期,取最新值(非阻塞判断)
        if let Some(p) = PERIOD.try_take() { period_ms = p; }

        let mut buf = [0u8; 6];
        // SHT3x:发测量命令 0x2400,读 6 字节(温+湿+CRC)
        match i2c.write_read(0x44, &[0x24, 0x00], &mut buf).await {
            Ok(()) => {
                let raw_t = u16::from_be_bytes([buf[0], buf[1]]);
                let raw_h = u16::from_be_bytes([buf[3], buf[4]]);
                let reading = Reading {
                    temp_c: -45.0 + 175.0 * raw_t as f32 / 65535.0,
                    hum_pct: 100.0 * raw_h as f32 / 65535.0,
                };
                CHAN.send(reading).await;   // 满则挂起等消费
            }
            Err(e) => defmt::warn!("i2c read failed: {:?}", defmt::Debug2Format(&e)),
        }
        Timer::after(Duration::from_millis(period_ms)).await;  // 挂起,期间可睡眠
    }
}

// —— report 任务:收数据,格式化后串口发出 ——
#[embassy_executor::task]
async fn report_task(mut uart: Uart<'static, embassy_stm32::mode::Async>) {
    loop {
        let r = CHAN.receive().await;          // 空则挂起等数据
        let mut line: String<32> = String::new();
        let _ = write!(line, "T={=f32},H={=f32}\r\n", r.temp_c, r.hum_pct);
        let _ = uart.write(line.as_bytes()).await;  // DMA 发送,不占 CPU
        defmt::info!("reported {:?}", r);
    }
}

// —— ui 任务:按键切周期 + LED 状态 ——
#[embassy_executor::task]
async fn ui_task(mut button: ExtiInput<'static>, mut led: Output<'static>) {
    let periods = [2000u64, 5000, 10000];
    let mut idx = 0;
    loop {
        button.wait_for_falling_edge().await;   // 挂起等按键
        idx = (idx + 1) % periods.len();
        PERIOD.signal(periods[idx]);           // 通知 sensor 任务
        led.toggle();
        defmt::info!("period -> {} ms", periods[idx]);
        Timer::after(Duration::from_millis(50)).await;  // 消抖
    }
}

#[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 = ExtiInput::new(p.PA0, p.EXTI0, Pull::Up);
    let i2c = /* I2c::new(...) 配 PB6/PB7 + DMA */ todo!();
    let uart = /* Uart::new(...) 配 PA9/PA10 + DMA */ todo!();

    // 启动三个并发任务;它们各自 await,空闲时 CPU 睡眠
    spawner.spawn(sensor_task(i2c)).unwrap();
    spawner.spawn(report_task(uart)).unwrap();
    spawner.spawn(ui_task(button, led)).unwrap();
}
为什么这个架构"很 Rust"

三个任务之间没有任何共享可变全局:数据通过 Channel 流动、控制信号通过 Signal 传递,所有权在任务间清晰移交。这意味着编译器已经帮你证明了"不存在数据竞争"——不是靠加锁靠自觉,而是结构上就无从竞争。加上无堆(heapless + 静态 Channel)、空闲即睡眠,你得到的是一个确定内存、无竞争、低功耗的系统,而写法却像同步顺序代码一样直白。这就是 Rust 嵌入式的终极价值。

从原型到量产:部署 Checklist

阶段检查项对应章节
构建target 三元组正确、opt-level="s"+LTO、memory.x 匹配芯片第 1-3 章
内存启用 flip-link 防栈溢出、确认无堆(或堆区足够)、heapless 容量够大第 9 章
并发无共享可变全局、Channel 容量不溢、无 await 处忙等/长阻塞第 6-8 章
健壮外设错误不 unwrap、启用 IWDG 看门狗、panic 有明确处理策略第 7、11 章
调试发布版 DEFMT_LOG 调到 warn/off、保留 ELF 供事后解码第 11 章
功耗确认空闲进 WFI、关闭未用外设时钟、测量实际平均电流第 8 章
烧录probe-rs 批量烧录、校验 Flash、可选加读保护 RDP第 11 章
上电前的最后三问

① 时钟对了吗?HSE 晶振频率、PLL 倍频要和实际硬件一致,配错会导致串口波特率偏、定时器不准。② 引脚复用对了吗?I2C/UART 的引脚复用号(AF)要查数据手册核对。③ 上拉/下拉对了吗?I2C 的 SCL/SDA 需要上拉(内部或外部 4.7k),按键接法决定 Pull 方向。这三处是新板子最常见的"编译通过但跑不起来"的坑。

本章小结 & 全书回顾

本章用一个异步环境监测节点把全书串起:embassy 三任务架构(sensor 读 I2C、report 串口上报、ui 按键控灯),任务间用静态 Channel(不丢数据的队列)与 Signal(最新值通知)通信,无共享可变全局——编译器直接证明无数据竞争。数据格式化用 heapless::String(无堆),I2C/串口用 async + DMA(等待不占 CPU),按键用 wait_for_falling_edge().await 中断驱动,空闲自动 WFI 睡眠(低功耗)。健壮性靠"错误不 unwrap + IWDG 看门狗",调试靠 defmt+probe-rs。回望全书:从 no_std/core 的裸机基础(1-3 章)、embedded-hal 生态与 typestate GPIO(4-5 章)、中断/RTIC 与所有权并发(6-7 章)、embassy 异步与内存管理(8-9 章)、到 DMA 安全与调试工具链(10-11 章),Rust 嵌入式的主线始终是一句话:把"靠程序员自觉"的一整类 bug(竞态、悬垂、越界、资源冲突)搬到编译期消灭,同时保持零成本抽象与裸机级性能。你现在已经拥有从点灯到量产节点的完整能力,去做点真正上电运行的东西吧。