知识门户

返回

第 4 章:零成本抽象——当类型系统遇见寄存器

Part1 了解嵌入式rust

第 4 章:零成本抽象——当类型系统遇见寄存器#

本章要回答的问题:Rust 的高级语法会不会让程序变慢?类型状态模式是什么?unsafe 在嵌入式中的角色是什么?


4.1 一个 C 工程师的合理疑虑#

”这么多抽象层,编译出来不会比 C 慢吧?”#

如果你是从 C 转过来的嵌入式工程师,看到 Rust 的 GPIO 代码时,你的第一反应很可能是:

// Rust:看起来"很重"
let mut led = gpioa.pa5.into_push_pull_output();
led.set_high();
rust

对比你熟悉的 C 代码:

// C:看起来"很轻"
GPIOA->BSRR = GPIO_BSRR_BS5;  // 一条寄存器写入
c

你的直觉告诉你:Rust 那段代码有函数调用、有类型转换、有方法分派——编译出来肯定比直接写寄存器慢。

这个直觉是错的。

用数据说话:反汇编对比#

让我们看一个最简单的操作——将 GPIOA 的第 5 位设为高电平。

C 代码(直接寄存器操作):

// C:直接写 BSRR 寄存器
#define GPIOA_BASE  0x40020000UL
#define BSRR_OFFSET 0x18UL

void led_on(void) {
    *(volatile uint32_t *)(GPIOA_BASE + BSRR_OFFSET) = (1UL << 5);
}
c

Rust 代码(使用 stm32f4xx-hal 的类型状态 API):

// Rust:通过 HAL 的类型状态 API
fn led_on(led: &mut gpio::PA5>) {
    led.set_high();
}
rust

两者的 ARM Thumb-2 反汇编--release 模式,opt-level = "s"):

; C 版本:led_on
08000100 :
  8000100: movw  r0, #0x0018      ; BSRR 偏移
  8000104: movt  r0, #0x4002      ; GPIOA 基地址高 16 位
  8000108: mov   r1, #32          ; 1 << 5 = 32
  800010a: str   r1, [r0]         ; 写入 BSRR
  800010c: bx    lr               ; 返回

; Rust 版本:led_on
08000100 :
  8000100: movw  r0, #0x0018      ; BSRR 偏移
  8000104: movt  r0, #0x4002      ; GPIOA 基地址高 16 位
  8000108: mov   r1, #32          ; 1 << 5 = 32
  800010a: str   r1, [r0]         ; 写入 BSRR
  800010c: bx    lr               ; 返回
asm

完全相同。逐条指令相同。

这不是巧合,也不是特例。这是 Rust 编译器的设计目标——零成本抽象(Zero-Cost Abstraction)

“你不必为不使用的特性付出运行时开销;使用高级抽象时,其性能应等同于手写的底层代码。” —— Rust 设计原则(源自 C++ 之父 Bjarne Stroustrup 的零开销原则)

为什么能做到?#

Rust 编译器(rustc + LLVM)在 --release 模式下会执行以下优化:

  1. 内联(Inlining)set_high() 是一个 #[inline] 函数,编译器将其展开为寄存器操作
  2. 常量折叠(Constant Folding):引脚号 5 在编译期已知,1 << 5 直接计算为 32
  3. 死代码消除(Dead Code Elimination):类型状态参数(Output<PushPull>)是零大小类型(ZST),不占用任何运行时空间
  4. 单态化(Monomorphization):泛型函数为每个具体类型生成专用代码,无虚函数分派

这些优化在 C 中也有对应物(inline-O2),但 Rust 的优势在于:你不需要手动管理这些优化——类型系统本身就”引导”编译器生成最优代码。


4.2 GPIO 的类型状态抽象#

C 的做法:直接操作寄存器地址#

在 C 中,GPIO 操作就是”往特定地址写特定值”:

这段代码的问题:

  1. 没有类型安全:你可以对输入引脚调用 led_on()——编译器不会阻止你
  2. 没有状态追踪:如果 PA5 被配置为 UART 的 TX 复用功能,你仍然可以调用 led_on()——硬件行为未定义
  3. 没有初始化保证:如果忘了调用 gpio_init()led_on() 操作的是一个未配置的引脚
  4. 没有并发保护:如果中断中也操作 PA5,主循环和中断可能同时修改寄存器

Rust 的做法:类型状态模式(Typestate Pattern)#

Rust 的 HAL 库用一个精妙的设计解决了所有这些问题——将硬件状态编码到类型中

类型状态模式的核心思想#

类型状态模式(Typestate Pattern) 的核心思想是:

用类型参数编码对象的”状态”,让编译器在编译期阻止非法的状态转换和操作。

在 GPIO 的例子中:

// 引脚的"状态"被编码为泛型参数
struct PA5 {
    _mode: PhantomData,  // 零大小类型,不占运行时空间
}

// 可能的状态(都是零大小类型)
struct Input;
struct Output;
struct Alternate;
struct Analog;

// 输出类型的子状态
struct PushPull;
struct OpenDrain;
rust

不同状态下,引脚可用的方法不同:

代码对比:C 的”忘记配置”vs Rust 的”编译不通过”#

C 中的典型 bug

// C:忘记初始化 GPIO,直接操作
void bug_example(void) {
    // 忘了调用 gpio_init()!
    // PA5 此时处于复位默认状态(输入模式)

    GPIOA->BSRR = (1UL << 5);  // 写入 BSRR
    // 硬件行为:PA5 是输入模式,写 BSRR 无效
    // LED 不会亮,但编译器不会报错
    // 这个 bug 可能需要用示波器才能发现
}
c

Rust 中的等价场景

// Rust:不可能"忘记初始化"
fn bug_example(gpioa: gpio::gpioa::Parts) {
    // gpioa.pa5 的类型是 PA5(复位默认状态)

    // gpioa.pa5.set_high();
    // ❌ 编译错误:
    // error[E0599]: no method named `set_high` found for struct `PA5<Input>`
    //   = note: the method is available for `PA5<Output<PushPull>>` but not `PA5<Input>`

    // 你必须先转换状态:
    let mut led = gpioa.pa5.into_push_pull_output();
    led.set_high();  // ✅ 现在可以了
}
rust

编译器错误信息(Rust 的错误信息非常友好):

error[E0599]: no method named `set_high` found for struct `PA5<Input>`
   in the current scope
  --> src/main.rs:12:15
   |
12 |     gpioa.pa5.set_high();
   |               ^^^^^^^^
   |
   = note: the method is available for `PA5<Output<PushPull>>` here:
           - `impl<OT> PA5<Output<OT>> { fn set_high(&mut self); }`
   = help: items with the same name exist for different types
   = help: consider converting `PA5<Input>` to `PA5<Output<PushPull>>`
           using `.into_push_pull_output()`
plaintext

编译器不仅告诉你”不能这样做”,还告诉你”应该怎么做”。

类型状态的完整生命周期#

                    into_push_pull_output()
    PA5 ─────────────────────────────→ PA5>
        │                                          │
        │  into_open_drain_output()                │  into_input()
        ▼                                          ▼
    PA5> ──── into_input() ──→ PA5

        │  into_alternate::<7>()  (AF7 = USART)

    PA5>

        │  into_analog()

    PA5
plaintext

每次状态转换都消耗(consume)旧的引脚对象,返回新的引脚对象。这意味着:

  • 你不可能在”输出模式”下调用”输入模式”的方法
  • 你不可能跳过初始化直接操作
  • 你不可能同时持有同一引脚的两种状态

与 C 的 enum + switch 对比#

C 工程师可能会想:“我也可以用 enum 记录状态,用 switch 检查啊?“

方面C 的运行时检查Rust 的类型状态
检查时机运行时(每次调用都检查)编译期(一次检查,永久保证)
性能开销每次调用都有 if 分支零开销(类型参数是 ZST)
遗漏风险可能忘记检查不可能遗漏(方法不存在)
调试难度运行时才发现错误编译时就发现错误
代码体积检查代码占用 Flash零额外代码

4.3 零成本抽象的编译结果#

反汇编对比:完整流程#

让我们对比一个更完整的例子——初始化 GPIO 并闪烁 LED。

C 代码

Rust 代码(使用 stm32f4xx-hal):

Rust 版本的核心循环反汇编cargo objdump --release -- --disassemble):

关键观察

  1. led.set_high() 被内联为一条 str 指令——与 C 的 GPIOA->BSRR = ... 完全相同
  2. 类型参数 Output<PushPull> 在编译后完全消失——没有任何运行时痕迹
  3. into_push_pull_output() 的寄存器配置代码被内联到初始化部分
  4. 整个程序没有任何函数调用(bl 指令)——所有抽象都被内联展开

与 C 的 inline 函数和 #define 宏的对比#

机制CRust区别
内联__attribute__((always_inline))#define#[inline] / #[inline(always)]Rust 的 inline 是”建议”,编译器最终决定
类型安全#define 宏无类型检查泛型函数有完整类型检查Rust 的 inline 函数仍然是”真正的函数”
调试宏展开后难以调试inline 函数保留调试信息Rust 的调试体验更好
条件编译#ifdef + 宏#[cfg(...)] + 泛型Rust 的方式更结构化

核心结论:Rust 的类型状态抽象在编译后完全消失。你得到的是与手写 C 寄存器操作完全相同的机器码,但你获得了编译期的类型安全保障。这就是”零成本抽象”的含义——抽象的成本为零,安全性是免费的。

什么时候不是零成本?#

诚实地说,并非所有 Rust 抽象都是零成本的。以下情况会产生运行时开销:

情况开销嵌入式中的建议
dyn Trait(动态分派)虚函数表间接调用避免,使用泛型 impl Trait
Box<T>(堆分配)堆分配器开销避免,使用栈分配或 static
format!() / String堆分配 + 格式化使用 defmt 替代
迭代器的 .collect()可能分配集合使用 for_each 或手动循环
Result / Option零开销(编译器优化)放心使用
泛型函数零开销(单态化)放心使用
类型状态零开销(ZST)放心使用

经验法则:在 --release 模式下,如果你只使用泛型(而非 dyn)和栈分配(而非堆),Rust 代码的性能与 C 完全相同。


4.4 单例模式#

为什么外设是单例?#

一个 STM32F407 芯片上:

  • 只有一个 GPIOA(基地址 0x40020000)
  • 只有一个 USART2(基地址 0x40004400)
  • 只有一个 RCC(基地址 0x40023800)

这些外设是物理上唯一的硬件资源。如果两个模块同时”拥有”同一个外设,就会产生冲突:

C 的解决方案是约定

  • “USART2 归通信模块管,其他人不许碰”
  • “GPIOA 的 pin 5 归 LED 模块,pin 6 归蜂鸣器模块”

这些约定靠文档和代码审查来维护——编译器完全不知道这些约定的存在。

C 的做法:全局变量 + 约定#

Rust 的做法:Peripherals::take() 单例#

take() 的工作原理#

// cortex-m 的 Peripherals::take() 实现(简化)
impl Peripherals {
    pub fn take() -> Option {
        // 使用原子操作检查是否已经被取走
        critical_section::with(|_| {
            static mut TAKEN: bool = false;
            if unsafe { TAKEN } {
                None  // 已经被取走了,返回 None
            } else {
                unsafe { TAKEN = true; }
                Some(Peripherals { /* ... */ })
            }
        })
    }
}
rust

关键保证

  1. 只能调用一次:第二次调用 take() 返回 Noneunwrap() 会 panic
  2. 所有权转移:外设的所有权从 dp 转移到使用者,转移后 dp 不能再访问该外设
  3. 编译期保证:你不可能同时持有两个 USART2 的所有权

如果尝试”双重获取”会怎样?#

#[cortex_m_rt::entry]
fn main() -> ! {
    let dp1 = pac::Peripherals::take().unwrap();  // ✅ 第一次:成功
    let dp2 = pac::Peripherals::take().unwrap();  // 💥 panic!第二次:None.unwrap()

    loop {}
}
rust

运行时输出(通过 RTT):

panicked at 'called `Option::unwrap()` on a `None` value', src/main.rs:8:52
plaintext

注意:在 Embassy 框架中(第 8 章),Peripherals::take()#[embassy_executor::main] 宏自动调用,你不需要手动处理。

单例模式与 C 的对比总结#

方面C 的做法Rust 的做法
唯一性保证约定(“这个全局变量只在一个地方初始化”)编译器 + 运行时保证take() 只能成功一次)
所有权追踪无(任何文件都可以 extern 访问)所有权系统(转移后原持有者无法再访问)
初始化保证无(可能忘记调用 init)类型系统(未初始化的外设无法使用)
并发安全无(需要程序员手动加锁)借用规则&mut 保证独占访问)
错误发现时机运行时(可能很晚)编译期(或首次 panic)

4.5 unsafe 在嵌入式中的角色#

什么时候必须用 unsafe#

Rust 的安全保证有一个边界——当你需要与”Rust 类型系统无法描述的世界”交互时,就需要 unsafe。在嵌入式中,这包括:

场景为什么需要 unsafe示例
直接寄存器操作编译器不知道硬件寄存器的副作用(*0x40020018 as *mut u32).write_volatile(32)
DMA 缓冲区硬件和 CPU 同时访问同一块内存DMA 描述符、共享缓冲区
中断中访问共享数据中断和主循环的并发无法用借用规则表达static mut COUNTER: u32
FFI(调用 C 库)C 代码不遵守 Rust 的安全规则调用厂商提供的 C 库
内联汇编编译器无法分析汇编的副作用cortex_m::asm::wfi()
裸指针解引用编译器无法验证指针有效性(*ptr).read_volatile()

unsafe 不是”关闭检查”#

一个常见的误解是:“unsafe 就是关闭 Rust 的安全检查。”

这是错误的。 unsafe 的真正含义是:

“我(程序员)向编译器保证:这段代码满足所有安全不变量(safety invariants),即使编译器无法自行验证。”

unsafe 块中,以下检查仍然有效

  • 所有权和借用规则(仍然不能 use-after-free)
  • 类型检查(仍然不能把 u32f64 用)
  • 模式匹配穷尽性(仍然必须处理所有 enum 变体)

unsafe额外允许以下操作:

  • 解引用裸指针(*const T / *mut T
  • 调用 unsafe fn
  • 访问 static mut 变量
  • 实现 unsafe trait
  • 访问 union 的字段

实际例子:中断中的共享数据#

// ✅ 另一种正确做法:使用 critical-section
use critical_section::Mutex;
use core::cell::RefCell;

static SHARED_DATA: Mutex> =
    Mutex::new(RefCell::new([0u8; 256]));

#[interrupt]
fn USART2() {
    critical_section::with(|cs| {
        let mut buf = SHARED_DATA.borrow(cs).borrow_mut();
        buf[0] = 0x55;  // ✅ 安全:临界区保证独占访问
    });
}
rust

最佳实践:将 unsafe 封装在安全 API 内部#

这是 Rust 嵌入式生态的核心设计原则:

设计哲学

unsafe 被”下沉”到最底层,上层代码全部是安全的。这意味着:

  • 应用开发者永远不需要写 unsafe
  • HAL 开发者只在极少数地方使用 unsafe
  • 如果 HAL 有 bug,问题被限制在 unsafe 块内,容易审计

与 C 的对比:C 中”所有代码都是 unsafe 的”#

// C:每一行代码都可能是"unsafe"的
void process_data(uint8_t *buf, uint16_t len) {
    // buf 可能是 NULL——编译器不检查
    // len 可能大于实际缓冲区——编译器不检查
    // buf 可能已经被 free——编译器不检查
    // buf 可能正在被另一个线程修改——编译器不检查

    for (uint16_t i = 0; i < len; i++) {
        buf[i] = transform(buf[i]);  // 以上任何一种情况都会导致未定义行为
    }
}
c

在 C 中,所有代码都隐式地是 unsafe——编译器不帮你检查任何安全不变量。Rust 的 unsafe 关键字的价值在于:它明确标记了”这里需要程序员额外小心”的位置,让安全审计变得可行。

unsafe 的使用准则#

准则说明
最小化unsafe 块应该尽可能小,只包含必须的操作
文档化每个 unsafe 块前应有 // SAFETY: 注释,说明为什么是安全的
封装unsafe 封装在安全 API 内部,不暴露给调用者
审计unsafe 代码应该被重点审查(cargo geiger 可以统计 unsafe 使用量)
避免如果有安全的替代方案(AtomicU32Mutex、HAL API),优先使用
// 好的 unsafe 使用示例
/// 读取 ADC 数据寄存器
///
/// # Safety
/// 调用者必须确保 ADC 已完成转换(DRDY 标志已置位)
pub unsafe fn read_data_register(adc: *const u32) -> u16 {
    // SAFETY: 调用者保证 ADC 已就绪,指针有效且对齐
    (adc.add(0x10)).read_volatile() as u16
}
rust

2025 年的真实案例:Linux 内核 Rust CVE#

2025 年 12 月,Linux 内核中首个涉及 Rust 代码的 CVE(CVE-2025-68260)被公开。问题出在 Android Binder 驱动的 Rust 重写版本中:

  • 根因unsafe 块中的逻辑疏漏——在特定并发场景下,链表指针发生内存破坏
  • 教训unsafe 代码仍然可能有 bug,Rust 的安全保证不覆盖 unsafe 块内部
  • 启示:这恰恰证明了 unsafe 标记的价值——问题被精确定位到 unsafe 块,而非散布在整个代码库中

这个案例告诉我们:Rust 不是”不可能有 bug”,而是”bug 被限制在明确标记的区域内”。对于嵌入式开发,这意味着你的 HAL 库中的少量 unsafe 代码是需要重点审计的部分,而你的应用代码可以保持 100% safe。


4.6 本章小结#

核心要点#

  1. 零成本抽象是真实的:Rust 的类型状态、泛型、trait 在 --release 模式下编译为与手写 C 完全相同的机器码。这不是营销口号,而是可以用反汇编验证的事实。

  2. 类型状态模式将硬件状态编码到类型中PA5<Output<PushPull>>PA5<Input> 是不同类型,编译器在编译期阻止非法操作。这比 C 的运行时检查更安全、更高效。

  3. 单例模式保证外设的唯一访问Peripherals::take() 确保每个外设只能被获取一次,所有权系统确保不可能同时有两个模块”拥有”同一个 USART。

  4. unsafe 是”安全边界”的标记:它不是”关闭检查”,而是”我向编译器保证”。在嵌入式中,unsafe 应该被封装在 HAL 内部,应用代码保持 100% safe。

  5. 抽象的层次决定了 unsafe 的位置

    • 应用层:100% safe
    • HAL 层:少量 unsafe(封装在安全 API 内)
    • PAC 层:大量 unsafe(直接寄存器操作)
    • 硬件:物理寄存器

本章概念在后续章节中的应用#

概念后续应用
类型状态第 8 章:Embassy HAL 的 GPIO/UART/SPI API
单例模式第 8 章:#[embassy_executor::main] 自动获取外设
零成本抽象第 14 章:性能优化(验证抽象不引入开销)
unsafe 封装第 12 章:理解 HAL 的内部实现
原子操作 / 临界区第 10 章:任务间通信(embassy-sync

一个思维转变#

如果你只记住本章的一件事,请记住这个思维转变:

在 C 中,安全性是程序员的责任——编译器不管你。 在 Rust 中,安全性是编译器的责任——程序员只需要在 unsafe 边界处额外小心。

这不是”Rust 比 C 好”的价值判断——而是”两种不同的工程权衡”。C 给你最大的自由,也给你最大的责任。Rust 限制了你的自由,但承担了大部分责任。对于嵌入式系统——一个 bug 可能导致设备变砖、数据丢失、甚至人身伤害——让编译器承担更多责任,通常是更好的选择。


下一章:第 5 章——并发基础:从轮询到抢占。我们将暂时离开 Rust 语法,回到嵌入式的核心问题:如何在单核 MCU 上同时做多件事?这将为理解 Embassy 的异步模型打下理论基础。