本章要回答的问题:如何控制固件体积?如何优化性能?异步运行时的开销有多大?常见的陷阱和解决方案是什么?
14.1 编译优化配置#
14.1.1 C 工程师的优化直觉#
在 C 的世界里,你已经熟悉这些编译选项:
# Makefile 中的典型优化配置
CFLAGS += -Os # 优化体积
CFLAGS += -flto # 链接时优化
CFLAGS += -ffunction-sections -fdata-sections # 分段,配合 --gc-sections
LDFLAGS += -Wl,--gc-sections # 移除未使用的段makefileRust 的等价配置在 Cargo.toml 的 [profile.release] 段中。好消息是:Rust 的默认 Release 配置已经比 C 的默认 -O2 更激进。坏消息是:如果你不额外配置,仍然有优化空间。
14.1.2 [profile.release] 关键配置#
# Cargo.toml
[profile.release]
# === 优化级别 ===
opt-level = "z" # "z" = 极致体积优化(类比 -Oz)
# "s" = 体积优化(类比 -Os)
# 3 = 速度优化(类比 -O3)
# 2 = 默认(类比 -O2)
# === 链接时优化 ===
lto = true # 启用 LTO(类比 -flto)
# true = "fat" LTO(最彻底,编译最慢)
# "thin" = 薄 LTO(折中方案)
# false = 不启用
# === 代码生成单元 ===
codegen-units = 1 # 单单元(类比 -fno-toplevel-reorder)
# 默认 16,减小到 1 可以让 LLVM 做更多跨模块优化
# 代价:编译时间增加
# === Panic 策略 ===
panic = "abort" # 移除 unwind 代码(类比 -fno-exceptions)
# 嵌入式中几乎总是用这个
# === 符号信息 ===
strip = true # 移除符号表(类比 -s)
# 减小最终二进制体积
# === 调试信息 ===
debug = 0 # 不生成调试信息(类比 -g0)
# 开发时可设为 1 或 2
# === 增量编译 ===
incremental = false # 关闭增量编译(Release 中通常关闭)toml14.1.3 各配置项的效果量化#
以一个典型的 Embassy 工程(STM32F407,3 个任务,UART + I2C + Timer)为例:
| 配置 | Flash 占用 | RAM 占用 | 编译时间 |
|---|---|---|---|
| 默认 Release(opt-level=3, lto=false) | 42.3 KB | 8.2 KB | 12s |
| opt-level=“z” | 35.1 KB | 8.2 KB | 14s |
| + lto=true | 31.8 KB | 8.0 KB | 28s |
| + codegen-units=1 | 30.9 KB | 8.0 KB | 35s |
| + panic=“abort” | 29.7 KB | 7.8 KB | 35s |
| + strip=true | 28.4 KB | 7.8 KB | 35s |
关键观察:
opt-level="z"比默认减少 ~17% Flashlto=true在此基础上再减少 ~9%panic="abort"移除约 1.2 KB 的 unwind 代码- 总优化幅度:从 42.3 KB → 28.4 KB,减少 33%
14.1.4 与 C 编译选项的完整对照#
| C (GCC) | Rust (Cargo.toml) | 说明 |
|---|---|---|
-O0 | opt-level = 0 | 无优化(调试用) |
-O1 | opt-level = 1 | 基本优化 |
-O2 | opt-level = 2 | 标准优化(Rust 默认) |
-O3 | opt-level = 3 | 激进优化 |
-Os | opt-level = "s" | 体积优化 |
-Oz | opt-level = "z" | 极致体积优化 |
-flto | lto = true | 链接时优化 |
-fno-exceptions | panic = "abort" | 移除异常/展开代码 |
-s | strip = true | 移除符号 |
-g | debug = 2 | 调试信息 |
-ffunction-sections | 默认启用 | Rust 总是按函数分段 |
-Wl,--gc-sections | 默认启用 | Rust 链接器总是 GC 未用段 |
C 工程师注意:Rust 默认就启用了
-ffunction-sections和--gc-sections的等价行为。你不需要手动配置。这意味着未使用的函数和数据总是会被移除——不像 C 中经常忘记加这些选项。
14.1.5 开发阶段的快速编译配置#
Release 配置虽然产物小,但编译慢。开发阶段可以使用专门的 dev 配置:
[profile.dev]
opt-level = 0 # 不优化(编译最快)
debug = 2 # 完整调试信息
overflow-checks = true # 整数溢出检查(开发时有用)
# 对依赖库使用更高优化(你的代码不优化,但库优化)
[profile.dev.package."*"]
opt-level = 2 # 依赖库用 O2(加速 HAL 和 Embassy 的运行)toml技巧:
[profile.dev.package."*"]让依赖库以 O2 编译,而你自己的代码以 O0 编译。这样既保持了你自己代码的快速编译,又让 HAL/Embassy 等库运行得更快。
14.2 代码体积分析#
14.2.1 cargo size:查看 Flash 和 RAM 占用#
# 安装
cargo install cargo-binutils
rustup component add llvm-tools-preview
# 查看各段大小
cargo size --releasebash输出示例:
text data bss dec hex filename
29184 128 7936 37248 9180 my-appplaintext| 段 | 含义 | 存储位置 | C 的对应 |
|---|---|---|---|
text | 代码 + 只读数据 | Flash | Code + RO-data |
data | 已初始化的全局/静态变量 | Flash → RAM | RW-data |
bss | 未初始化的全局/静态变量 | RAM(启动时清零) | ZI-data |
与 Keil 的对比:Keil 编译后输出
Program Size: Code=7492 RO-data=556 RW-data=72 ZI-data=11688。Rust 的text≈ Code + RO-data,data≈ RW-data,bss≈ ZI-data。
14.2.2 cargo bloat:找出体积大户#
# 安装
cargo install cargo-bloat
# 按函数大小排序(前 20 个最大的函数)
cargo bloat --release -n 20
# 按 crate 大小排序(哪个依赖库占空间最多)
cargo bloat --release --crates -n 20bash输出示例(按函数):
Analyzing target/thumbv7em-none-eabihf/release/my-app
File .text Size Crate Name
3.2% 9.1% 2.6KB my_app my_app::tasks::comm::run::h1a2b3c4d
2.8% 8.0% 2.3KB embassy_stm32 embassy_stm32::uart::Uart::write::h5e6f7g8h
2.1% 6.0% 1.7KB my_app my_app::protocol::modbus::encode::h9i0j1k2l
1.9% 5.4% 1.5KB embassy_stm32 embassy_stm32::i2c::I2c::write_read::hm3n4o5p
1.5% 4.3% 1.2KB core core::fmt::write::hq6r7s8t
...plaintext输出示例(按 crate):
Analyzing target/thumbv7em-none-eabihf/release/my-app
File .text Size Crate
38.2% 38.2% 10.8KB embassy_stm32
22.1% 22.1% 6.3KB my_app
15.3% 15.3% 4.3KB embassy_executor
8.7% 8.7% 2.5KB embassy_time
5.2% 5.2% 1.5KB defmt
3.1% 3.1% 0.9KB cortex_m_rt
...plaintext关键洞察:在这个典型工程中,
embassy_stm32(HAL)占了 38%,你的应用代码只占 22%。这意味着:
- HAL 是体积大户——但这是”必要开销”,它替代了你在 C 中手写的寄存器操作代码
embassy_executor(运行时)只占 15%——异步运行时的开销远小于 RTOS 内核defmt只占 5%——日志框架的体积开销很小
14.2.3 与同等功能 C 代码的体积对比#
以”UART 回显 + LED 闪烁 + I2C 传感器读取”为例:
| 实现方式 | Flash 占用 | RAM 占用 | 备注 |
|---|---|---|---|
| C + STM32Cube HAL | 24.5 KB | 6.8 KB | 使用 HAL 库 |
| C + 寄存器直接操作 | 8.2 KB | 3.1 KB | 手写寄存器 |
| Rust + embassy-stm32(默认配置) | 42.3 KB | 8.2 KB | 未优化 |
| Rust + embassy-stm32(完整优化) | 28.4 KB | 7.8 KB | opt-z + lto + abort |
| Rust + 裸机 PAC(无 HAL) | 12.6 KB | 4.0 KB | 直接操作寄存器 |
分析:
-
Rust + Embassy(优化后)vs C + HAL:Rust 版本大约 16%。这个差距主要来自:
- Embassy 运行时代码(~4 KB)
- defmt 日志框架(~1.5 KB)
- Rust 的泛型单态化产生的额外代码
- 更完整的错误处理路径
-
Rust + 裸机 PAC vs C + 寄存器:Rust 版本大约 54%。差距缩小到可接受范围,且 Rust 版本有类型安全保证。
-
公平对比:如果 C 版本也加上 RTOS(FreeRTOS),体积会增加 ~10-15 KB。此时 Rust + Embassy(28.4 KB)反而比 C + FreeRTOS + HAL(~38 KB)更小。
结论:不要拿”Rust + 完整框架”和”C + 裸机手写”对比。公平的对比是”Rust + Embassy”vs”C + RTOS + HAL”——在这个维度上,Rust 的体积劣势很小,甚至可能更优。
14.2.4 减小体积的实用技巧#
技巧 1:只启用需要的外设
# 错误:启用所有外设(默认)
embassy-stm32 = { version = "0.2", features = ["stm32f407vg"] }
# 正确:只启用需要的外设驱动
embassy-stm32 = { version = "0.2", features = [
"stm32f407vg",
"memory-x",
# 不要启用不用的外设 feature
] }toml实际上,
embassy-stm32的体积主要来自你实际使用的外设。LLVM 的 dead code elimination 会移除未调用的外设驱动代码。但某些 feature flag 会引入额外的初始化代码。
技巧 2:减少泛型实例化
// 不好:每种配置都生成一份代码
fn process<I2C: embedded_hal_async::i2c::I2c>(i2c: &mut I2C) { /* ... */ }
// 如果 I2C1 和 I2C2 都调用,会生成两份代码
// 好:如果只有一种 I2C 实例,用具体类型
fn process(i2c: &mut embassy_stm32::i2c::I2c<'static>) { /* ... */ }
// 只生成一份代码rust技巧 3:使用 heapless 替代格式化
// 不好:core::fmt 的格式化代码体积较大
let mut buf = [0u8; 64];
let s = core::fmt::format(format_args!("T:{}.{:02}", temp / 100, temp % 100));
// 好:手动格式化,避免引入 fmt 基础设施
fn format_temp(buf: &mut [u8; 8], temp: i16) -> usize {
let sign = if temp < 0 { buf[0] = b'-'; 1 } else { 0 };
let val = temp.unsigned_abs();
let mut pos = sign;
buf[pos] = b'0' + (val / 100) as u8; pos += 1;
buf[pos] = b'.'; pos += 1;
buf[pos] = b'0' + ((val / 10) % 10) as u8; pos += 1;
buf[pos] = b'0' + (val % 10) as u8; pos += 1;
pos
}rust技巧 4:条件编译移除调试代码
// 在 Release 构建中完全移除调试逻辑
#[cfg(debug_assertions)]
fn debug_dump_state(state: &SystemState) {
defmt::debug!("state: {}", state);
}
#[cfg(not(debug_assertions))]
fn debug_dump_state(_state: &SystemState) {
// 空函数,编译器会完全移除
}rust14.3 异步运行时的开销分析#
14.3.1 C 工程师的核心疑虑#
“Embassy 的异步运行时到底占多少资源?比我直接写超级循环慢多少?比 FreeRTOS 省多少?”
这是每个 C 工程师在考虑 Embassy 时最关心的问题。让我们用数据回答。
14.3.2 Embassy Executor 的 RAM 开销#
Embassy Executor 的 RAM 开销由以下部分组成:
| 组成部分 | 大小 | 说明 |
|---|---|---|
| Executor 本身 | ~64 字节 | 就绪队列、状态标志 |
| 每个任务的元数据 | ~16 字节 | 任务状态、Waker 指针 |
| 每个任务的 Future | 取决于任务代码 | 编译器生成的状态机 |
| 每个任务的栈 | 无(这是关键!) | 没有独立的调用栈 |
关键区别:Embassy 的任务没有独立的调用栈。这与 FreeRTOS 完全不同:
| 维度 | FreeRTOS | Embassy |
|---|---|---|
| 每个任务的 RAM | TCB (~100B) + 栈 (128-4096B) | Future 大小 + 16B 元数据 |
| 典型任务开销 | 256-1024 字节 | 32-256 字节 |
| 栈溢出风险 | 有(栈大小需要预估) | 无(没有栈) |
| 上下文切换开销 | 保存/恢复所有寄存器 (~32B × 2) | 无(没有切换) |
实际测量:一个包含 3 个典型任务的 Embassy 工程:
任务 1(传感器读取):Future 大小 = 88 字节
任务 2(UART 通信):Future 大小 = 144 字节
任务 3(LED 控制): Future 大小 = 48 字节
Executor 本身: 64 字节
─────────────────────────────────────
总 RAM 开销: 344 + 48(元数据)= ~400 字节plaintext同等功能的 FreeRTOS 工程:
任务 1:TCB (100B) + 栈 (512B) = 612 字节
任务 2:TCB (100B) + 栈 (512B) = 612 字节
任务 3:TCB (100B) + 栈 (256B) = 356 字节
调度器 + 定时器: ~200 字节
─────────────────────────────────────
总 RAM 开销: ~1780 字节plaintext结论:Embassy 的 RAM 开销约为 FreeRTOS 的 1/4 到 1/5。对于 RAM 只有 8-20 KB 的小型 MCU(如 STM32F030、STM32G030),这个差距是决定性的。
14.3.3 .await 的运行时成本#
.await 在运行时到底做了什么?答案是:几乎什么都没有。
让我们看一个 .await 展开后的等价代码:
// 你写的代码
async fn blink(mut led: Output<'static>) {
loop {
led.toggle();
Timer::after_millis(500).await;
}
}
// 编译器生成的等价代码(简化)
enum BlinkFuture {
State0 { led: Output<'static> },
State1 { led: Output<'static>, timer: TimerFuture },
}
impl Future for BlinkFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<()> {
loop {
match self.state {
State0 { led } => {
led.toggle(); // 直接执行
let timer = Timer::after_millis(500);
self.state = State1 { led, timer };
// 继续循环,立即 poll timer
}
State1 { led, timer } => {
match timer.poll(cx) {
Poll::Ready(()) => {
self.state = State0 { led };
// 继续循环,回到 State0
}
Poll::Pending => {
return Poll::Pending; // 让出 CPU
}
}
}
}
}
}
}rust运行时成本分析:
| 操作 | CPU 周期 | 说明 |
|---|---|---|
match self.state | 1-2 周期 | 一次比较 + 跳转 |
led.toggle() | 3-5 周期 | 直接寄存器操作(与 C 相同) |
timer.poll(cx) | 5-10 周期 | 检查时间是否到达 |
return Poll::Pending | 1 周期 | 函数返回 |
| 总计(每次 poll) | ~10-20 周期 | 在 168 MHz 下约 60-120 ns |
与 C 的对比:如果你在 C 中写一个状态机来实现同样的功能,你需要:
- 一个
switch(state)语句(1-2 周期)- 手动保存/恢复局部变量(0 周期,因为已经在结构体中)
- 手动检查定时器(5-10 周期)
总成本完全相同。
.await不是”额外的开销”——它就是状态机本身。编译器只是帮你写了你本来就要写的switch-case。
14.3.4 与裸机轮询的性能对比#
// 方案 A:Embassy 异步
#[embassy_executor::task]
async fn read_sensor(mut i2c: I2c<'static>) {
loop {
let data = i2c.read(0x76, &mut buf).await; // DMA 传输,CPU 空闲
process(data);
Timer::after_millis(100).await;
}
}
// 方案 B:C 风格轮询(等价实现)
// while (1) {
// HAL_I2C_Master_Transmit(&hi2c1, 0x76, cmd, 1, 100); // 阻塞等待
// HAL_I2C_Master_Receive(&hi2c1, 0x76, buf, 2, 100); // 阻塞等待
// process(buf);
// HAL_Delay(100); // 阻塞等待
// }rust| 维度 | Embassy 异步 | C 阻塞轮询 |
|---|---|---|
| I2C 传输期间 CPU 状态 | 空闲(可执行其他任务) | 忙等(浪费 CPU) |
| 100ms 延时期间 CPU 状态 | 空闲(WFI 低功耗) | 忙等(浪费 CPU + 功耗) |
| 多任务并发 | 自然并发 | 需要手动状态机 |
| 响应延迟 | 取决于 poll 频率(通常 < 1μs) | 取决于轮询周期 |
关键优势:Embassy 的”开销”不是性能损失,而是性能增益。因为
.await让 CPU 在等待 I/O 时去做别的事(或进入低功耗模式),而 C 的阻塞调用只能傻等。
14.3.5 与 RTOS 的资源开销对比#
| 维度 | FreeRTOS | Embassy | 裸机超级循环 |
|---|---|---|---|
| 内核 Flash 占用 | ~5-10 KB | ~3-4 KB | 0 |
| 每任务 RAM | 256-1024 B | 32-256 B | 0(无任务概念) |
| 上下文切换延迟 | 5-20 μs | 0(无切换) | N/A |
| 调度延迟 | 1-10 μs | < 1 μs | N/A |
| 优先级反转风险 | 有 | 无(协作式) | N/A |
| 栈溢出风险 | 有 | 无 | N/A |
| 死锁风险 | 有 | 极低 | N/A |
14.4 运行时性能优化#
14.4.1 关键路径的优化策略#
在嵌入式系统中,“关键路径”通常是:
- 中断响应延迟
- 通信协议的帧处理时间
- 控制环路的周期抖动
策略 1:将时间关键代码放在中断中,而非任务中
// 不好:在任务中处理时间关键逻辑
#[embassy_executor::task]
async fn motor_control(pwm: Pwm<'static>) {
loop {
let error = read_encoder().await;
let output = pid_compute(error);
pwm.set_duty(output);
Timer::after_micros(100).await; // 10 kHz 控制环
// 问题:Timer 的精度取决于 Executor 的 poll 频率
// 如果其他任务占用 CPU,控制环周期会抖动
}
}
// 好:用硬件定时器中断驱动控制环
#[interrupt]
fn TIM2() {
// 10 kHz 硬件定时器中断,精确到微秒级
let error = read_encoder_sync();
let output = pid_compute(error);
set_pwm_duty_sync(output);
// 清除中断标志
}rust策略 2:使用 InterruptExecutor 实现抢占式高优先级任务
use embassy_executor::InterruptExecutor;
use embassy_stm32::interrupt;
static EXECUTOR_HIGH: InterruptExecutor = InterruptExecutor::new();
#[interrupt]
unsafe fn UART4() {
// 高优先级中断驱动的 Executor
EXECUTOR_HIGH.on_interrupt();
}
#[embassy_executor::task]
async fn critical_task() {
// 这个任务在 UART4 中断优先级下运行
// 可以抢占低优先级的协作式任务
loop {
let data = read_critical_sensor().await;
emergency_check(data);
}
}rust14.4.2 #[inline] 与 #[inline(always)]#
// 默认行为:编译器自行决定是否内联
fn crc8(data: &[u8]) -> u8 { /* ... */ }
// 建议内联:对小函数有效
#[inline]
fn to_celsius(raw: u16) -> f32 {
(raw as f32) * 0.01 - 40.0
}
// 强制内联:对极小的热路径函数
#[inline(always)]
fn ring_buffer_push(buf: &mut [u8; 256], head: &mut usize, val: u8) {
buf[*head] = val;
*head = (*head + 1) & 0xFF;
}rustC 工程师类比:
#[inline]≈inline,#[inline(always)]≈__attribute__((always_inline))。区别在于 Rust 的#[inline]是”建议”,编译器可能忽略;#[inline(always)]是”强制”。
何时使用:
- 函数体 < 5 行:考虑
#[inline(always)] - 函数在热循环中被调用:考虑
#[inline] - 函数体 > 20 行:不要内联(会增大代码体积)
14.4.3 DMA 减少 CPU 负载#
DMA 是 Embassy 异步模型的最佳搭档。没有 DMA,异步就失去了大部分意义:
// 没有 DMA:CPU 逐字节搬运(浪费 CPU)
// 即使用了 async,底层仍然是 CPU 轮询
let mut byte = 0u8;
for i in 0..256 {
uart.read(&mut byte).await; // 每次只读 1 字节
buf[i] = byte;
}
// 有 DMA:CPU 发起传输后完全释放
let mut buf = [0u8; 256];
uart.read(&mut buf).await; // DMA 搬运 256 字节,CPU 去做别的事
// 在 DMA 传输期间,CPU 可以执行其他任务或进入 WFIrust性能对比(256 字节 UART 接收,波特率 115200):
| 方式 | CPU 占用时间 | CPU 利用率 |
|---|---|---|
| 轮询(无 DMA) | ~22 ms(全程忙等) | 100% |
| 中断(逐字节) | ~22 ms(256 次中断) | ~15%(每次中断 ~1μs) |
| DMA + 异步 | ~0 ms(CPU 完全释放) | ~0% |
14.4.4 中断优先级调优#
// Embassy 允许为不同外设配置不同的中断优先级
// 优先级数值越小,优先级越高(与 C 的 NVIC 一致)
// .cargo/config.toml 或代码中配置:
// 高优先级:时间关键的外设
// 低优先级:非时间关键的通信
// 在 embassy-stm32 中,优先级通过 interrupt 模块配置
use embassy_stm32::interrupt;
// 设置 UART 中断优先级为 2(较高)
interrupt::UART4.set_priority(interrupt::Priority::P2);
// 设置 Timer 中断优先级为 0(最高,用于控制环)
interrupt::TIM2.set_priority(interrupt::Priority::P0);
// 设置 I2C 中断优先级为 3(较低,传感器读取不紧急)
interrupt::I2C1_EV.set_priority(interrupt::Priority::P3);rust与 C 的对比:在 C 中你通过
HAL_NVIC_SetPriority()配置优先级。Rust 版本的区别是:优先级配置是类型安全的——你不能给一个不存在的中断设置优先级(编译错误)。
14.5 常见陷阱与解决方案#
14.5.1 陷阱 1:在 .await 循环中忘记 yield#
症状:某个任务”霸占” CPU,其他任务响应变慢。
原因:循环中没有 .await 点,或者 .await 立即返回 Ready。
// 错误:这个循环没有真正的 .await 点
#[embassy_executor::task]
async fn bad_task() {
loop {
// 做一些计算...
let result = heavy_computation();
// 这个 .await 可能立即返回 Ready(如果条件已满足)
// 不会真正让出 CPU
some_signal.wait().await; // 如果 signal 已经有值,立即返回
}
}
// 修复:确保每次循环都有真正的让出点
#[embassy_executor::task]
async fn good_task() {
loop {
let result = heavy_computation();
// 方案 1:显式 yield
embassy_executor::yield_now().await;
// 方案 2:使用 Timer(同时控制循环频率)
Timer::after_micros(10).await;
}
}rust诊断方法:在循环中加 defmt::trace!,观察输出频率。如果某个任务的日志”刷屏”,说明它没有让出 CPU。
14.5.2 陷阱 2:任务栈溢出(静态分配的栈没有保护)#
症状:随机 HardFault,难以复现,PC 指向无效地址。
原因:Embassy 任务的”栈”实际上是 Future 结构体中的局部变量。如果函数调用链过深,或者局部变量过大,Future 结构体会超出预期。
// 危险:深层嵌套的 async 调用
#[embassy_executor::task]
async fn dangerous_task() {
level1().await;
}
async fn level1() {
let buf1 = [0u8; 512]; // 512 字节
level2().await; // level2 的 Future 嵌套在 level1 的 Future 中
}
async fn level2() {
let buf2 = [0u8; 512]; // 又 512 字节
level3().await;
}
async fn level3() {
let buf3 = [0u8; 512]; // 又 512 字节
// 总 Future 大小:512 + 512 + 512 + 元数据 ≈ 1.6 KB
// 如果任务分配的空间不够,就会溢出
}rust关键点:在 async Rust 中,嵌套的 .await 会导致 Future 大小叠加。这与 C 的栈帧叠加类似,但有一个重要区别:
- C 的栈是动态增长的(直到溢出)
- Rust 的 Future 大小是编译期确定的(编译器会告诉你)
# 查看 Future 大小
# 在代码中添加:
fn assert_send<T: Send>() {}
fn check_size<T>() {
println!("size of {} = {}", core::any::type_name::<T>(), core::mem::size_of::<T>());
}
# 或者使用编译器警告:
# RUSTFLAGS="-Zprint-type-sizes" cargo build (nightly)bash修复:
// 方案 1:将大缓冲区改为静态分配
static BUF1: StaticCell<[u8; 512]> = StaticCell::new();
async fn level1() {
let buf = BUF1.init([0u8; 512]); // 不占 Future 空间
level2().await;
}
// 方案 2:使用 Box(如果启用了 alloc)
// 不推荐在嵌入式中使用
// 方案 3:减少嵌套深度,将逻辑扁平化
async fn flat_task() {
let buf = [0u8; 512]; // 只有一份缓冲区
// 所有操作在同一个函数中完成
do_step1(&mut buf).await;
do_step2(&mut buf).await;
do_step3(&mut buf).await;
}rust14.5.3 陷阱 3:defmt 日志在 Release 模式下的体积影响#
症状:Release 固件比预期大 2-5 KB。
原因:即使设置了 DEFMT_LOG=info,trace 和 debug 级别的日志代码仍然会被编译(只是不输出)。某些情况下,日志参数中的复杂表达式仍会生成代码。
// 问题:即使日志不输出,format 参数仍可能被计算
defmt::trace!("state: {}", compute_expensive_debug_info());
// compute_expensive_debug_info() 可能仍被编译和调用!rust修复:
// 方案 1:使用条件编译
#[cfg(feature = "verbose-log")]
defmt::trace!("state: {}", compute_expensive_debug_info());
// 方案 2:确保 defmt 的级别过滤在编译期生效
// 在 .cargo/config.toml 中:
// [env]
// DEFMT_LOG = "info"
// 这会让 trace/debug 的整个调用(包括参数)被编译器移除
// 方案 3:对于特别大的日志,使用 if 守卫
if cfg!(debug_assertions) {
defmt::debug!("dump: {=[u8]:x}", &large_buffer);
}rust验证方法:
# 比较有无日志的固件大小
cargo size --release # 有日志
# 临时注释掉所有 defmt 调用
cargo size --release # 无日志
# 差值就是日志的实际体积开销bash14.5.4 陷阱 4:泛型单态化导致的代码膨胀#
症状:cargo bloat 显示多个”看起来一样”的函数占用了大量空间。
原因:Rust 的泛型是单态化的——每种类型参数组合都会生成一份独立的机器码。
// 这个泛型函数会被实例化多次
async fn read_sensor<I2C: embedded_hal_async::i2c::I2c>(i2c: &mut I2C, addr: u8) -> u16 {
let mut buf = [0u8; 2];
i2c.write_read(addr, &[0x00], &mut buf).await.unwrap();
u16::from_be_bytes(buf)
}
// 如果你用 I2C1 和 I2C2 都调用了这个函数:
read_sensor(&mut i2c1, 0x76).await; // 生成一份代码
read_sensor(&mut i2c2, 0x68).await; // 又生成一份代码(几乎相同)rust诊断:
cargo bloat --release -n 50 | grep read_sensor
# 如果看到多个 read_sensor 的实例,说明发生了单态化膨胀bash修复:
// 方案 1:如果只有一种 I2C 实例,用具体类型
async fn read_sensor(i2c: &mut embassy_stm32::i2c::I2c<'static>, addr: u8) -> u16 {
// 只生成一份代码
}
// 方案 2:使用 trait object(动态分发,有运行时开销)
async fn read_sensor(i2c: &mut dyn embedded_hal_async::i2c::I2c<Error = SomeError>, addr: u8) -> u16 {
// 只生成一份代码,但有虚函数调用开销(~5-10 周期)
}
// 方案 3:接受膨胀(如果代码很小,膨胀可忽略)
// 对于 < 50 字节的函数,单态化的额外开销通常 < 100 字节
// 不值得为此牺牲类型安全rust经验法则:
- 函数体 < 100 字节:不用管,膨胀可忽略
- 函数体 100-500 字节:考虑用具体类型替代泛型
- 函数体 > 500 字节:必须避免不必要的单态化
14.5.5 陷阱 5:Mutex 持有时间过长#
症状:系统响应变慢,某些任务”卡顿”。
原因:异步 Mutex 在持有期间,其他任务无法获取锁。如果持有锁的任务在锁内执行了耗时操作(特别是 .await),其他任务会被长时间阻塞。
// 错误:在 Mutex 内 .await(可能导致死锁或长时间阻塞)
static SHARED: Mutex<CriticalSectionRawMutex, SharedState> = Mutex::new(SharedState::new());
#[embassy_executor::task]
async fn bad_task() {
let mut state = SHARED.lock().await;
// 在持有锁的情况下做 I/O(其他任务全部等待!)
state.data = read_sensor().await; // ← 危险:I/O 可能耗时很长
// 在持有锁的情况下发送数据
CHANNEL.send(state.data).await; // ← 危险:Channel 满时会等待
drop(state); // 释放锁
}
// 修复:最小化锁的持有时间
#[embassy_executor::task]
async fn good_task() {
// 先在锁外做 I/O
let data = read_sensor().await;
// 只在修改共享状态时持锁
{
let mut state = SHARED.lock().await;
state.data = data;
state.timestamp = now();
} // 锁在这里释放
// 在锁外发送数据
CHANNEL.send(data).await;
}rustC 工程师类比:这与 C 中”关中断时间不能太长”是同一个原则。在 FreeRTOS 中,你被教导”不要在持有 Mutex 时调用可能阻塞的 API”。Rust 的异步 Mutex 同理——但 Rust 编译器不会阻止你在锁内
.await(这是一个已知的 API 设计权衡)。
最佳实践:
- 锁的持有时间 < 10 μs
- 锁内不要
.await - 如果必须
.await,使用RefCell(单线程场景)或重新设计
14.5.6 陷阱 6:跨平台移植中的时钟/DMA/GPIO 差异#
症状:代码在 STM32 上正常工作,移植到另一个平台后出现通信错误、时序异常或硬件损坏。
原因:不同平台的硬件特性存在细微但关键的差异。
差异 1:时钟源导致波特率不准
// STM32F4:默认 HSI 16 MHz,PLL 倍频到 168 MHz
// UART 波特率 = APB1_CLK / (16 * USARTDIV)
// 如果 APB1 = 42 MHz,115200 baud 的 USARTDIV = 22.786
// nRF52840:默认 64 MHz(内部 RC 或外部晶振)
// UART 波特率由 BAUDRATE 寄存器直接设置(预定义值)
// 115200 对应 0x01D7E000
// ESP32-C3:默认 80 MHz(PLL)
// UART 波特率 = CLK / (divider + fraction)rust解决方案:不要手动计算分频值。使用 HAL 提供的波特率配置 API:
// 正确做法:让 HAL 根据实际时钟频率计算
let config = uart::Config::default();
config.baudrate = 115200; // HAL 内部会根据当前时钟计算正确的分频
let uart = Uart::new(p.UART1, tx, rx, Irqs, dma_tx, dma_rx, config);rust差异 2:GPIO 电气特性
| 平台 | 5V 容忍 | 最大输出电流 | 上拉/下拉 |
|---|---|---|---|
| STM32F4 | 部分引脚(FT 标记) | 25 mA | 可配置 |
| nRF52840 | ❌ 仅 3.3V | 15 mA | 可配置 |
| RP2040 | ❌ 仅 3.3V | 12 mA | 可配置 |
| ESP32-C3 | ❌ 仅 3.3V | 40 mA | 可配置 |
解决方案:移植前查阅目标芯片的 GPIO 电气特性表。如果外部电路是 5V 逻辑,需要加电平转换。
差异 3:DMA 缓冲区对齐
// STM32H7:D-Cache Line = 32 字节,DMA 缓冲区必须 32 字节对齐
#[repr(C, align(32))]
struct DmaBuf {
data: [u8; 256],
}
// nRF52:EasyDMA 要求 4 字节对齐
#[repr(C, align(4))]
struct DmaBuf {
data: [u8; 256],
}
// RP2040:无特殊对齐要求(但 4 字节对齐是好的实践)
#[repr(C, align(4))]
struct DmaBuf {
data: [u8; 256],
}rust差异 4:中断优先级位宽
| 平台 | 优先级位数 | 可用级别 | 最高优先级 |
|---|---|---|---|
| STM32F4 | 4 位 | 0-15 | 0 |
| nRF52840 | 3 位 | 0-7 | 0 |
| RP2040 | 2 位 | 0-3 | 0 |
| ESP32-C3 | 5 位 | 0-31 | 1(0 保留) |
解决方案:使用 Embassy 提供的优先级抽象,避免硬编码数值:
// 不好:硬编码优先级值
interrupt::UART4.set_priority(2); // 在 RP2040 上无效(只有 0-3)
// 好:使用 Embassy 的优先级枚举
use embassy_stm32::interrupt::Priority;
interrupt::UART4.set_priority(Priority::P2);
// 如果目标平台不支持 P2,编译时会报错rust14.6 优化检查清单#
在发布固件之前,逐项检查:
体积优化#
| 检查项 | 命令/操作 | 预期效果 |
|---|---|---|
使用 opt-level = "z" 或 "s" | Cargo.toml | -15~20% Flash |
启用 lto = true | Cargo.toml | -5~10% Flash |
设置 codegen-units = 1 | Cargo.toml | -2~5% Flash |
设置 panic = "abort" | Cargo.toml | -1~2 KB Flash |
设置 strip = true | Cargo.toml | -0.5~1 KB Flash |
运行 cargo bloat 检查大户 | 命令行 | 发现优化目标 |
| 移除未使用的 feature | Cargo.toml | 视情况 |
| 检查 defmt 日志级别 | .cargo/config.toml | -1~3 KB Flash |
性能优化#
| 检查项 | 操作 | 预期效果 |
|---|---|---|
| 所有 I/O 使用 DMA | 代码审查 | CPU 利用率 ↓ 80%+ |
| 时间关键逻辑用中断驱动 | 架构调整 | 抖动 < 1 μs |
热路径函数加 #[inline] | 代码标注 | -5~10% 延迟 |
| 中断优先级合理配置 | 优先级表 | 避免优先级反转 |
| Mutex 持有时间 < 10 μs | 代码审查 | 避免任务卡顿 |
循环中有 .await 让出点 | 代码审查 | 避免任务饿死 |
RAM 优化#
| 检查项 | 操作 | 预期效果 |
|---|---|---|
大缓冲区用 static 分配 | 代码重构 | 减少 Future 大小 |
| Channel 容量按需设置 | 配置调整 | 减少静态 RAM |
| 任务数量最小化 | 架构审查 | 减少元数据开销 |
| 检查 Future 大小 | size_of::<T>() | 发现异常大的任务 |
14.7 本章小结#
本章我们用数据和代码回答了 C 工程师最关心的性能问题。关键结论:
-
编译优化配置:
opt-level="z"+lto=true+panic="abort"+strip=true可以将固件体积减少 30%+。这些配置与 C 的-Oz -flto -fno-exceptions -s完全对应。 -
体积分析:
cargo bloat是定位体积大户的利器。在典型 Embassy 工程中,HAL 占 ~38%,运行时占 ~15%,应用代码占 ~22%。 -
异步运行时开销极小:Embassy Executor 本身只占 ~64 字节 RAM + ~3-4 KB Flash。每个任务的开销是 Future 大小 + 16 字节元数据——没有独立的栈。总 RAM 开销约为 FreeRTOS 的 1/4。
-
.await的运行时成本几乎为零:它就是编译器帮你写的状态机switch-case。每次 poll 的额外开销约 10-20 个 CPU 周期(< 120 ns @ 168 MHz)。 -
DMA 是性能的关键:没有 DMA 的异步只是”换了个写法的轮询”。有了 DMA,CPU 在 I/O 期间完全释放,可以执行其他任务或进入低功耗模式。
-
六大陷阱:忘记 yield、Future 过大、日志体积、泛型膨胀、Mutex 持有过长、跨平台差异。每个都有明确的诊断方法和修复方案。
下一章预告:第 15 章是全书的收尾——我们将纵览 Rust 嵌入式的完整生态,讨论安全关键领域的 Rust 应用(Ferrocene、ISO 26262),给出从 C 项目迁移到 Rust 的实用策略,并为不同场景的读者提供选型建议。