第 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);
}cRust 代码(使用 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 模式下会执行以下优化:
- 内联(Inlining):
set_high()是一个#[inline]函数,编译器将其展开为寄存器操作 - 常量折叠(Constant Folding):引脚号
5在编译期已知,1 << 5直接计算为32 - 死代码消除(Dead Code Elimination):类型状态参数(
Output<PushPull>)是零大小类型(ZST),不占用任何运行时空间 - 单态化(Monomorphization):泛型函数为每个具体类型生成专用代码,无虚函数分派
这些优化在 C 中也有对应物(inline、-O2),但 Rust 的优势在于:你不需要手动管理这些优化——类型系统本身就”引导”编译器生成最优代码。
4.2 GPIO 的类型状态抽象#
C 的做法:直接操作寄存器地址#
在 C 中,GPIO 操作就是”往特定地址写特定值”:
// STM32F407 的 GPIOA 寄存器(基地址 0x40020000)
typedef struct {
volatile uint32_t MODER; // 模式寄存器(偏移 0x00)
volatile uint32_t OTYPER; // 输出类型寄存器(偏移 0x04)
volatile uint32_t OSPEEDR; // 输出速度寄存器(偏移 0x08)
volatile uint32_t PUPDR; // 上下拉寄存器(偏移 0x0C)
volatile uint32_t IDR; // 输入数据寄存器(偏移 0x10)
volatile uint32_t ODR; // 输出数据寄存器(偏移 0x14)
volatile uint32_t BSRR; // 位设置/复位寄存器(偏移 0x18)
volatile uint32_t LCKR; // 配置锁定寄存器(偏移 0x1C)
volatile uint32_t AFR[2]; // 复用功能寄存器(偏移 0x20-0x24)
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000UL)
// 配置 PA5 为推挽输出
void gpio_init(void) {
// 1. 使能 GPIOA 时钟(RCC_AHB1ENR 的 bit 0)
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// 2. 设置 PA5 为输出模式(MODER5 = 01)
GPIOA->MODER &= ~(3UL << (5 * 2)); // 清除 bit[11:10]
GPIOA->MODER |= (1UL << (5 * 2)); // 设置 bit[11:10] = 01
// 3. 设置推挽输出(OTYPER5 = 0)
GPIOA->OTYPER &= ~(1UL << 5);
// 4. 设置高速(OSPEEDR5 = 10)
GPIOA->OSPEEDR &= ~(3UL << (5 * 2));
GPIOA->OSPEEDR |= (2UL << (5 * 2));
}
// 点亮 LED
void led_on(void) {
GPIOA->BSRR = (1UL << 5); // 置位 PA5
}
// 熄灭 LED
void led_off(void) {
GPIOA->BSRR = (1UL << 21); // 复位 PA5(bit 5 + 16)
}c这段代码的问题:
- 没有类型安全:你可以对输入引脚调用
led_on()——编译器不会阻止你 - 没有状态追踪:如果 PA5 被配置为 UART 的 TX 复用功能,你仍然可以调用
led_on()——硬件行为未定义 - 没有初始化保证:如果忘了调用
gpio_init(),led_on()操作的是一个未配置的引脚 - 没有并发保护:如果中断中也操作 PA5,主循环和中断可能同时修改寄存器
Rust 的做法:类型状态模式(Typestate Pattern)#
Rust 的 HAL 库用一个精妙的设计解决了所有这些问题——将硬件状态编码到类型中:
use stm32f4xx_hal::{
pac, // Peripheral Access Crate(寄存器定义)
prelude::*, // 常用 trait 的预导入
gpio::{self, Output, PushPull, Input},
};
#[cortex_m_rt::entry]
fn main() -> ! {
// 1. 获取外设单例(详见 4.4 节)
let dp = pac::Peripherals::take().unwrap();
// 2. 将 GPIOA 拆分为独立的引脚
let gpioa = dp.GPIOA.split();
// 3. 配置 PA5 为推挽输出
// 此时 led 的类型是:gpio::PA5>
let mut led = gpioa.pa5.into_push_pull_output();
// 4. 操作 LED
led.set_high(); // ✅ 编译通过:Output 模式可以 set_high
led.set_low(); // ✅ 编译通过
// 5. 尝试读取输入
// led.is_high(); // ❌ 编译错误!Output 模式没有 is_high() 方法
loop {}
}rust类型状态模式的核心思想#
类型状态模式(Typestate Pattern) 的核心思想是:
用类型参数编码对象的”状态”,让编译器在编译期阻止非法的状态转换和操作。
在 GPIO 的例子中:
// 引脚的"状态"被编码为泛型参数
struct PA5 {
_mode: PhantomData, // 零大小类型,不占运行时空间
}
// 可能的状态(都是零大小类型)
struct Input;
struct Output;
struct Alternate;
struct Analog;
// 输出类型的子状态
struct PushPull;
struct OpenDrain;rust不同状态下,引脚可用的方法不同:
// 只有 Output 模式才有 set_high / set_low
impl PA5> {
pub fn set_high(&mut self) { /* 写 BSRR */ }
pub fn set_low(&mut self) { /* 写 BSRR */ }
pub fn toggle(&mut self) { /* 读 ODR,写 BSRR */ }
}
// 只有 Input 模式才有 is_high / is_low
impl PA5 {
pub fn is_high(&self) -> bool { /* 读 IDR */ }
pub fn is_low(&self) -> bool { /* 读 IDR */ }
}
// 状态转换:消耗旧类型,返回新类型
impl PA5 {
pub fn into_push_pull_output(self) -> PA5> {
// 配置 MODER、OTYPER 寄存器
PA5 { _mode: PhantomData }
}
}rust代码对比:C 的”忘记配置”vs Rust 的”编译不通过”#
C 中的典型 bug:
// C:忘记初始化 GPIO,直接操作
void bug_example(void) {
// 忘了调用 gpio_init()!
// PA5 此时处于复位默认状态(输入模式)
GPIOA->BSRR = (1UL << 5); // 写入 BSRR
// 硬件行为:PA5 是输入模式,写 BSRR 无效
// LED 不会亮,但编译器不会报错
// 这个 bug 可能需要用示波器才能发现
}cRust 中的等价场景:
// 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()
▼
PA5plaintext每次状态转换都消耗(consume)旧的引脚对象,返回新的引脚对象。这意味着:
- 你不可能在”输出模式”下调用”输入模式”的方法
- 你不可能跳过初始化直接操作
- 你不可能同时持有同一引脚的两种状态
与 C 的 enum + switch 对比#
C 工程师可能会想:“我也可以用 enum 记录状态,用 switch 检查啊?“
// C 的"运行时类型状态"
typedef enum { GPIO_INPUT, GPIO_OUTPUT_PP, GPIO_OUTPUT_OD, GPIO_AF } GpioMode;
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
GpioMode mode; // 运行时状态
} GpioPin;
void gpio_set_high(GpioPin *pin) {
if (pin->mode != GPIO_OUTPUT_PP && pin->mode != GPIO_OUTPUT_OD) {
// 运行时检查——但你可能忘了调用这个函数
// 或者这个检查被 #ifdef NDEBUG 去掉了
return;
}
pin->port->BSRR = pin->pin;
}c| 方面 | C 的运行时检查 | Rust 的类型状态 |
|---|---|---|
| 检查时机 | 运行时(每次调用都检查) | 编译期(一次检查,永久保证) |
| 性能开销 | 每次调用都有 if 分支 | 零开销(类型参数是 ZST) |
| 遗漏风险 | 可能忘记检查 | 不可能遗漏(方法不存在) |
| 调试难度 | 运行时才发现错误 | 编译时就发现错误 |
| 代码体积 | 检查代码占用 Flash | 零额外代码 |
4.3 零成本抽象的编译结果#
反汇编对比:完整流程#
让我们对比一个更完整的例子——初始化 GPIO 并闪烁 LED。
C 代码:
#include "stm32f4xx.h"
void led_blink(void) {
// 使能 GPIOA 时钟
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// 配置 PA5 为推挽输出
GPIOA->MODER &= ~(3UL << 10);
GPIOA->MODER |= (1UL << 10);
GPIOA->OTYPER &= ~(1UL << 5);
while (1) {
GPIOA->BSRR = (1UL << 5); // LED ON
for (volatile int i = 0; i < 1000000; i++); // 延时
GPIOA->BSRR = (1UL << 21); // LED OFF
for (volatile int i = 0; i < 1000000; i++); // 延时
}
}cRust 代码(使用 stm32f4xx-hal):
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use stm32f4xx_hal::{pac, prelude::*};
use panic_halt as _;
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let _clocks = rcc.cfgr.freeze();
let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
loop {
led.set_high();
cortex_m::asm::delay(48_000_000);
led.set_low();
cortex_m::asm::delay(48_000_000);
}
}rustRust 版本的核心循环反汇编(cargo objdump --release -- --disassemble):
08000180 :
; ... 初始化代码省略 ...
; 主循环开始
80001a0: movw r0, #0x0018 ; BSRR 偏移
80001a4: movt r0, #0x4002 ; GPIOA 基地址
80001a8: mov r1, #32 ; 1 << 5
80001aa: str r1, [r0] ; LED ON:写 BSRR
; 延时循环
80001ac: movw r2, #0x0DC0 ; 48000000 的低 16 位
80001b0: movt r2, #0x02DC ; 48000000 的高 16 位
80001b4: subs r2, #1
80001b6: bne #-0x4 ; 循环
; LED OFF
80001b8: mov r1, #0
80001ba: movt r1, #0x0020 ; 1 << 21
80001be: str r1, [r0] ; LED OFF:写 BSRR
; 延时循环
80001c0: movw r2, #0x0DC0
80001c4: movt r2, #0x02DC
80001c8: subs r2, #1
80001ca: bne #-0x4
80001cc: b #-0x2e ; 跳回主循环开始asm关键观察:
led.set_high()被内联为一条str指令——与 C 的GPIOA->BSRR = ...完全相同- 类型参数
Output<PushPull>在编译后完全消失——没有任何运行时痕迹 into_push_pull_output()的寄存器配置代码被内联到初始化部分- 整个程序没有任何函数调用(
bl指令)——所有抽象都被内联展开
与 C 的 inline 函数和 #define 宏的对比#
| 机制 | C | Rust | 区别 |
|---|---|---|---|
| 内联 | __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 中的典型问题:两个模块同时操作同一个 USART
// module_a.c
void module_a_send(uint8_t *data, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
while (!(USART2->SR & USART_SR_TXE));
USART2->DR = data[i];
}
}
// module_b.c
void module_b_send(uint8_t *data, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
while (!(USART2->SR & USART_SR_TXE));
USART2->DR = data[i]; // 💥 如果 module_a 正在发送,数据会被打断!
}
}
// main.c
int main(void) {
// 两个模块都"拥有" USART2——编译器不会阻止你
module_a_send(msg_a, len_a);
module_b_send(msg_b, len_b); // 如果在中断中调用呢?
}cC 的解决方案是约定:
- “USART2 归通信模块管,其他人不许碰”
- “GPIOA 的 pin 5 归 LED 模块,pin 6 归蜂鸣器模块”
这些约定靠文档和代码审查来维护——编译器完全不知道这些约定的存在。
C 的做法:全局变量 + 约定#
// C 的典型做法:全局变量 + 头文件声明
// usart2.h
extern USART_HandleTypeDef husart2; // "全局唯一"的 USART2 句柄
// usart2.c
USART_HandleTypeDef husart2; // 定义
void usart2_init(void) {
husart2.Instance = USART2;
husart2.Init.BaudRate = 115200;
HAL_UART_Init(&husart2);
}
// 问题:
// 1. 任何文件都可以 #include "usart2.h" 然后操作 husart2
// 2. 没有任何机制阻止两个模块同时使用
// 3. 如果 usart2_init() 没被调用,husart2 处于未初始化状态
// 4. 如果 usart2_init() 被调用两次,行为未定义cRust 的做法:Peripherals::take() 单例#
use stm32f4xx_hal::pac;
#[cortex_m_rt::entry]
fn main() -> ! {
// 获取所有外设的所有权——只能调用一次!
let dp = pac::Peripherals::take().unwrap();
// dp.GPIOA 的所有权现在属于 dp
// dp.USART2 的所有权现在属于 dp
// dp.RCC 的所有权现在属于 dp
// 将 GPIOA 拆分为独立的引脚
let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
// 配置 USART2
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.freeze();
let mut usart = dp.USART2.serial(
(gpioa.pa2, gpioa.pa3), // TX, RX 引脚
115200.bps(),
&clocks,
);
// 此时:
// - led 拥有 PA5(类型:PA5>)
// - usart 拥有 USART2 + PA2 + PA3
// - 没有任何其他代码可以访问这些资源
loop {
led.set_high();
usart.write(b"Hello").unwrap();
led.set_low();
}
}rusttake() 的工作原理#
// 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关键保证:
- 只能调用一次:第二次调用
take()返回None,unwrap()会 panic - 所有权转移:外设的所有权从
dp转移到使用者,转移后dp不能再访问该外设 - 编译期保证:你不可能同时持有两个
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:52plaintext注意:在 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)
- 类型检查(仍然不能把
u32当f64用) - 模式匹配穷尽性(仍然必须处理所有
enum变体)
unsafe 只额外允许以下操作:
- 解引用裸指针(
*const T/*mut T) - 调用
unsafe fn - 访问
static mut变量 - 实现
unsafe trait - 访问 union 的字段
实际例子:中断中的共享数据#
// ❌ 错误做法:static mut 需要 unsafe
static mut COUNTER: u32 = 0;
#[interrupt]
fn TIM2() {
unsafe {
COUNTER += 1; // 需要 unsafe:中断和主循环可能同时访问
}
}
#[entry]
fn main() -> ! {
loop {
let count = unsafe { COUNTER }; // 需要 unsafe
// 使用 count...
}
}rust// ✅ 正确做法:使用安全的抽象
use core::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[interrupt]
fn TIM2() {
COUNTER.fetch_add(1, Ordering::Relaxed); // ✅ 安全,无需 unsafe
}
#[entry]
fn main() -> ! {
loop {
let count = COUNTER.load(Ordering::Relaxed); // ✅ 安全
// 使用 count...
}
}rust// ✅ 另一种正确做法:使用 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 嵌入式生态的核心设计原则:
// HAL 库内部(unsafe 被封装)
impl PA5> {
pub fn set_high(&mut self) {
// 这个 unsafe 块被封装在安全的公开 API 内部
// 调用者不需要写 unsafe
unsafe {
(*GPIOA::ptr()).bsrr.write(|w| w.bits(1 << 5));
}
}
}
// 用户代码(完全安全)
fn main() -> ! {
let mut led = gpioa.pa5.into_push_pull_output();
led.set_high(); // ✅ 安全调用,无需 unsafe
loop {}
}rust设计哲学:
┌─────────────────────────────────────────────────┐
│ 用户代码(100% safe) │
│ │
│ led.set_high(); │
│ usart.write(b"Hello")?; │
│ timer.start(1.kHz())?; │
│ │
├─────────────────────────────────────────────────┤
│ HAL 层(safe API) │
│ │
│ pub fn set_high(&mut self) { │
│ unsafe { /* 寄存器操作 */ } │
│ } │
│ │
├─────────────────────────────────────────────────┤
│ PAC 层(unsafe 底层) │
│ │
│ pub unsafe fn write_volatile(...) │
│ │
├─────────────────────────────────────────────────┤
│ 硬件寄存器 │
│ │
│ 0x40020018: BSRR │
│ │
└─────────────────────────────────────────────────┘plaintextunsafe 被”下沉”到最底层,上层代码全部是安全的。这意味着:
- 应用开发者永远不需要写
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 使用量) |
| 避免 | 如果有安全的替代方案(AtomicU32、Mutex、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
}rust2025 年的真实案例: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 本章小结#
核心要点#
-
零成本抽象是真实的:Rust 的类型状态、泛型、trait 在
--release模式下编译为与手写 C 完全相同的机器码。这不是营销口号,而是可以用反汇编验证的事实。 -
类型状态模式将硬件状态编码到类型中:
PA5<Output<PushPull>>和PA5<Input>是不同类型,编译器在编译期阻止非法操作。这比 C 的运行时检查更安全、更高效。 -
单例模式保证外设的唯一访问:
Peripherals::take()确保每个外设只能被获取一次,所有权系统确保不可能同时有两个模块”拥有”同一个 USART。 -
unsafe是”安全边界”的标记:它不是”关闭检查”,而是”我向编译器保证”。在嵌入式中,unsafe应该被封装在 HAL 内部,应用代码保持 100% safe。 -
抽象的层次决定了 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 的异步模型打下理论基础。