抛弃 C 语言:在 STM32 上跑 Rust 是种什么体验?

前言:苦 C 久矣

作为一个嵌入式开发的“小白”,宏定义地狱、悬垂指针、复杂的构建系统 (Makefile/CMake)……每一次写代码都像是在走钢丝。稍微不注意一个 memcpy 溢出,整个系统就崩了,还找不到堆栈信息,真的让我头痛。

去年开始,Rust 官方宣布了对嵌入式设备的正式支持(Embedded Rust)。我怀着“为了更好的开发体验”的单纯目的,买了一块 STM32F4 Discovery 开发板,开始尝试用 Rust 点灯。

结论在最前面:真香。但是门槛真高。

1. 为什么要在单片机上用 Rust?

你可能会问,C 语言不是嵌入式的通过语言吗?为什么要换?

  1. 内存安全:没有 Buffer Overflow,没有 Null Pointer。对于需要长期稳定运行的工控设备来说,这简直是救命稻草。
  2. 现代构建系统:Cargo 完爆 Makefile/CMake。添加一个驱动库只需要在 Cargo.toml 里加一行,而不是去 GitHub clone 下来手动配 include path。
  3. 零成本抽象:Rust 的高级特性(Trait、Enum、Iterator)在编译后会优化成和 C 一样高效的汇编。

2. 核心概念:PAC, HAL, BSP

Embedded Rust 的生态被分为三层,这比 C 语言的生态要清晰得多:

Layer 1: Peripheral Access Crate (PAC)

这是最底层。通过 svd2rust 工具,自动从芯片厂商提供的 SVD (System View Description) XML 文件生成 Rust 代码。
它提供了对寄存器的原始访问权限。

1
2
3
// 这里的操作是 unsafe 的,因为直接读写地址
let rcc = p.RCC;
rcc.ahb1enr.modify(|_, w| w.gpioaen().set_bit());

Layer 2: Hardware Abstraction Layer (HAL)

这是中间层。它封装了寄存器操作,提供了类型安全的接口。
在 C 语言里,你可能会误把 GPIOA 的配置写到 GPIOB 上。但在 Rust 里,这根本编译不过! 因为 PA0PB0 是不同的类型。

Layer 3: Board Support Package (BSP)

针对具体开发板的封装。比如 stm32f4-discovery crate,直接把板子上的 LED 定义好了。

3. 实战:Blinky (点灯)

我们跳过环境配置(其实就是装 rustup target add thumbv7em-none-eabihf),直接看代码。

这也是 Embedded Rust 最令人惊艳的地方:主板上的资源(Peripherals)也是受 Ownership 保护的。你拿走了 GPIOA,别人就不能再动它了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#![no_std]
#![no_main]

use panic_halt as _; // 就像 C 的 while(1) 处理 HardFault
use stm32f4xx_hal as hal; // 引入 HAL 库

use crate::hal::{pac, prelude::*};
use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
// 1. 获取外设的所有权 (类似单例模式,系统启动时只能拿一次)
let dp = pac::Peripherals::take().unwrap();

// 2. 配置时钟树 (Clocks)
// 这一步在 CubeMX 里要点半天,在 Rust 里就是链式调用
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(168.MHz()).freeze();

// 3. 获取 GPIOD 的控制权 (Discovery 板的 LED 在 PD12-PD15)
let gpiod = dp.GPIOD.split();

// 4. 将 PD12 设置为推挽输出模式
let mut led = gpiod.pd12.into_push_pull_output();

// 5. 一个简单的延时函数 (利用 SysTick)
// 注意:真实项目请使用 timer
let mut i = 0;
loop {
// 点亮 LED
led.set_high();

// 忙等待
for _ in 0..100_000 {
cortex_m::asm::nop();
}

// 熄灭 LED
led.set_low();

for _ in 0..100_000 {
cortex_m::asm::nop();
}
}
}

注意看 gpiod.pd12.into_push_pull_output()。这个函数不仅改变了引脚模式,还改变了类型

  • 之前的类型:PD12<Input<Floating>>
  • 之后的类型:PD12<Output<PushPull>>

这意味着,如果你试图在 Input 类型的引脚上调用 .set_high(),编译器会直接报错:method not found这种编译期的状态机检查,能帮你规避掉 90% 的低级配置错误。

4. 并发陷阱:Interrupts 与 Mutex

在嵌入式里,我们经常要在中断服务函数 (ISR) 和主循环 (main) 之间共享数据。
在 C 语言里,我们通常用 volatile 并且手动开关中断。
在 Rust 里,所有权的优势再次体现。

如果你想共享数据,必须使用 cortex_m::interrupt::Mutex 配合 RefCell。这看起来很繁琐,但它强迫你思考:你在中断里访问这个数据时,确定主循环不会正在改写它吗?

graph TD
    Main([Main Loop])
    ISR([Interrupt Service Routine])
    Resource[(Shared Global Data)]
    
    Main -- Needs Critical Section --> Resource
    ISR -- Needs Critical Section --> Resource
    
    style Resource fill:#f9f

Rust 强制要求你在访问共享全局变量时,显式进入 Critical Section(关中断):

1
2
3
4
5
6
7
8
9
10
11
static G_DATA: Mutex<RefCell<Option<u32>>> = Mutex::new(RefCell::new(None));

#[interrupt]
fn EXTI0() {
cortex_m::interrupt::free(|cs| {
// 进入临界区,cs 是令牌
let mut data = G_DATA.borrow(cs).borrow_mut();
// 现在可以安全修改了
*data = Some(100);
});
}

5. 总结:痛并快乐着

优势

  • Safe: 只要不用 unsafe块,基本不会跑飞。
  • Tooling: Cargo build, Cargo flash, Cargo doc,体验极佳。
  • Abstraction: 可以在单片机上用 async/await (Embassy 框架),这是 C 语言无法想象的。

劣势

  • 学习曲线陡峭:你需要懂 Rust,还要懂芯片。
  • 编译体积:如果不开启 LTO (Link Time Optimization),生成的 bin 文件可能比 C 大。
  • 生态不全:常用芯片 (STM32/ESP32) 支持很好,但冷门芯片可能只有 PAC,没有 HAL。

总体来说,如果你是一个追求极致代码质量的嵌入式工程师,Rust 绝对值得你投入时间去学习。它也许不能马上替换掉你公司祖传的 C 代码基建,但在新项目里,它是目前最好的选择。


抛弃 C 语言:在 STM32 上跑 Rust 是种什么体验?
https://www.qixyuan.top/2025/04/10/5-stm32-rust-experience/
作者
QixYuan
发布于
2025年4月10日
许可协议