本章要回答的问题:异步代码怎么调试?Embassy 项目怎么组织?怎么测试?从原型到产品的工程实践是什么?
13.1 调试异步代码:从 GDB 思维到 async 思维#
13.1.1 C 工程师的调试习惯#
作为一个 C 工程师,你的调试流程大概是这样的:
1. 发现 bug(设备行为异常)
2. 连接调试器(J-Link / ST-Link)
3. 在可疑函数设断点
4. 单步执行,观察变量
5. 查看调用栈(backtrace),理解"谁调用了我"
6. 定位问题,修复plaintext这个流程的核心依赖是调用栈。在 C 中,函数调用是同步的、嵌套的:
// C 的调用栈是"深度优先"的
main()
└── app_loop()
└── read_sensor()
└── i2c_transfer()
└── HAL_I2C_Master_Transmit() ← 断点在这里c当你在 HAL_I2C_Master_Transmit() 处停下时,调用栈清晰地告诉你:是 read_sensor() 调用了你,read_sensor() 是被 app_loop() 调用的,app_loop() 是被 main() 调用的。一切井然有序。
13.1.2 异步代码的调试困境#
现在看 Embassy 的异步代码:
#[embassy_executor::task]
async fn sensor_task(mut i2c: I2c<'static>) {
loop {
let temp = read_sensor(&mut i2c).await; // ← 你想在这里调试
process(temp).await;
Timer::after_secs(1).await;
}
}
#[embassy_executor::task]
async fn comm_task(mut uart: Uart<'static>) {
loop {
let cmd = uart.read_until(b'\n').await;
handle_command(cmd).await;
}
}rust当你在 read_sensor() 内部设断点并停下时,调用栈是什么?
__cortex_m_rt_main_trampoline()
└── embassy_executor::raw::Executor::run()
└── <sensor_task as Future>::poll()
└── read_sensor::poll() ← 断点在这里plaintext你看到了什么?没有 comm_task,没有 app_loop,没有你熟悉的嵌套结构。 调用栈是”扁平”的——所有任务都被 Executor 的 run() 函数轮询,每个任务在某一时刻被 poll(),然后返回。
这就是异步调试的第一个认知冲击:
在 C 中,调用栈告诉你”谁调用了我”。在 async 中,没有人”调用”你——是调度器在”轮询”你。
13.1.3 心智模型转换#
| 维度 | C(同步) | Rust async(异步) |
|---|---|---|
| 执行模型 | 函数调用 → 等待返回 → 继续 | poll → Pending → 被唤醒 → 再 poll |
| 调用栈 | 深度嵌套,反映调用关系 | 扁平,只反映当前 poll 路径 |
| ”谁在等我” | 调用者(在栈上) | Waker(不在栈上) |
| 并发可见性 | 中断可能随时打断 | 其他任务在 .await 点交替执行 |
| 状态保存 | 在栈上(局部变量) | 在 Future 结构体中(编译器生成) |
关键洞察:在异步代码中,一个任务的”完整执行历史”不存在于调用栈中。它的状态被编译器编码为一个状态机(Future 结构体),存储在静态内存中。当你在断点处停下时,你只能看到当前这一次 poll 的路径,而不是任务的完整历史。
13.1.4 实用调试策略#
既然调用栈不再万能,C 工程师需要新的调试策略:
策略 1:日志优先于断点
在异步代码中,defmt 日志比断点更有用。因为:
- 断点会暂停整个 Executor,所有任务同时停止
- 日志可以记录任务的时间线,让你看到任务交替执行的全貌
#[embassy_executor::task]
async fn sensor_task(mut i2c: I2c<'static>) {
loop {
defmt::trace!("sensor: starting read");
let temp = read_sensor(&mut i2c).await;
defmt::debug!("sensor: got temp={=i16}", temp);
defmt::trace!("sensor: sending to channel");
CHANNEL.send(temp).await;
defmt::trace!("sensor: sent, waiting 1s");
Timer::after_secs(1).await;
}
}rust策略 2:在 .await 前后打点
异步 bug 通常发生在 .await 的边界——任务在这里让出 CPU,其他任务可能修改共享状态。在每个 .await 前后打日志,可以精确定位问题:
defmt::trace!("before await: state={=u8}", self.state);
some_async_operation().await;
defmt::trace!("after await: state={=u8}", self.state);
// 如果 state 在这两行之间被改变了,说明有并发问题rust策略 3:用 defmt::timestamp 建立时间线
// 在 defmt 配置中启用时间戳
// .cargo/config.toml:
// [env]
// DEFMT_LOG = "trace"
// 输出示例:
// 0.000000 INFO system boot
// 0.001234 TRACE sensor: starting read
// 0.001567 TRACE comm: waiting for data
// 0.002345 DEBUG sensor: got temp=253
// 0.002678 TRACE sensor: sending to channel
// 0.003012 TRACE comm: got data from channelrust通过时间戳,你可以重建任务的交替执行顺序——这是断点无法提供的信息。
13.2 defmt 实战:不只是 println#
13.2.1 defmt 的工作原理回顾#
第 12 章介绍了 defmt 的原理:固件只传输日志索引和原始数据,格式化在主机端完成。这里我们聚焦实战用法。
核心优势(与 C 的 printf 对比):
| 维度 | C printf | defmt |
|---|---|---|
| Flash 占用 | 格式字符串存储在 Flash 中("temp=%d\n" 占 10 字节) | 只存储索引(1-2 字节) |
| RAM 占用 | 需要格式化缓冲区(通常 128-256 字节) | 几乎为零(直接写 RTT 缓冲区) |
| CPU 开销 | 运行时格式化(除法、取模等) | 几乎为零(只写原始字节) |
| 传输量 | 完整字符串("temp=253\n" = 10 字节) | 索引 + 原始数据(1 + 2 = 3 字节) |
| 类型安全 | 无(%d 对应 float 不会报错) | 编译期检查(类型不匹配编译失败) |
实际数据:在一个包含 50 条日志语句的固件中:
- C
printf方案:Flash 增加 ~2.1 KB(格式字符串)+ 256 字节 RAM(缓冲区) - defmt 方案:Flash 增加 ~180 字节(索引表)+ 0 字节额外 RAM
13.2.2 结构化日志语法#
defmt 使用 {=type} 语法指定数据类型,这比 printf 的 %d/%x/%s 更精确:
// 基本类型
defmt::info!("temperature: {=i16}°C", temp);
defmt::info!("voltage: {=u16:mV}", adc_value); // 带单位
defmt::info!("status: {=u8:#04x}", reg); // 十六进制,4位宽
// 数组和切片
defmt::debug!("rx buf: {=[u8]:x}", &buf[..len]); // 十六进制打印缓冲区
// 自定义类型:实现 defmt::Format trait
#[derive(defmt::Format)]
struct SensorReading {
temperature: i16,
humidity: u16,
timestamp: u32,
}
let reading = SensorReading { temperature: 253, humidity: 650, timestamp: 12345 };
defmt::info!("reading: {}", reading);
// 输出: reading: SensorReading { temperature: 253, humidity: 650, timestamp: 12345 }rustC 工程师注意:
#[derive(defmt::Format)]类似于 C 中为结构体写一个to_string()函数,但它是编译期自动生成的,零运行时开销。
13.2.3 日志级别策略#
// 五个级别,从低到高
defmt::trace!("进入函数,参数: {=u8}", arg); // 最详细,只在开发时用
defmt::debug!("中间状态: {=u16}", state); // 调试用
defmt::info!("系统事件: 传感器已连接"); // 正常运行信息
defmt::warn!("异常但可恢复: 重试第 {=u8} 次", n); // 需要关注
defmt::error!("不可恢复错误: I2C 总线故障"); // 需要立即处理rust推荐策略:
| 阶段 | 编译时级别 | 说明 |
|---|---|---|
| 开发调试 | trace | 所有日志都输出 |
| 集成测试 | debug | 过滤掉最细粒度的跟踪 |
| 量产固件 | info 或 warn | 只保留关键信息 |
| 故障排查 | 临时改回 debug | 通过 OTA 推送调试固件 |
配置方式(.cargo/config.toml):
[env]
DEFMT_LOG = "trace" # 开发时
# DEFMT_LOG = "info" # 量产时toml13.2.4 用 defmt 追踪任务调度#
这是调试异步代码最实用的技巧。在每个任务的关键路径上插入 trace 日志:
#[embassy_executor::task]
async fn sensor_task(spawner: Spawner, i2c: I2c<'static>) {
defmt::info!("sensor_task: spawned");
loop {
defmt::trace!("sensor_task: poll start");
let reading = read_sensor(&i2c).await;
defmt::trace!("sensor_task: read done, t={=i16}", reading);
DATA_CHANNEL.send(reading).await;
defmt::trace!("sensor_task: sent to channel");
Timer::after_millis(100).await;
defmt::trace!("sensor_task: timer done, loop");
}
}rust输出示例(带时间戳):
0.000000 INFO sensor_task: spawned
0.000012 TRACE sensor_task: poll start
0.000345 TRACE sensor_task: read done, t=253
0.000350 TRACE sensor_task: sent to channel
0.000355 TRACE sensor_task: timer done, loop
0.100355 TRACE sensor_task: poll start ← 100ms 后再次被 poll
0.100678 TRACE sensor_task: read done, t=254plaintext通过观察时间戳间隔,你可以判断:
- 任务是否按预期周期执行
- 某个
.await是否花了异常长的时间 - 是否有其他任务”抢占”了执行时间
13.2.5 条件编译:量产时移除日志#
// 方法 1:通过 defmt 级别控制(推荐)
// 编译时设置 DEFMT_LOG=info,trace/debug 日志自动被优化掉
// 方法 2:通过 feature flag 控制
#[cfg(feature = "debug-log")]
defmt::trace!("detailed: {=[u8]:x}", &buf);
// Cargo.toml:
// [features]
// debug-log = []
// 开发时: cargo build --features debug-log
// 量产时: cargo build(不带 feature)rust重要:defmt 的级别过滤是编译期的。被过滤掉的日志不会占用任何 Flash 空间,不会有运行时分支判断。这与 C 的
#ifdef DEBUG类似,但更优雅——你不需要手动加#ifdef。
13.3 probe-rs 调试实战#
13.3.1 probe-rs 是什么#
probe-rs 是 Rust 生态的新一代嵌入式调试工具,它同时替代了 C 世界中的多个工具:
| C 世界 | probe-rs 对应功能 |
|---|---|
| OpenOCD(调试服务器) | probe-rs 内置 GDB server + DAP server |
| arm-none-eabi-gdb(调试器) | probe-rs 内置调试器 + VSCode 集成 |
| ST-Link Utility / J-Flash(烧录工具) | probe-rs run / cargo embed |
| SEGGER RTT Viewer(日志查看) | probe-rs 内置 RTT 支持 |
| Keil / IAR(IDE 调试) | VSCode + probe-rs 扩展 |
一个工具,五个功能。这是 Rust 工具链”集成化”理念的体现。
13.3.2 基本调试流程#
一键编译 + 烧录 + 日志:
# 最简流程:编译 → 烧录 → 显示 RTT 日志
cargo run
# 等价于:
cargo build
probe-rs run --chip STM32F407VGTx target/thumbv7em-none-eabihf/debug/my-appbashVSCode 图形化调试:
在 .vscode/launch.json 中配置:
{
"version": "0.2.0",
"configurations": [
{
"type": "probe-rs-debug",
"request": "launch",
"name": "Debug Embassy App",
"chip": "STM32F407VGTx",
"flashingConfig": {
"flashingEnabled": true,
"resetAfterFlashing": true
},
"coreConfigs": [
{
"coreIndex": 0,
"programBinary": "target/thumbv7em-none-eabihf/debug/my-app"
}
]
}
]
}json配置完成后,在 VSCode 中:
- 在代码行号左侧点击设置断点
- 按 F5 启动调试
- 程序烧录到芯片并运行
- 命中断点时自动暂停
- 可以查看变量、调用栈、寄存器
13.3.3 在 async fn 中设断点的技巧#
技巧 1:在 .await 行设断点
async fn read_sensor(i2c: &mut I2c<'static>) -> i16 {
let mut buf = [0u8; 2];
i2c.write_read(0x76, &[0xFA], &mut buf).await; // ← 断点 1:I2C 传输前
// 命中断点 1 时,可以检查 buf 的初始值
let raw = i16::from_be_bytes(buf);
// ← 断点 2:传输完成后
// 命中断点 2 时,可以检查 buf 的实际内容
raw / 10
}rust技巧 2:条件断点
在 VSCode 中,右键断点 → “Edit Breakpoint” → 输入条件:
// 只在温度超过阈值时暂停
raw > 500rust技巧 3:日志断点(Logpoint)
不暂停程序,只在命中时输出日志。适合不想打断实时行为的场景:
// VSCode: 右键 → "Add Logpoint"
// 输入: "sensor read: buf={buf[0]:x} {buf[1]:x}"plaintext13.3.4 查看 Future 状态#
当你在断点处停下时,可以展开变量面板查看 Future 的内部状态。编译器为每个 async fn 生成了一个匿名结构体,其中包含:
- 所有局部变量的当前值
- 当前处于哪个
.await点(状态机的 state 字段) - 被挂起的子 Future
// VSCode 变量面板示例:
▼ sensor_task::future
state: 2 ← 当前在第 2 个 .await 点
i2c: I2c { ... }
buf: [0x01, 0x3A] ← 局部变量仍然存活
__state_1: TimerFuture { ... } ← 被挂起的 TimerplaintextC 工程师类比:这类似于在 FreeRTOS 中查看任务的 TCB(Task Control Block),但信息更丰富——你可以看到任务”停在了哪一行代码”。
13.3.5 内存查看:检查任务栈#
Embassy 的任务栈是静态分配的。你可以通过查看内存来确认栈使用情况:
// 在代码中定义任务栈
#[embassy_executor::task]
async fn sensor_task(/* ... */) {
// 任务代码
}
// 查看栈大小:编译后在 .map 文件中搜索任务名
// 或者使用 cargo size 查看各段大小rust在 probe-rs 调试器中,你可以直接查看内存区域:
// VSCode 调试控制台:
-memory read --address 0x20000000 --count 256plaintext栈溢出检测技巧:在任务栈底部填充魔术值,运行时检查是否被覆盖:
// 这不是 Embassy 内置功能,但可以作为调试手段
// 在 build.rs 或链接脚本中,将未使用的 RAM 填充为 0xDEADBEEF
// 运行时检查该区域是否被修改rust13.3.6 与 Keil / IAR 调试体验的对比#
| 维度 | Keil / IAR | VSCode + probe-rs |
|---|---|---|
| 价格 | 数千~数万元 | 免费 |
| 芯片支持 | 通过 Pack 扩展 | 通过 target 描述文件 |
| 调试界面 | 成熟、稳定 | 现代、可定制 |
| 外设寄存器查看 | 内置(SVD 解析) | 需安装 Embedded Viewer 扩展 |
| 逻辑分析仪 | 内置(部分版本) | 需外部工具 |
| RTT 支持 | 需 SEGGER 授权 | 内置 |
| 脚本化 | 有限 | 完全可编程(probe-rs 是 Rust 库) |
| 学习曲线 | 低(图形化向导) | 中(需配置 JSON) |
实事求是:对于复杂的外设调试(如查看 SPI 波形、分析 DMA 传输),Keil/IAR 的内置工具仍然更方便。但对于日常开发(断点、变量、日志),VSCode + probe-rs 的体验已经足够好,而且免费。
13.4 常见运行时问题诊断#
13.4.1 问题 1:任务”卡死”#
症状:某个任务不再执行,其他任务正常。
原因:任务内部有一个不让出 CPU 的循环。
// 错误:这个循环永远不会让出 CPU
#[embassy_executor::task]
async fn bad_task() {
loop {
do_something();
// 没有 .await!Executor 永远不会切换到其他任务
}
}
// 错误:条件永远不满足
#[embassy_executor::task]
async fn bad_task2() {
wait_for_condition().await; // 如果条件永远不满足,任务永远挂起
do_something();
}rust诊断方法:
- 在可疑循环中加入
defmt::trace!,观察是否还在输出 - 检查所有循环中是否有
.await点 - 检查
.await的条件是否可能永远不满足
修复:
// 修复 1:在循环中加入 yield
loop {
do_something();
embassy_time::Timer::after_millis(1).await; // 让出 CPU
}
// 修复 2:使用 embassy 的 yield_now
loop {
do_something();
embassy_executor::yield_now().await;
}
// 修复 3:为等待设置超时
match timeout(Duration::from_secs(5), wait_for_condition()).await {
Ok(_) => do_something(),
Err(_) => defmt::error!("timeout waiting for condition!"),
}rust13.4.2 问题 2:任务”饿死”#
症状:低优先级任务长时间不执行,高优先级任务正常。
原因:高优先级任务(通过 InterruptExecutor 运行)频繁被唤醒,低优先级任务得不到执行机会。
诊断方法:
- 在每个任务入口加
defmt::trace!,观察执行频率 - 检查高优先级任务是否有频繁的
.await让出点
修复:
- 确保高优先级任务在每次循环中都有
.await - 重新评估是否真的需要
InterruptExecutor(大多数场景不需要) - 考虑将高优先级任务改为中断驱动(不用 Executor)
13.4.3 问题 3:Channel 死锁#
症状:两个任务都不再执行,系统”挂起”。
原因:生产者等 Channel 有空间,消费者等 Channel 有数据,但双方都在等对方。
// 死锁场景:Channel 容量为 1
static CH: Channel<NoopRawMutex, u8, 1> = Channel::new();
// 任务 A:生产者
#[embassy_executor::task]
async fn producer() {
loop {
CH.send(1).await; // Channel 满时挂起
CH.send(2).await; // 等任务 B 消费后才能继续
}
}
// 任务 B:消费者(但逻辑有 bug,永远不消费)
#[embassy_executor::task]
async fn consumer() {
let _ = CH.receive().await;
// bug: 这里 panic 了,但被静默吞掉
// 之后再也不消费了
}rust诊断方法:
- 在所有
.send().await和.receive().await前后加日志 - 检查是否有任务在 Channel 操作之间 panic
- 检查 Channel 容量是否足够
修复:
- 增大 Channel 容量
- 使用
try_send()/try_receive()替代阻塞版本 - 确保消费者任务不会意外退出
13.4.4 问题 4:栈溢出#
症状:随机 HardFault,PC 指针指向无效地址,难以复现。
原因:Embassy 的任务栈是静态分配的,大小在编译期确定。如果任务的实际栈使用超过分配的大小,就会覆盖相邻内存,导致不可预测的行为。
与 C 的对比:在 FreeRTOS 中,栈溢出可以通过
vApplicationStackOverflowHook检测(虽然也不完美)。在 Embassy 中,默认没有任何栈溢出保护。溢出就是 HardFault,没有警告。
诊断方法:
- 观察 HardFault 寄存器:
// 在 HardFault handler 中打印关键寄存器
#[exception]
unsafe fn HardFault() {
// 读取 SCB 寄存器
let cfsr = cortex_m::peripheral::SCB::cfsr();
let hfsr = cortex_m::peripheral::SCB::hfsr();
let mmfar = cortex_m::peripheral::SCB::mmfar();
let bfar = cortex_m::peripheral::SCB::bfar();
// 通过 RTT 输出(如果 RTT 还能工作)
// 或者通过 LED 闪烁编码
loop {}
}rust-
检查 CFSR 的 STKERR 位:如果
CFSR.STKERR == 1,说明是栈操作错误(压栈/弹栈时访问了无效地址),高度怀疑栈溢出。 -
增大栈大小测试:如果增大任务栈后问题消失,确认是栈溢出。
修复:
// Embassy 任务栈大小通过 task 宏的参数控制
// 默认通常是 4096 字节,对于复杂任务可能不够
// 方法 1:增大特定任务的栈
#[embassy_executor::task(pool_size = 4)] // 这个任务最多 4 个实例
async fn complex_task() {
let large_buf = [0u8; 2048]; // 栈上分配 2KB
// ...
}
// 方法 2:将大缓冲区改为静态分配(不占栈)
static LARGE_BUF: StaticCell<[u8; 2048]> = StaticCell::new();
#[embassy_executor::task]
async fn complex_task() {
let buf = LARGE_BUF.init([0u8; 2048]); // 在静态内存中,不占栈
// ...
}
// 方法 3:使用堆分配(如果启用了 alloc)
// 不推荐在嵌入式中使用,但某些场景下是务实的选择rust预防措施:
# .cargo/config.toml
# 启用栈探测(nightly 功能)
# [unstable]
# build-std = ["core", "alloc"]
# [build]
# rustflags = ["-Z", "stack-protector=all"]toml13.4.5 问题 5:Panic 后系统行为#
症状:设备突然停止工作,LED 全灭或全亮,无日志输出。
原因:Rust 的 panic! 在嵌入式中的默认行为是无限循环(loop {})。设备不会复位,不会输出任何信息,只是”死”在那里。
诊断方法:
- 连接调试器,查看 PC 指针是否停在
rust_begin_unwind或panic_handler中 - 检查是否有
unwrap()/expect()调用失败
修复——配置 panic 行为:
// 方案 1:panic 时输出日志 + 复位(推荐用于开发)
// Cargo.toml: panic-probe = { version = "0.3", features = ["print-defmt"] }
use panic_probe as _;
// panic 时会通过 defmt 输出错误信息,然后触发断点(调试器连接时)
// 方案 2:panic 时复位(推荐用于量产)
// Cargo.toml: panic-reset = "0.1"
use panic_reset as _;
// panic 时直接触发系统复位,看门狗会重新启动
// 方案 3:panic 时停机(默认行为)
// Cargo.toml: panic-halt = "0.2"
use panic_halt as _;
// panic 时进入无限循环,设备"死"在那里rust最佳实践:
# Cargo.toml
[dependencies]
# 开发时用 panic-probe(可以看到错误信息)
panic-probe = { version = "0.3", features = ["print-defmt"], optional = true }
# 量产时用 panic-reset(自动恢复)
panic-reset = { version = "0.1", optional = true }
[features]
default = ["dev"]
dev = ["panic-probe"]
release = ["panic-reset"]toml13.5 Embassy 项目的工程架构#
13.5.1 C 工程的典型分层#
一个典型的 C 嵌入式工程长这样:
project/
├── main.c // 入口 + 超级循环
├── app/
│ ├── sensor_app.c // 传感器应用逻辑
│ └── comm_app.c // 通信应用逻辑
├── hal/
│ ├── hal_i2c.c // I2C 硬件抽象
│ └── hal_uart.c // UART 硬件抽象
├── drivers/
│ ├── bme280.c // BME280 传感器驱动
│ └── esp8266.c // WiFi 模块驱动
├── protocol/
│ ├── modbus.c // Modbus 协议
│ └── mqtt.c // MQTT 协议
├── config.h // 全局配置
└── Makefileplaintext问题:
- 模块间通过全局变量和回调函数通信
config.h被所有文件#include,改一个宏全部重编译- 没有强制的访问控制(任何文件都能
extern任何全局变量)
13.5.2 Rust 工程的推荐分层#
project/
├── Cargo.toml
├── .cargo/
│ └── config.toml // target、runner、rustflags
├── memory.x // 链接脚本(Flash/RAM 布局)
├── build.rs // 构建脚本
├── src/
│ ├── main.rs // 入口:初始化 + 任务生成
│ ├── config.rs // 配置常量(替代 config.h)
│ ├── tasks/ // 异步任务
│ │ ├── mod.rs
│ │ ├── sensor.rs // 传感器采集任务
│ │ ├── comm.rs // 通信任务
│ │ └── control.rs // 控制任务
│ ├── drivers/ // 硬件驱动(面向 embedded-hal)
│ │ ├── mod.rs
│ │ ├── bme280.rs // BME280 驱动
│ │ └── motor.rs // 电机驱动
│ ├── protocol/ // 通信协议
│ │ ├── mod.rs
│ │ ├── modbus.rs
│ │ └── frame.rs // 帧编解码
│ └── shared.rs // 共享状态定义(Channel、Signal)
└── tests/ // 集成测试(可选)
└── hil_test.rsplaintext13.5.3 各模块的职责与通信#
main.rs:只做初始化和任务生成
#![no_std]
#![no_main]
mod config;
mod tasks;
mod drivers;
mod protocol;
mod shared;
use embassy_executor::Spawner;
use embassy_stm32::Config;
use shared::{SENSOR_CHANNEL, COMMAND_SIGNAL};
#[embassy_executor::main]
async fn main(spawner: Spawner) {
// 1. 硬件初始化
let p = embassy_stm32::init(Config::default());
// 2. 外设配置
let i2c = /* ... */;
let uart = /* ... */;
// 3. 生成任务(把外设的所有权转移给任务)
spawner.spawn(tasks::sensor::run(i2c)).unwrap();
spawner.spawn(tasks::comm::run(uart)).unwrap();
spawner.spawn(tasks::control::run()).unwrap();
// 4. main 任务可以做一些低优先级的事,或者直接退出
defmt::info!("all tasks spawned");
}rustshared.rs:定义任务间通信原语
use embassy_sync::channel::Channel;
use embassy_sync::signal::Signal;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use crate::drivers::bme280::SensorReading;
use crate::protocol::Command;
// 传感器数据通道:sensor_task → control_task
pub static SENSOR_CHANNEL: Channel<CriticalSectionRawMutex, SensorReading, 4> = Channel::new();
// 命令信号:comm_task → control_task
pub static COMMAND_SIGNAL: Signal<CriticalSectionRawMutex, Command> = Signal::new();rust与 C 的对比:在 C 中,你可能用
volatile全局变量 + 中断回调来实现类似功能。Rust 的Channel和Signal是类型安全的、编译期检查的、不会数据竞争的。
tasks/sensor.rs:传感器采集任务
use embassy_time::Timer;
use embassy_stm32::i2c::I2c;
use crate::drivers::bme280::Bme280;
use crate::shared::SENSOR_CHANNEL;
use crate::config::SENSOR_INTERVAL_MS;
#[embassy_executor::task]
pub async fn run(i2c: I2c<'static>) {
let mut sensor = Bme280::new(i2c);
sensor.init().await.unwrap();
loop {
match sensor.read().await {
Ok(reading) => {
defmt::debug!("sensor: t={=i16} h={=u16}", reading.temperature, reading.humidity);
// 如果 Channel 满了,这里会等待(不会丢数据)
SENSOR_CHANNEL.send(reading).await;
}
Err(e) => {
defmt::error!("sensor read failed: {}", e);
}
}
Timer::after_millis(SENSOR_INTERVAL_MS).await;
}
}rustdrivers/bme280.rs:面向 embedded-hal 的驱动
use embedded_hal_async::i2c::I2c;
pub struct SensorReading {
pub temperature: i16, // 0.01°C
pub humidity: u16, // 0.01%
}
pub struct Bme280<I2C: I2c> {
i2c: I2C,
address: u8,
}
impl<I2C: I2c> Bme280<I2C> {
pub fn new(i2c: I2C) -> Self {
Self { i2c, address: 0x76 }
}
pub async fn init(&mut self) -> Result<(), I2C::Error> {
// 读取芯片 ID,验证通信
let mut id = [0u8; 1];
self.i2c.write_read(self.address, &[0xD0], &mut id).await?;
if id[0] != 0x60 {
// 芯片 ID 不匹配
return Err(/* ... */);
}
// 配置传感器...
Ok(())
}
pub async fn read(&mut self) -> Result<SensorReading, I2C::Error> {
let mut buf = [0u8; 6];
self.i2c.write_read(self.address, &[0xF7], &mut buf).await?;
// 解析原始数据...
Ok(SensorReading { temperature, humidity })
}
}rust关键设计决策:驱动面向
embedded_hal_async::i2c::I2ctrait 编程,而不是面向embassy_stm32::i2c::I2c。这意味着这个驱动可以在 STM32、nRF、ESP32 上复用,无需修改。
13.5.4 大型项目的 Workspace 组织#
当项目规模增大时,可以使用 Cargo Workspace 将驱动和协议分离为独立 crate:
workspace/
├── Cargo.toml // workspace 定义
├── firmware/ // 主固件
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs
│ └── tasks/
├── drivers/ // 驱动库(可跨项目复用)
│ ├── bme280/
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ └── motor/
│ ├── Cargo.toml
│ └── src/lib.rs
├── protocol/ // 协议库
│ ├── modbus/
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ └── frame/
│ ├── Cargo.toml
│ └── src/lib.rs
└── shared-types/ // 共享类型定义
├── Cargo.toml
└── src/lib.rsplaintext# workspace/Cargo.toml
[workspace]
members = [
"firmware",
"drivers/bme280",
"drivers/motor",
"protocol/modbus",
"protocol/frame",
"shared-types",
]
resolver = "2"toml好处:
- 驱动库可以独立编译和测试(不需要硬件)
- 驱动库可以发布到 crates.io,供其他项目使用
- 修改驱动不影响固件的编译缓存(增量编译更快)
13.6 测试策略#
13.6.1 嵌入式测试的挑战#
在 C 的嵌入式开发中,测试通常是这样的:
- 没有单元测试(“嵌入式代码没法测”)
- 靠手动测试(“烧进去看看 LED 亮不亮”)
- 靠 Code Review(“看看逻辑对不对”)
Rust 生态提供了更好的选择,但 no_std 环境确实带来了一些限制。
13.6.2 单元测试:纯逻辑代码#
对于不依赖硬件的纯逻辑代码(协议解析、数据转换、状态机),可以直接用 #[cfg(test)]:
// src/protocol/frame.rs
pub fn encode_frame(cmd: u8, payload: &[u8]) -> heapless::Vec<u8, 256> {
let mut frame = heapless::Vec::new();
frame.push(0xAA).unwrap(); // 帧头
frame.push(cmd).unwrap();
frame.push(payload.len() as u8).unwrap();
frame.extend_from_slice(payload).unwrap();
let crc = crc8(&frame);
frame.push(crc).unwrap();
frame
}
pub fn decode_frame(data: &[u8]) -> Result<(u8, &[u8]), FrameError> {
if data.len() < 4 { return Err(FrameError::TooShort); }
if data[0] != 0xAA { return Err(FrameError::BadHeader); }
let len = data[2] as usize;
if data.len() < 3 + len + 1 { return Err(FrameError::Truncated); }
let crc = crc8(&data[..3 + len]);
if crc != data[3 + len] { return Err(FrameError::BadCrc); }
Ok((data[1], &data[3..3 + len]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_decode_roundtrip() {
let payload = [0x01, 0x02, 0x03];
let frame = encode_frame(0x10, &payload);
let (cmd, data) = decode_frame(&frame).unwrap();
assert_eq!(cmd, 0x10);
assert_eq!(data, &payload);
}
#[test]
fn test_decode_bad_crc() {
let mut frame = encode_frame(0x10, &[0x01]);
let last = frame.len() - 1;
frame[last] ^= 0xFF; // 破坏 CRC
assert!(matches!(decode_frame(&frame), Err(FrameError::BadCrc)));
}
#[test]
fn test_decode_too_short() {
assert!(matches!(decode_frame(&[0xAA, 0x10]), Err(FrameError::TooShort)));
}
}rust运行:cargo test(在主机上运行,不需要硬件)。
C 工程师对比:这相当于 C 的 Unity 框架,但内置在语言中,不需要额外安装。
cargo test一条命令搞定。
13.6.3 驱动测试:Mock embedded-hal trait#
对于依赖硬件的驱动,可以 Mock embedded-hal trait 来测试逻辑:
// 测试用的 Mock I2C
#[cfg(test)]
mod mock {
use embedded_hal_async::i2c::{I2c, ErrorType, ErrorKind};
pub struct MockError;
impl embedded_hal_async::i2c::Error for MockError {
fn kind(&self) -> ErrorKind { ErrorKind::Other }
}
pub struct MockI2c {
// 预设的响应数据
pub responses: heapless::Vec<(u8, heapless::Vec<u8, 32>), 8>,
pub write_log: heapless::Vec<u8, 64>,
}
impl ErrorType for MockI2c {
type Error = MockError;
}
impl I2c for MockI2c {
async fn write_read(
&mut self,
address: u8,
write: &[u8],
read: &mut [u8],
) -> Result<(), Self::Error> {
self.write_log.extend_from_slice(write).unwrap();
// 根据预设响应填充 read 缓冲区
if let Some((_, resp)) = self.responses.pop() {
read.copy_from_slice(&resp[..read.len()]);
}
Ok(())
}
async fn write(&mut self, _addr: u8, _data: &[u8]) -> Result<(), Self::Error> {
Ok(())
}
async fn read(&mut self, _addr: u8, _buf: &mut [u8]) -> Result<(), Self::Error> {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::mock::MockI2c;
use super::Bme280;
#[tokio::test] // 或 embassy 的测试运行时
async fn test_bme280_init() {
let mut mock = MockI2c {
responses: heapless::Vec::new(),
write_log: heapless::Vec::new(),
};
// 预设:读取芯片 ID 寄存器返回 0x60
mock.responses.push((0xD0, heapless::Vec::from_slice(&[0x60]).unwrap())).unwrap();
let mut sensor = Bme280::new(mock);
assert!(sensor.init().await.is_ok());
}
}rust13.6.4 硬件在环测试(HIL)#
对于必须在真实硬件上运行的测试,probe-rs 提供了测试集成:
// tests/hil_test.rs
// 这个测试需要连接真实硬件
#![no_std]
#![no_main]
use embassy_stm32::i2c::I2c;
use defmt::info;
#[embassy_executor::main]
async fn main(_spawner: embassy_executor::Spawner) {
let p = embassy_stm32::init(Default::default());
// 测试 1:I2C 通信
let i2c = I2c::new(p.I2C1, p.PB8, p.PB9, /* ... */);
let mut sensor = Bme280::new(i2c);
sensor.init().await.expect("BME280 init failed");
let reading = sensor.read().await.expect("BME280 read failed");
assert!(reading.temperature > -4000 && reading.temperature < 8500);
info!("HIL test passed: temp={=i16}", reading.temperature);
// 测试通过,输出特殊标记
defmt::info!("TEST PASSED");
// 触发断点,让 probe-rs 知道测试完成
cortex_m::asm::bkpt();
}rust运行:
# probe-rs 可以自动运行 HIL 测试
probe-rs test --chip STM32F407VGTx tests/hil_test.rsbash13.6.5 测试策略总结#
| 测试类型 | 运行环境 | 覆盖范围 | 频率 |
|---|---|---|---|
| 单元测试 | 主机(cargo test) | 纯逻辑:协议、算法、状态机 | 每次提交 |
| Mock 测试 | 主机(cargo test) | 驱动逻辑(不依赖真实硬件) | 每次提交 |
| HIL 测试 | 真实硬件 | 硬件交互:I2C/SPI/UART 通信 | 每日 / 每次发版 |
| 集成测试 | 真实硬件 | 完整系统行为 | 每次发版 |
与 C 的对比:C 的嵌入式测试通常只有最后一种(手动集成测试)。Rust 的 trait 系统使得前两种测试变得可行且简单——这是 Rust 在工程质量上的巨大优势。
13.7 从原型到产品的检查清单#
13.7.1 看门狗配置#
use embassy_stm32::wdg::IndependentWatchdog;
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
// 配置独立看门狗:2 秒超时
let mut wdg = IndependentWatchdog::new(p.IWDG);
wdg.start(embassy_time::Duration::from_secs(2));
// 在主循环或关键任务中定期喂狗
spawner.spawn(watchdog_task(wdg)).unwrap();
}
#[embassy_executor::task]
async fn watchdog_task(mut wdg: IndependentWatchdog<'static>) {
loop {
wdg.feed();
Timer::after_millis(500).await; // 每 500ms 喂一次(超时 2s)
}
}rustC 工程师注意:在 C 中你可能在超级循环里喂狗。在 Embassy 中,看门狗任务应该是最低优先级的任务。如果其他任务卡死导致看门狗任务得不到执行,系统会自动复位。
13.7.2 低功耗模式#
use embassy_stm32::low_power::Executor;
// 使用支持低功耗的 Executor
// 当所有任务都在 Pending 状态时,CPU 自动进入 WFI(Wait For Interrupt)
// 这相当于 C 中的 __WFI(),但由 Executor 自动管理
// 更深的低功耗模式需要手动配置
use embassy_stm32::rcc::low_power::{LpConfig, StopMode};
let config = Config::default();
// 配置 Stop 模式:关闭大部分时钟,只保留 RTC 和唤醒中断rust13.7.3 OTA 更新策略#
┌─────────────────────────────────────────┐
│ Flash 布局 │
├──────────┬──────────┬──────────────────┤
│ Bootloader │ App A │ App B │
│ (16 KB) │ (活跃) │ (待更新) │
├──────────┼──────────┼──────────────────┤
│ 0x08000000│0x08004000│ 0x08040000 │
└──────────┴──────────┴──────────────────┘
更新流程:
1. 通过 UART/WiFi/BLE 接收新固件 → 写入 App B 区域
2. 校验 CRC/签名
3. 设置标志位:下次启动从 App B 启动
4. 复位
5. Bootloader 检查标志 → 跳转到 App B
6. App B 运行成功 → 确认标志(标记为"已验证")
7. 如果 App B 崩溃 → Bootloader 回滚到 App Aplaintext具体实现参见第 11 章的 embassy-boot 部分。
13.7.4 日志与错误上报#
// 产品级日志策略
// 1. 正常运行:只记录 INFO 级别(启动、连接、断开)
// 2. 异常:记录 ERROR + 上下文(错误码、当前状态、最近 N 条操作)
// 3. 持久化:将关键错误写入 Flash 的日志区域
// 4. 上报:通过通信接口(UART/WiFi/BLE)发送给上位机
#[derive(defmt::Format)]
struct ErrorRecord {
timestamp: u32,
error_code: u16,
task_id: u8,
context: [u8; 16],
}
// 环形缓冲区存储最近 32 条错误
static ERROR_LOG: StaticCell<RingBuffer<ErrorRecord, 32>> = StaticCell::new();rust13.7.5 版本管理与固件签名#
// 在 build.rs 中嵌入版本信息
// build.rs:
fn main() {
let git_hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=GIT_HASH={}", git_hash);
println!("cargo:rustc-env=BUILD_TIME={}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"));
}
// 在固件中:
const FIRMWARE_VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_HASH: &str = env!("GIT_HASH");
const BUILD_TIME: &str = env!("BUILD_TIME");
// 启动时打印
defmt::info!("FW v{} ({}), built {}", FIRMWARE_VERSION, GIT_HASH, BUILD_TIME);rust13.7.6 生产烧录流程#
# 1. 编译 Release 固件
cargo build --release --features release
# 2. 生成二进制文件
cargo objcopy --release -- -O binary firmware.bin
# 3. 计算校验和
sha256sum firmware.bin > firmware.bin.sha256
# 4. 批量烧录(使用 probe-rs)
probe-rs download --chip STM32F407VGTx --binary-format bin \
--base-address 0x08000000 firmware.bin
# 5. 验证烧录
probe-rs verify --chip STM32F407VGTx firmware.bin
# 6. 设置选项字节(读保护、写保护)
probe-rs reset --chip STM32F407VGTxbash13.7.7 产品化检查清单总表#
| 检查项 | 状态 | 备注 |
|---|---|---|
| 看门狗已配置并定期喂狗 | ☐ | 超时时间根据最长任务周期确定 |
| Panic 行为已配置(复位或停机) | ☐ | 量产用 panic-reset |
| 低功耗模式已配置 | ☐ | 电池供电产品必须 |
| OTA 更新机制已实现 | ☐ | A/B 分区 + 回滚 |
| 错误日志持久化 | ☐ | 写入 Flash 或外部 EEPROM |
| 固件版本可查询 | ☐ | 通过通信接口上报 |
| 固件签名/校验 | ☐ | 防止篡改 |
| 读保护已启用 | ☐ | 防止固件被读取 |
| 关键参数存储在 Flash | ☐ | 掉电不丢失 |
| EMC 测试通过 | ☐ | 硬件相关 |
| 长期老化测试通过 | ☐ | 72h+ 连续运行 |
13.8 本章小结#
本章我们从”方法论”的角度审视了 Embassy 开发。关键要点:
-
调试思维转换:异步代码的调用栈是扁平的,不能依赖 C 的”看调用栈”习惯。日志(defmt)比断点更适合追踪异步行为。
-
defmt 是嵌入式日志的最佳实践:编译期格式化、类型安全、极低的 Flash/RAM/CPU 开销。在
.await前后打点是调试异步代码的核心技巧。 -
probe-rs 一站式调试:替代了 C 世界中 OpenOCD + GDB + RTT Viewer + 烧录工具的组合。VSCode 集成提供了接近 Keil/IAR 的图形化体验。
-
五类常见运行时问题:任务卡死、任务饿死、Channel 死锁、栈溢出、Panic 静默。每类都有明确的诊断方法和修复方案。
-
工程架构:用模块(
mod)替代 C 的文件组织,用 Channel/Signal 替代全局变量,用 trait 替代函数指针表。大型项目使用 Workspace 分离驱动和协议。 -
测试分层:纯逻辑用
cargo test,驱动用 Mock trait,硬件交互用 HIL 测试。Rust 的 trait 系统使得前两层测试在嵌入式中首次变得简单。 -
产品化清单:看门狗、低功耗、OTA、日志持久化、版本管理、读保护——这些在 C 中需要手动实现的东西,在 Rust 生态中都有成熟的 crate 支持。
下一章预告:第 14 章将深入性能与体积优化——如何让你的 Embassy 固件比同等功能的 C 代码更小?异步运行时的真实开销到底有多大?我们将用反汇编和实际测量数据说话。