知识门户

返回

第 8 章:第一个 Embassy 工程——GPIO、UART、SPI、I2C 与 Timer

Part2 深入embassy框架

第 8 章:第一个 Embassy 工程——GPIO、UART、SPI、I2C 与 Timer#

本章要回答的问题:如何用 Embassy 点亮 LED、读取传感器、驱动显示器?每个外设的异步 API 长什么样?与 C HAL 有何区别?

本章定位:动手实操。从零搭建一个 Embassy 工程,逐个外设讲解 API,每个外设都有 C 对比。目标平台:STM32F407VGT6(Nucleo-F401RE 或正点原子探索者开发板均可)。


8.1 工程搭建:从 cargo new 到 LED 闪烁#

步骤一:创建工程#

cargo new embassy_blinky
cd embassy_blinky
bash

步骤二:配置 .cargo/config.toml#

# .cargo/config.toml

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F407VGTx"

[build]
target = "thumbv7em-none-eabihf"

[env]
DEFMT_LOG = "info"
toml

与第 3 章裸机工程的区别runnerprobe-rs run 变为带 --chip 参数的完整命令。Embassy 工程通常使用 probe-rs run 而非 probe-rs download,因为 run 会同时启动 RTT 日志输出。

步骤三:Cargo.toml#

步骤四:memory.x#

/* memory.x —— STM32F407VGT6 的内存布局 */
MEMORY
{
    FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
    RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}
plaintext

步骤五:build.rs#

// build.rs
fn main() {
    println!("cargo:rustc-link-arg-bins=--nmagic");
    println!("cargo:rustc-link-arg-bins=-Tlink.x");
    println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
}
rust

步骤六:第一个 Embassy 程序——LED 闪烁#

步骤七:编译、烧录、运行#

# 编译
cargo build --release

# 烧录并运行(同时启动 RTT 日志)
cargo run --release

# 预期输出(RTT 日志):
# INFO Embassy Blinky 启动!
# INFO LED ON
# INFO LED OFF
# INFO LED ON
# ...
bash

与 C 的逐行对比#

行号C(STM32 HAL)Rust(Embassy)说明
入口int main(void)#[embassy_executor::main] async fn main(_spawner: Spawner)Embassy 入口是 async fn
初始化HAL_Init(); SystemClock_Config();let p = embassy_stm32::init(Default::default());一行搞定时钟+外设
GPIO 配置GPIO_InitTypeDef + HAL_GPIO_Init()Output::new(p.PA5, Level::Low, Speed::Low)类型状态,无需结构体
点亮HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET)led.set_high()方法调用 vs 函数+枚举
延时HAL_Delay(500)Timer::after(Duration::from_millis(500)).await异步等待 vs 阻塞等待
日志printf() + 重定向defmt::info!()零开销日志(编译期格式化)

最关键的差异HAL_Delay(500)阻塞的——CPU 在这 500ms 内什么都不做。Timer::after(...).await异步的——CPU 在这 500ms 内可以执行其他任务。


8.2 GPIO 输出与输入#

8.2.1 GPIO 输出:控制 LED#

Embassy API#

use embassy_stm32::gpio::{Level, Output, Speed};

// 创建输出引脚
let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
//                          ↑引脚  ↑初始电平  ↑速度

// 操作
led.set_high();    // 输出高电平
led.set_low();     // 输出低电平
led.toggle();      // 翻转
led.set_state(Level::High);  // 设置指定电平

// 查询当前状态
let is_high: bool = led.is_set_high();
rust

C 对比(STM32 HAL)#

// C:GPIO 输出配置(~15 行)
__HAL_RCC_GPIOA_CLK_ENABLE();

GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 操作
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);    // 高电平
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);  // 低电平
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);                 // 翻转
c

关键差异#

方面C(HAL)Embassy
时钟使能手动 __HAL_RCC_GPIOA_CLK_ENABLE()自动init() 内部处理)
配置方式填充结构体 + 调用 HAL_GPIO_Init()构造函数 Output::new()
类型安全无(任何引脚都可以传给任何函数)p.PA5 只能用于 GPIOA 的操作)
模式切换重新调用 HAL_GPIO_Init()消耗旧对象,创建新对象(类型状态)
引脚冲突无保护(两个模块可以配置同一引脚)编译错误(所有权转移后不可再用)

8.2.2 GPIO 输入:读取按键#

Embassy API(轮询式)#

use embassy_stm32::gpio::{Input, Pull};

// 创建输入引脚(上拉)
let button = Input::new(p.PA0, Pull::Up);

// 读取电平
let is_pressed: bool = button.is_low();  // 低电平有效(按下接地)
rust

Embassy API(异步等待——重点!)#

C 对比(中断式)#

关键差异#

方面C(中断 + 标志)Embassy(async wait)
中断配置手动配置 NVIC、优先级、EXTI自动wait_for_* 内部配置)
事件通知volatile 标志 + 主循环轮询.await 自动挂起/恢复
竞态风险有(标志可能在检查和清除之间被修改)(单线程协作式)
代码量~40 行~5 行
多按键需要多个标志 + 多个中断多个 .awaitselect

完整示例:按键控制 LED#

注意:在等待按键期间(wait_for_falling_edge().await),CPU 进入 WFI(Wait For Interrupt)低功耗模式。如果有其他任务,它们会正常执行。这就是异步的价值——等待是免费的

8.2.3 多任务 GPIO:两个 LED 独立闪烁#

C 的等价实现需要:

  • 两个定时器中断(或一个定时器 + 分频)
  • 两个 volatile 标志
  • 主循环中的两个 if 判断
  • 或者使用 RTOS 创建两个任务

Embassy 的实现:两个 async fn,两个 spawn,完事。


8.3 UART:异步串口通信#

8.3.1 Embassy UART API#

Embassy 提供两种 UART 模式:

模式类型适用场景
DMA 模式Uart大数据量传输、需要精确控制
缓冲模式BufferedUart小数据量、类似 std::io::Read/Write

DMA 模式(推荐)#

缓冲模式#

8.3.2 C 对比(STM32 HAL + 中断)#

8.3.3 关键差异#

方面C(HAL + 中断)Embassy(async DMA)
接收方式中断回调 + volatile 标志.await(DMA 传输完成后自动恢复)
发送方式HAL_UART_Transmit()(阻塞)或 _IT(中断)uart.write().await(DMA,异步)
CPU 占用中断方式:每字节一次中断DMA 方式:传输期间 CPU 完全空闲
缓冲区管理手动(环形缓冲区 + 头尾指针)HAL 内部管理
错误处理回调中检查错误标志Result 返回值
代码量~50 行~10 行

8.3.4 完整示例:UART 回显 + 命令解析#

注意:这个程序在等待 UART 数据时(uart.read().await),CPU 完全空闲。如果同时有其他任务(如 LED 闪烁),它们会正常运行。在 C 中实现同样的效果,你需要:中断 + DMA + 环形缓冲区 + 主循环解析——至少 100 行代码。


8.4 SPI:驱动 OLED 显示器#

8.4.1 Embassy SPI API#

8.4.2 C 对比(STM32 HAL)#

8.4.3 完整示例:SSD1306 OLED 显示#

8.4.4 SPI 的 C vs Embassy 对比总结#

方面C(HAL)Embassy
初始化~30 行(GPIO + SPI 配置)~8 行(Spi::new()
传输HAL_SPI_Transmit()(阻塞)spi.write().await(DMA 异步)
片选管理手动 HAL_GPIO_WritePin(CS, LOW/HIGH)ExclusiveDevice 自动管理
多设备手动切换 CS每个设备一个 SpiDevice 实例
CPU 占用阻塞式:CPU 等待传输完成DMA 式:传输期间 CPU 执行其他任务

8.5 I2C:读取 BME280 传感器#

8.5.1 Embassy I2C API#

8.5.2 C 对比(STM32 HAL)#

8.5.3 完整示例:BME280 温湿度读取#

8.5.4 I2C 的 C vs Embassy 对比总结#

方面C(HAL)Embassy
初始化~25 行(GPIO 开漏 + I2C 配置)~8 行(I2c::new()
写后读HAL_I2C_Mem_Read()(阻塞,100ms 超时)i2c.write_read().await(DMA 异步)
错误处理返回 HAL_StatusTypeDef,需手动检查Result<(), Error>,可用 ? 传播
总线恢复手动发送 9 个时钟脉冲HAL 内部处理(部分)
多设备同一 hi2c1 句柄,手动切换地址同一 i2c 实例,传入不同地址
CPU 占用阻塞式:CPU 等待 ACKDMA 式:传输期间 CPU 空闲

8.6 Timer:异步定时#

8.6.1 Embassy 的三种定时方式#

方式API适用场景
一次性延时Timer::after(Duration).await”等待 N 毫秒后继续”
周期性触发Ticker::every(Duration) + .next().await”每 N 毫秒执行一次”
超时控制select() + Timer”等待事件,最多等 N 毫秒”

8.6.2 一次性延时#

use embassy_time::{Timer, Duration};

// 等待 1 秒
Timer::after(Duration::from_secs(1)).await;

// 等待 500 微秒
Timer::after(Duration::from_micros(500)).await;

// 等待 100 毫秒
Timer::after(Duration::from_millis(100)).await;
rust

与 C 的对比

// C:阻塞延时(CPU 空转)
HAL_Delay(1000);  // 1 秒内 CPU 什么都不做

// C:非阻塞延时(需要手动管理)
static uint32_t last_time = 0;
if (HAL_GetTick() - last_time >= 1000) {
    last_time = HAL_GetTick();
    // 执行操作
}
c
// Embassy:异步延时(CPU 可以执行其他任务)
Timer::after(Duration::from_secs(1)).await;
// 在这 1 秒内,其他任务正常运行
rust

8.6.3 周期性触发(Ticker)#

use embassy_time::{Ticker, Duration};

let mut ticker = Ticker::every(Duration::from_millis(100));

loop {
    ticker.next().await;  // 等待下一个 100ms 周期
    // 执行周期性操作
    defmt::info!("Tick!");
}
rust

Ticker vs Timer 的区别

Timer::after()Ticker::every()
行为调用时刻开始计时创建时刻开始,固定周期
漂移有(执行时间会累积)(自动补偿执行时间)
适用一次性延时精确周期任务
// ❌ Timer 的漂移问题
loop {
    do_something();  // 假设耗时 5ms
    Timer::after(Duration::from_millis(100)).await;
    // 实际周期 = 100ms + 5ms = 105ms(漂移!)
}

// ✅ Ticker 无漂移
let mut ticker = Ticker::every(Duration::from_millis(100));
loop {
    ticker.next().await;
    do_something();  // 即使耗时 5ms,下一个 tick 仍在 100ms 处
    // 实际周期 = 精确 100ms
}
rust

8.6.4 超时控制(select)#

C 的等价实现

Embassy 的优势select 在等待期间不消耗 CPU——两个 Future 都挂起,CPU 可以执行其他任务。C 的 while 循环是纯空转。

8.6.5 C 的 HAL_Delay vs Embassy 的 Timer#

方面C HAL_Delay()Embassy Timer::after().await
CPU 行为空转(while 循环检查 SysTick)挂起(CPU 执行其他任务或 WFI)
多任务影响阻塞所有其他代码只阻塞当前任务
精度1ms(SysTick 周期)取决于时间驱动(通常 1ms 或更高)
功耗高(CPU 全速运行)(可进入 WFI)
可组合性可与 selectjoin 组合

8.7 综合示例:多外设协作#

需求#

  • 每 2 秒读取 BME280 温度(I2C)
  • 每 500ms 更新 OLED 显示(SPI)
  • 按键切换显示模式(GPIO 输入)
  • UART 输出日志

完整代码#

这段代码的 C 等价实现需要多少行?#

模块C 代码量(估计)Embassy 代码量
系统初始化 + 时钟~50 行1 行
I2C 初始化 + BME280~80 行~30 行
SPI 初始化 + SSD1306~100 行~40 行
UART 初始化 + 日志~40 行~5 行
按键中断 + 消抖~40 行~15 行
任务调度(超级循环/RTOS)~60 行~10 行
任务间通信~30 行(全局变量+临界区)~5 行(Channel)
总计~400 行~106 行

代码量减少约 75%,且消除了所有 volatile、临界区、中断优先级配置。


8.8 本章小结#

API 速查表#

外设创建核心操作异步方法
GPIO 输出Output::new(pin, level, speed)set_high() / set_low() / toggle()无(即时操作)
GPIO 输入Input::new(pin, pull)is_high() / is_low()wait_for_falling_edge().await
UARTUart::new(usart, rx, tx, dma_rx, dma_tx, cfg)write() / read()全部异步
SPISpi::new(spi, sck, mosi, miso, dma_tx, dma_rx, cfg)write() / transfer()全部异步
I2CI2c::new(i2c, scl, sda, dma_tx, dma_rx, cfg)write() / read() / write_read()全部异步
TimerTimer::after(duration).await本身就是异步
TickerTicker::every(duration).next().await本身就是异步

核心认知#

  1. Embassy 的 GPIO 是同步的,通信外设是异步的set_high() 不需要 .await(一条寄存器写入,无需等待),但 uart.write() 需要 .await(DMA 传输需要时间)。

  2. DMA 是异步的基础:UART/SPI/I2C 的 .await 之所以能”释放 CPU”,是因为底层使用 DMA 传输。没有 DMA,就只能阻塞等待。

  3. init() 一行搞定所有初始化:时钟树、外设使能、GPIO 复用——全部由 embassy_stm32::init() 自动处理。C 中需要 50+ 行的 SystemClock_Config() + 外设初始化,在 Embassy 中是一行。

  4. 所有权系统防止引脚冲突p.PA5Output::new() 消耗后,不能再被其他代码使用。如果你尝试把同一个引脚传给两个外设,编译器会报错。C 中没有这种保护。

  5. 任务间通信通过 Channel:不需要 volatile 全局变量,不需要临界区,不需要中断回调。Channel::send().awaitChannel::receive().await 就是全部。

常见错误与排查#

错误信息原因解决方案
use of moved value: p.PA5同一引脚被使用了两次检查引脚分配,确保每个引脚只用一次
no DMA channel availableDMA 通道被其他外设占用更换 DMA 通道(参考数据手册的 DMA 请求映射表)
I2C error: Nack设备地址错误或未连接检查接线、上拉电阻、设备地址
SPI transfer timeout时钟配置错误或接线问题检查 SCK/MOSI/MISO 接线,降低频率
UART garbled output波特率不匹配确认 TX/RX 交叉连接,检查波特率设置
Timer not working未启用 time-driver-any featureCargo.toml 中添加该 feature

下一步#

你现在已经掌握了 Embassy 的基本外设操作。但你可能还有疑问:

  • Timer::after().await 底层是怎么实现的?谁在”计时”?
  • spawner.spawn() 到底做了什么?任务是怎么被”调度”的?
  • .await 挂起后,CPU 到底在做什么?

这些问题的答案在第 9 章:Embassy 运行时——Executor、Future 与 Waker


下一章:第 9 章——Embassy 运行时:Executor、Future 与 Waker。我们将打开 Embassy 的”引擎盖”,看看 async/await 在裸机上到底是怎么运行的。