摘要:在 C 语言统治嵌入式的 40 年里,我们习惯了用 GDB 追查段错误 (SegFault),用 Mutex 保护全局变量。但 Rust 的出现打破了“安全与性能不可兼得”的魔咒。本文将剖析 Rust 的 “零开销抽象”,展示编译器如何像一位严厉的老师,拒绝编译任何可能导致内存泄漏或并发冲突的代码,从而构建出从数学上证明是安全的嵌入式固件。


一、 C/C++ 的原罪:相信人类

让我们看一个经典的 C 语言并发 Bug:

// 全局变量
int *shared_ptr;

void TaskA() {
    shared_ptr = malloc(100); // 1. 分配
    // ... 做点事
    free(shared_ptr);         // 2. 释放
    shared_ptr = NULL;        // 3. 置空
}

void TaskB() {
    if (shared_ptr != NULL) { // 4. 检查
        // 5. 中断发生了!TaskA 此时执行了步骤 2 (Free)
        *shared_ptr = 10;     // 6. Use-After-Free (UAF) -> 崩溃!
    }
}

这就是 Use-After-Free (释放后使用)。 为了修这个 Bug,你需要引入互斥锁、检查时序、甚至重构架构。 而且,编译器不会给你任何警告。它默许你这么做。


二、 铁律:所有权 (Ownership) 与 借用 (Borrowing)

Rust 的核心哲学是:内存资源必须有,且只能有一个“主人”。

1. 唯一的拥有者

{
    let s1 = String::from("hello");
    let s2 = s1; // 发生了 "Move" (所有权转移)
    
    // println!("{}", s1); // 编译报错!!
    // 因为 s1 已经把所有权交给了 s2,s1 现在失效了。
} // 作用域结束,只有 s2 会释放内存。永远不会 Double Free。

2. 借用检查器 (Borrow Checker)

如果别人想用数据怎么办?可以 “借 (Borrow)”。 Rust 强制执行 读写锁逻辑,但不是在运行时,而是 在编译时

  • 规则 A:你可以有任意多个 不可变引用 (只读)。

  • 规则 B:你只能有一个 可变引用 (读写)。

  • 规则 C:规则 A 和 规则 B 互斥。

这意味着: 只要有一个指针在修改数据,编译器就绝对不允许其他指针读取数据。 数据竞争 (Data Race) 在 Rust 中被从语法层面根除了。


三、 硬件抽象:类型即状态 (Type State Programming)

在嵌入式中,配置 GPIO 是个高危操作。 你必须先设置模式为 Output,才能设置高低电平。 在 C 语言里,这全靠程序员自觉:

// C 语言:编译器管不了逻辑
GPIO_Init(PA5, INPUT); 
GPIO_SetBits(PA5); // 错误!输入模式不能设电平,但编译能过,运行时无效

在 Rust 中,利用 泛型和所有权,我们可以把 引脚状态编码进类型里

// Rust 嵌入式 HAL
let pin = gpioa.pa5.into_push_pull_output(); // 消耗掉 pa5,变成 OutputPin 类型
pin.set_high(); // 只有 OutputPin 类型有 set_high 方法

// let pin_in = gpioa.pa5.into_floating_input();
// pin_in.set_high(); // 编译报错!InputPin 没有 set_high 方法!

哲学含义非法的状态在代码里是不可表达的。 你不需要在运行时检查 if (mode == OUTPUT),因为如果不是 Output,代码连编译都编不过。


四、 并发的救赎:Send 与 Sync

在 RTOS 中,我们经常把一个结构体指针传给另一个线程。 Rust 有两个神奇的 Trait (特征)

  • Send:表示这个类型的所有权可以在线程间转移。

  • Sync:表示这个类型可以在多线程间共享引用(即它是线程安全的)。

绝大多数原始指针(*mut T)都不是 Send 也不是 Sync。 如果你试图把一个非线程安全的变量传给另一个线程:

thread::spawn(move || {
    unsafe_variable.modify(); // 编译器报错:`Rc<T>` cannot be sent between threads safely
});

震撼之处: 你不需要等到程序跑了 3 天崩溃了才发现竞争。 你在敲代码的时候,编译器就告诉你:这个变量不能跨线程用,除非你把它包在 Mutex 里。


五、 错误处理:告别 return -1

C 语言的错误处理是随意的。 int ret = Flash_Write(); 如果 ret 是 -1,你可能忘了检查,继续往下跑。

Rust 使用 Result<T, E> 枚举:

enum Result<T, E> {
    Ok(T),
    Err(E),
}
let f = File::open("hello.txt");
// f 不是文件句柄,它是 Result。
// 你必须“解包”才能拿到文件。
match f {
    Ok(file) => file.read(...),
    Err(error) => panic!("Problem opening the file: {:?}", error),
};

或者使用 ? 操作符 自动传播错误: let file = File::open("hello.txt")?;

强制性:你不能忽略错误。如果你不处理 Result,编译器会发出警告。这迫使你写出极其健壮的代码。


六、 裸机 Rust:#![no_std]

你可能会问:Rust 有垃圾回收(GC)吗?生成的固件大吗?

  1. 没有 GC:Rust 的内存管理是编译期确定的(RAII),运行时没有 GC 线程,没有暂停。

  2. no_std 模式:在嵌入式开发中,我们使用 #![no_std] 属性,不链接标准库(就像 C 的 nosys.specs)。

  3. 零成本抽象:Rust 的迭代器、闭包、泛型,最终都会被 LLVM 优化成和 C 一样的汇编指令。

一个简单的 Blink 程序,编译后的二进制大小和 C 语言几乎一样。


七、 结语:戴着镣铐跳舞,是为了跳得更远

学习 Rust 的过程是痛苦的。 你会觉得在这个语言里,你什么都做不了:不能随意改变量,不能有全局指针,不能在这个函数里用那个函数的引用。 你会哪怕为了写一个链表,都要和编译器搏斗三天。

但一旦你的代码 编译通过 了,奇迹就发生了:

  • 它通常能直接运行。

  • 它没有内存泄漏。

  • 它没有数据竞争。

  • 它在极端边界条件下依然稳定。

C 语言给了你一把锋利的手术刀,你可以切除肿瘤,也经常割破手指。 Rust 给了你一套全自动达芬奇手术机器人,它限制了你的每一个动作,但保证你每一刀都精准无误。

在安全性要求极高的汽车、医疗、航空领域,Rust 不是一种选择,它是必然的归宿。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐