知识门户

返回

第 2 章:Rust 速成——C 工程师最需要的 20% 语法

Part1 了解嵌入式rust

第 2 章:Rust 速成——C 工程师最需要的 20% 语法#

本章要回答的问题:C 工程师需要掌握哪些 Rust 概念才能读懂后续章节?这些概念和 C 的什么机制对应?

本章定位:这不是完整的 Rust 教程,而是”C 工程师的最小必要知识集”。只讲后续章节会用到的概念,用 C 的类比帮助理解。


2.1 所有权与借用:为什么没有 malloc/free#

C 的痛点:谁负责 free#

在 C 中,每一块动态分配的内存都需要有人负责释放。当代码规模增大、模块增多时,“谁分配、谁释放”这个问题变得极其复杂:

这个问题的本质是:C 语言没有机制来表达”这块内存归谁管”。 所有的约定都靠注释、文档和程序员的记忆力。

Rust 的所有权规则:每个值有且仅有一个所有者#

Rust 用三条规则彻底解决了这个问题:

  1. 每个值有且仅有一个所有者(owner)
  2. 当所有者离开作用域时,值被自动释放(drop)
  3. 值可以被移动(move)或借用(borrow),但不能同时

注意最后一行被注释掉的代码——如果你取消注释,编译器会报错:

error[E0382]: borrow of moved value: `msg`
 --> src/main.rs:14:27
  |
13 |     process_message(msg);
  |                     --- value moved here
14 |     println!("{:?}", msg);
  |                     ^^^ value borrowed here after move
plaintext

这就是 Rust 的核心哲学:编译器不允许你犯 C 中”理论上可能”的错误。

借用(&&mut):类比 C 的指针,但有编译期约束#

在 C 中,指针是”自由的”——你可以创建任意多个指针指向同一块内存,可以通过任何指针修改数据,编译器不会阻止你。

Rust 的借用规则:

规则说明C 的对应
&T(不可变借用)可以同时存在多个const T *(但 C 不强制)
&mut T(可变借用)同一时刻只能存在一个T *(但 C 允许多个)
不能同时存在 &T&mut T读写互斥C 中无此限制

用 C 的思维来理解:

// C:编译器不管你
uint16_t sensor_data[4] = {0};
uint16_t *r1 = sensor_data;      // 读指针 1
uint16_t *r2 = sensor_data;      // 读指针 2
uint16_t *w  = sensor_data;      // 写指针
w[0] = 1024;                     // 通过写指针修改
printf("%d\n", r1[0]);           // 通过读指针读取——"安全"吗?
// 如果 w 在另一个线程/中断里呢?C 编译器不会警告你。
c

Rust 的借用规则在编译期就阻止了”一边读一边写”的可能性。这在嵌入式中尤其重要——中断和主循环之间的数据竞争,在 Rust 中根本写不出来。

生命周期:编译器如何保证引用有效#

“生命周期”(lifetime)是 Rust 中让 C 工程师最困惑的概念之一。其实它的核心思想很简单:

引用不能比它指向的数据活得更久。

fn get_reference() -> &u32 {
    let value = 42u32;
    &value  // ❌ 编译错误!value 在函数结束时被销毁
            // 返回的引用将指向已释放的内存(悬垂指针)
}
rust

编译器报错:

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:21
  |
1 | fn get_reference() -> &u32 {
  |                     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value,
          but there is no value for it to be borrowed from
plaintext

在 C 中,这段代码会编译通过,然后在运行时产生未定义行为:

uint32_t *get_reference(void) {
    uint32_t value = 42;
    return &value;  // ⚠️ 返回局部变量的地址——未定义行为!
}
c

Rust 的生命周期系统,本质上就是编译器自动帮你做了”这个指针还有效吗”的检查。 在 C 中,这个检查靠程序员的大脑;在 Rust 中,靠编译器。

在嵌入式中的典型场景#

实际代码对比:C 的链表 vs Rust 的 Vec#

C 工程师经常需要手动管理动态数组:

Rust 的等价代码:

// Rust:Vec 自动管理
fn example() {
    let mut arr: Vec = Vec::with_capacity(4);
    arr.push(100);
    arr.push(200);
    // 不需要 destroy!arr 离开作用域时自动释放
    // 不可能 use-after-free:编译器不允许
    // 不可能内存泄漏:除非你故意用 std::mem::forget()
}
rust

注意:在 no_std 嵌入式环境中,Vec 需要堆分配器。如果不想用堆,可以使用 heapless::Vec(固定容量,栈上分配),第 15 章会详细介绍。

所有权在嵌入式中的特殊考量#

在嵌入式中,很多资源是静态分配的(没有堆),所有权模型同样适用:

这个例子展示了 Rust 嵌入式的一个重要模式:用类型系统 + 临界区替代 volatile + 手动关中断。 后续章节会反复出现这个模式。


2.2 Trait:Rust 的”接口”与 C 的函数指针表#

C 的做法:struct + 函数指针(模拟面向对象)#

C 工程师对”接口”并不陌生——我们只是不叫它”接口”,我们叫它”函数指针表”:

这个模式在 C 嵌入式中非常常见:Linux 内核的 file_operations、STM32 HAL 的 UART_HandleTypeDef、Zephyr 的 device_api——本质上都是函数指针表。

Rust 的 Trait:编译期多态 vs 运行时多态#

Rust 的 trait 就是”接口”的正式语言级支持:

两种多态的对比#

特性C 的函数指针表Rust 的 Trait(泛型)Rust 的 Trait(动态)
语法ops->read(self, ...)sensor.read(...)sensor.read(...)
分派时机运行时(间接跳转)编译期(内联)运行时(间接跳转)
性能开销函数指针间接调用零开销虚函数表间接调用
类型安全无(void* 强转)完全类型安全完全类型安全
代码体积一份代码每个类型一份(单态化)一份代码
嵌入式适用性通用首选(性能关键路径)需要堆分配(Box

在嵌入式中,编译期多态(泛型)是首选——它生成的代码与手写 C 完全相同,没有函数指针间接调用的开销。

与 C 的回调函数表对比#

让我们看一个更贴近嵌入式的例子——中断回调:

关键区别:

  • C 的回调通过函数指针间接调用——运行时开销
  • Rust 的泛型回调在编译期单态化——直接内联,与手写代码无异

Trait 的默认实现#

Trait 可以有默认实现,类似 C++ 的虚函数默认行为:


2.3 枚举与模式匹配:替代 union + switch#

C 的 enum:只是整数常量#

Rust 的 enum:可以携带数据(代数数据类型)#

Rust 的 enum 是 C 的 enum + union + struct 的合体:

// Rust 的 enum:每个变体可以携带不同类型的数据
enum MachineState {
    Idle,                          // 无数据
    Running { speed: u16 },        // 携带 u16
    Error { code: u32, retry: bool }, // 携带多个字段
}
rust

这一个 enum 就替代了 C 中的 enum + union + struct 三件套,而且类型安全——你不可能在 Idle 状态下访问 speed 字段。

Option<T>:替代 NULL 指针#

C 中用 NULL 表示”没有值”,这是无数 bug 的根源:

// C:NULL 指针
uint8_t *find_in_buffer(uint8_t *buf, uint16_t len, uint8_t target) {
    for (uint16_t i = 0; i < len; i++) {
        if (buf[i] == target) return &buf[i];
    }
    return NULL;  // "没找到"
}

// 调用者必须记得检查 NULL
uint8_t *result = find_in_buffer(buf, len, 0xAA);
if (result != NULL) {
    *result = 0xBB;  // 如果忘了检查?💥 段错误
}
c

Rust 用 Option<T> 替代 NULL:

关键区别:在 C 中,忘记检查 NULL 是”运行时崩溃”;在 Rust 中,忘记处理 None 是”编译错误”。

match:比 switch 更强大的模式匹配#

C 的 switch 只能匹配整数常量,而且容易忘记 break

// C:switch 的陷阱
switch (state) {
    case STATE_IDLE:
        start_motor();
        // 忘了 break!fall-through 到下一个 case
    case STATE_RUNNING:
        set_speed(1000);
        break;
    case STATE_ERROR:
        stop_motor();
        break;
    // 如果 state 是一个未定义的值?没有 default 就是未定义行为
}
c

Rust 的 match 解决了所有这些问题:

编译器强制你处理所有可能的情况。如果你新增了一个状态变体,所有未更新的 match 都会编译失败——这比 C 的 switch 安全得多。

实际代码对比:C 的错误码 vs Rust 的 Result#

Result 的优势:

  • 错误是类型:编译器知道每个函数可能返回哪些错误
  • 必须处理:不处理 Result 会有编译警告(#[must_use]
  • 自文档化UartError::Timeout-4 可读性强一万倍

2.4 泛型与单态化:为什么”模板”不会膨胀#

C 的”泛型”:void* 或宏#

C 没有真正的泛型,只有两种 workaround:

// 方法 1:void*(丢失类型信息)
void queue_push(Queue *q, void *item) {
    memcpy(q->buf + q->tail * q->item_size, item, q->item_size);
    q->tail = (q->tail + 1) % q->capacity;
}

// 方法 2:宏(代码生成,但无类型检查)
#define QUEUE_PUSH(q, item) do { \
    memcpy((q)->buf + (q)->tail * sizeof(*(item)), \
           (item), sizeof(*(item))); \
    (q)->tail = ((q)->tail + 1) % (q)->capacity; \
} while(0)
c

两种方法都有问题:void* 丢失类型安全,宏没有类型检查且调试困难。

C++ 的模板:编译期代码生成#

// C++ 模板
template
class Queue {
    T buf[N];
    size_t head = 0, tail = 0;
public:
    void push(const T& item) {
        buf[tail] = item;
        tail = (tail + 1) % N;
    }
};

Queue adc_queue;
Queue uart_queue;
// 编译器为每个  组合生成一份代码
cpp

C++ 模板的问题:

  • 编译错误信息极其晦涩
  • 可能导致代码膨胀(每个实例化都是一份完整代码)
  • 在嵌入式中,std::vector 等 STL 容器隐式使用堆分配

Rust 的泛型:单态化(monomorphization)#

单态化意味着:编译器在编译期为每个具体类型生成一份专用代码。Queue<u16, 16>Queue<u8, 64> 会变成两个完全独立的函数,就像你手写了两份代码一样。

为什么嵌入式中泛型不会导致代码膨胀#

这是 C 工程师最常见的担忧:“每个类型都生成一份代码,Flash 不就爆了吗?”

答案是:LLVM 优化器会消除重复。

// 这两个函数,单态化后生成的机器码完全相同:
fn process_u8(val: u8) -> u8 { val.wrapping_add(1) }
fn process_u16(val: u16) -> u16 { val.wrapping_add(1) }

// 编译器会生成:
// process_u8:  add r0, r0, #1; bx lr
// process_u16: add r0, r0, #1; bx lr
// 完全相同的指令!
rust

对于更复杂的泛型函数,LLVM 的 mergefunc pass 会识别出功能相同的函数并合并。实际测量表明,在典型的嵌入式项目中,泛型带来的额外代码体积通常小于 5%

经验法则:如果你担心代码体积,用 cargo size 测量,而不是凭直觉猜测。第 14 章会详细讨论体积优化。


2.5 错误处理:Result 替代错误码#

C 的做法:返回 -1,检查 errno#

这种模式的问题:

  • 容易遗漏:忘记检查返回值,编译器不会警告(除非加了 __attribute__((warn_unused_result))
  • 错误信息丢失-1 可能是 I2C 超时,也可能是 NACK,调用者无法区分
  • 传播冗长:每一层都要写 if (ret < 0) return ret;

Rust 的 Result<T, E>:错误是值,不是异常#

// Rust 的等价代码
fn sensor_read(buf: &mut [u8]) -> Result<(), SensorError> {
    i2c.write(SENSOR_ADDR, &[CMD_READ])?;  // ? 自动传播错误
    i2c.read(SENSOR_ADDR, buf)?;           // ? 自动传播错误
    Ok(())
}
rust

? 操作符:优雅的错误传播#

? 是 Rust 中最实用的语法糖之一。它的语义是:

// 这一行:
i2c.write(SENSOR_ADDR, &[CMD_READ])?;

// 等价于:
match i2c.write(SENSOR_ADDR, &[CMD_READ]) {
    Ok(val) => val,
    Err(e) => return Err(e.into()),  // 提前返回错误
}
rust

对比 C 的 if (ret < 0) return ret;? 操作符:

  • 更简洁(一个字符 vs 一整行)
  • 不可能遗漏(不处理 Result 会有编译警告)
  • 支持错误类型转换(.into()

panic!:不可恢复错误(类比 C 的 assert + abort#

// panic! 用于"这不应该发生"的情况
fn divide(a: u32, b: u32) -> u32 {
    if b == 0 {
        panic!("division by zero!");  // 不可恢复,程序终止
    }
    a / b
}
rust

在嵌入式中,panic 通常意味着:

  • 进入 panic_handler(你在第 3 章会配置它)
  • 输出错误信息(通过 RTT 或串口)
  • 死循环等待看门狗复位
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    // 输出错误信息到 RTT
    defmt::error!("PANIC: {}", info);
    loop {
        // 等待看门狗复位
        cortex_m::asm::wfi();
    }
}
rust

最佳实践:在嵌入式中,panic 应该只用于”逻辑上不可能发生”的情况(如数组越界、不可达分支)。对于”可能发生但可恢复”的错误(如通信超时),使用 Result


2.6 闭包与迭代器:嵌入式中的零成本用法#

C 的函数指针 vs Rust 的闭包#

// C:函数指针(不能捕获上下文)
typedef int (*compare_fn)(const void *a, const void *b);

int compare_u16(const void *a, const void *b) {
    return *(uint16_t *)a - *(uint16_t *)b;
}

qsort(arr, len, sizeof(uint16_t), compare_u16);
c

C 的函数指针有一个根本限制:不能捕获局部变量。如果你需要”带上下文的回调”,就必须传 void *ctx

Rust 的闭包可以直接捕获局部变量,无需 void *ctx

// Rust:闭包自动捕获上下文
let mut sum = 0u32;
let mut count = 0u16;

buf.iter().for_each(|&value| {
    sum += value as u32;  // 直接捕获 sum
    count += 1;           // 直接捕获 count
});
rust

迭代器链:编译器会优化为与手写循环相同的代码#

// Rust 迭代器链
let result: u32 = buf.iter()
    .filter(|&&x| x > 100)       // 过滤大于 100 的值
    .map(|&x| x as u32 * 2)      // 每个值乘以 2
    .sum();                       // 求和
rust

等价的 C 代码:

// C:手写循环
uint32_t result = 0;
for (uint16_t i = 0; i < len; i++) {
    if (buf[i] > 100) {
        result += (uint32_t)buf[i] * 2;
    }
}
c

关键事实:在 --release 模式下,Rust 编译器会将迭代器链优化为与手写 C 循环完全相同的机器码。没有函数调用开销,没有中间数组分配,没有虚函数分派。

你可以用 Compiler Explorer 验证这一点——将 Rust 代码和 C 代码分别编译为 ARM 汇编,对比输出。

在嵌入式中的典型用法#

// 1. ADC 多通道采样:过滤 + 转换 + 求平均
let adc_values: [u16; 8] = read_all_channels();
let average: u16 = adc_values.iter()
    .filter(|&&v| v > 10)          // 过滤噪声(低于 10 的无效值)
    .map(|&v| v as u32)            // 转为 u32 防止溢出
    .sum::() as u16 / 8;

// 2. UART 协议解析:查找帧头
let frame_start = rx_buf.iter()
    .position(|&b| b == 0xAA);     // 查找 0xAA 的位置

// 3. GPIO 批量配置
pins.iter_mut().for_each(|pin| {
    pin.set_high();
});
rust

2.7 模块系统与 use:替代 #include#

C 的 #include:文本替换#

// C 的 #include 是纯粹的文本替换
#include "uart.h"      // 把 uart.h 的内容原封不动地粘贴到这里
#include "sensor.h"    // 再粘贴 sensor.h
#include     // 再粘贴标准库头文件

// 问题:
// 1. 头文件保护(#ifndef)是手动管理的
// 2. 包含顺序可能影响编译结果
// 3. 没有命名空间——所有全局符号都在同一个平面
// 4. 循环包含需要前向声明
c

Rust 的 mod + use:命名空间与可见性#

pub 与封装:比 static 更精细的访问控制#

C 的做法Rust 的做法说明
static 函数/变量不加 pub仅当前模块可见
头文件中声明pub对外可见
无对应机制pub(crate)仅当前 crate 内可见
无对应机制pub(super)仅父模块可见
无对应机制pub(in path)仅指定路径可见
// Rust 的精细访问控制
pub struct Peripheral {
    pub name: &'static str,     // 外部可读可写
    pub(crate) config: u32,     // 仅 crate 内可访问
    reg_addr: u32,              // 仅本模块可访问(默认私有)
}
rust

这比 C 的”要么全局可见,要么 static”精细得多。在嵌入式中,这意味着你可以:

  • 将寄存器地址封装在 HAL 内部,外部无法直接访问
  • 将配置字段限制在 crate 内部,防止外部误修改
  • 只暴露安全的 API,隐藏不安全的实现细节

嵌入式项目的典型模块结构#


2.8 本章小结#

速查表:C → Rust 概念对照#

C 概念Rust 对应关键区别
malloc / free所有权系统(自动 drop)编译期保证,无需手动管理
T *(指针)&T / &mut T(引用)有编译期约束(借用规则)
const T *&T(不可变引用)编译器强制不可变
struct + 函数指针trait + impl编译期多态,零开销
enum(整数常量)enum(代数数据类型)可携带数据,类型安全
unionenum 的变体无需手动管理活跃成员
switchmatch穷尽性检查,无 fall-through
NULLOption<T>编译器强制处理”无值”情况
返回 -1 + errnoResult<T, E>错误是类型,必须处理
void* + 回调闭包 / 泛型类型安全,可捕获上下文
#includemod + use命名空间,精细访问控制
#ifdefFeature flag(cfgCargo 管理,依赖树感知
static 函数不加 pub模块级私有
assert()panic!()输出信息后终止
for 循环迭代器 + 闭包零成本,编译器优化为相同代码

后续章节中会遇到的 Rust 特性预告#

特性出现章节用途
async / await第 7-10 章Embassy 异步任务
Future trait第 9 章理解 Embassy 运行时
Pin第 9 章固定 Future 的内存位置
const 泛型第 8 章固定大小的缓冲区
过程宏(#[...]第 8 章#[embassy_executor::task]
unsafe第 4 章直接寄存器操作
生命周期标注 'a第 9 章任务间共享数据
Send / Sync第 10 章任务间通信的安全性

推荐深入学习资源#

如果你想在后续章节之外深入学习 Rust,以下资源按优先级排序:

  1. 《The Rust Programming Language》(官方书籍,免费在线)

    • 第 4 章(所有权)、第 6 章(枚举)、第 10 章(泛型/Trait)是重点
    • 不需要全部读完,按需查阅
  2. Rust by Example(官方示例集,免费在线)

    • 适合”看代码学语法”的工程师
  3. 《Rust for Rustaceans》(Jon Gjengset)

    • 进阶读物,适合掌握基础后深入理解
  4. Rust 官方文档(rustup doc

    • 标准库 API 参考,随用随查

建议:不要试图”先学完 Rust 再学嵌入式”。从第 3 章开始,你会在实际项目中自然习得这些概念。遇到不懂的语法,回来查这一章就够了。


下一章:第 3 章——开始上路——第一个裸机工程。我们将创建第一个 Rust 嵌入式项目,从 cargo new 到 LED 闪烁,完整走一遍编译、烧录、调试的流程。