3. Rust语言快速入门:变量、数据类型、函数、控制流、所有权、借用与生命周期等核心语法

Rust 语言以其内存安全、无垃圾回收和高性能的特性,成为嵌入式系统开发领域备受瞩目的系统级编程语言。对于从 C/C++ 背景转向 Rust 的开发者而言,理解其核心语法与独特的内存管理模型是迈入嵌入式 Rust 开发的第一步。本章将聚焦于 Rust 的基础语法骨架,并深入剖析所有权(Ownership)、借用(Borrowing)与生命周期(Lifetime)这三个 Rust 最核心的语言特性。这些概念不仅是编写安全、高效嵌入式固件的基石,更是理解 Rust 编译器如何在不牺牲性能的前提下杜绝悬垂指针、数据竞争和内存泄漏的关键。

Rust 核心语法与内存安全模型 基础语法 变量与绑定 (let/mut) 数据类型 (标量/复合) 函数与模式匹配 控制流 (if/loop/match) 类型推导与泛型 核心内存安全机制 所有权 (Ownership) 借用 (Borrowing) 生命周期 (Lifetime) 编译期检查 (零成本) 无GC、无悬垂指针 编译时 强约束 嵌入式应用:无OS裸机 / RTOS / 驱动开发

上图清晰地展示了 Rust 语言的两个核心层面:左侧是开发者日常书写的语法结构,右侧则是 Rust 在编译期强制执行的内存安全机制。在嵌入式开发中,我们不仅要关注功能逻辑,更要深刻理解数据在内存中的流动方式。接下来,我们将逐一拆解这些概念,并结合嵌入式场景进行实战演练。

3.1 变量与数据类型:从 C 到 Rust 的思维转变

核心原理:不可变性与类型安全

在 Rust 中,变量默认是不可变(Immutable)的。这与 C 语言中变量默认可变(除非使用 const)的语义截然相反。这一设计哲学旨在从源头减少因变量意外修改而引发的难以追踪的 Bug。当你声明 let x = 5; 时,x 的值在生命周期内无法被重新赋值。若需可变性,必须显式使用 let mut x = 5;。这种显式性让代码的意图更加清晰,也让编译器能够更激进地进行优化。

Rust 是静态类型语言,但具备强大的类型推导能力。编译器通常能根据上下文推断出变量类型,无需开发者显式标注。然而,在定义全局常量或对外接口时,显式标注类型是良好的工程实践。

核心数据类型与嵌入式映射

Rust 的数据类型分为标量(Scalar)和复合(Compound)类型。对于嵌入式开发者,理解这些类型在内存中的确切大小至关重要。

Rust 类型 大小 (字节) 嵌入式典型用途 C 语言对应
u8 / i8 1 寄存器操作、状态标志、传感器原始字节 uint8_t / int8_t
u16 / i16 2 ADC 采样值、PWM 占空比、定时器计数值 uint16_t / int16_t
u32 / i32 4 系统 Tick 计数、GPIO 端口映射、内存地址 uint32_t / int32_t
f32 4 浮点运算(若无 FPU 需谨慎使用软浮点) float
bool 1 中断标志、设备状态 bool (C99)
char 4 Unicode 字符(嵌入式中较少用) char (1字节)
tuple (T1, T2) 元素总和 函数返回多个值(如坐标、状态码+数据) struct
array [T; N] N * size_of(T) 固定大小的 DMA 缓冲区、查找表 数组
风险提示:在嵌入式裸机环境中,f32 运算在没有硬件 FPU(浮点运算单元)的 MCU(如 Cortex-M0)上会引入巨大的软件模拟开销。应优先使用定点数或整数运算替代浮点运算,或仅在支持 FPU 的 M4F/M7 内核上启用硬件浮点功能。

案例拆解:寄存器访问的类型安全封装

假设我们需要操作一个 32 位 SoC 的 GPIO 输出数据寄存器(ODR)。在 C 语言中,我们通常使用指针强制转换:

// C 语言风格
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
GPIOA_ODR |= (1 << 5); // 设置 PA5 为高电平

在 Rust 中,我们利用 volatile 和指针操作,但更推荐使用 core::ptr::write_volatile 和 read_volatile 来确保编译器不会优化掉对内存映射寄存器的访问:

// Rust 风格(使用 core 库)
use core::ptr;

const GPIOA_ODR_ADDR: usize = 0x4002_0014;

fn set_pin_high() {
    // 将地址转换为可变指针
    let odr_ptr = GPIOA_ODR_ADDR as *mut u32;
    unsafe {
        // 读取当前值,置位第5位,再写回
        let current = ptr::read_volatile(odr_ptr);
        ptr::write_volatile(odr_ptr, current | (1 << 5));
    }
}

这段代码展示了 Rust 如何通过 unsafe 块显式标记对裸指针的操作,提醒开发者此处绕过了编译器的安全检查,必须自行保证正确性。在更高级的嵌入式框架(如 embedded-hal)中,这些操作会被封装成类型安全的 GPIO 接口,避免直接接触裸指针。

常见误区与复盘

  • 误区:认为 Rust 的 let 等同于 C 的 const。实际上,let 绑定的是变量,const 是编译期常量。Rust 的 const 必须使用字面量或常量函数初始化。
  • 误区:在嵌入式开发中滥用 u32 表示所有整数。应根据实际数据范围选择最小且合适的类型,以节省宝贵的 RAM 和 Flash 空间。
  • 复盘:在编写底层驱动时,应优先使用 core::ptr::read_volatile 和 write_volatile,而非直接解引用裸指针,这能有效防止编译器将看似“无用”的寄存器读写优化掉。

3.2 函数与控制流:表达逻辑的利器

核心原理:表达式驱动的语言

Rust 是基于表达式(Expression-based)的语言。这意味着大多数代码都是表达式,会返回值。这与 C 语言中“语句(Statement)”和“表达式”的区分不同。在 Rust 中,if 可以作为表达式使用,函数体的最后一个表达式即为返回值,无需 return 关键字(除非提前返回)。

// 函数定义:计算两个数的最大公约数(欧几里得算法)
fn gcd(mut a: u32, mut b: u32) -> u32 {
    while b != 0 {
        let temp = b;
        b = a % b;
        a = temp;
    }
    a // 最后一个表达式作为返回值
}

// if 作为表达式
fn classify_temperature(temp: i32) -> &'static str {
    if temp < 0 {
        "Freezing"
    } else if temp < 25 {
        "Comfortable"
    } else {
        "Hot"
    }
}

控制流:match 与 loop

对于嵌入式开发,match 表达式是处理状态机和命令解析的绝佳工具。它比 C 语言的 switch-case 更强大,支持模式匹配和解构。

// 状态机示例:UART 接收状态
enum RxState {
    Idle,
    Receiving { count: u8, checksum: u8 },
}

fn handle_rx_byte(state: RxState, byte: u8) -> RxState {
    match state {
        RxState::Idle => {
            if byte == 0xAA { // 帧头
                RxState::Receiving { count: 0, checksum: 0 }
            } else {
                RxState::Idle
            }
        }
        RxState::Receiving { count, checksum } => {
            // 处理数据字节...
            if count == 7 { // 帧结束
                // 校验 checksum
                RxState::Idle
            } else {
                RxState::Receiving { count: count + 1, checksum: checksum ^ byte }
            }
        }
    }
}

Rust 的 loop 是无限循环,常用于嵌入式主循环(Super Loop)。for 循环则更安全地遍历迭代器,避免了 C 语言中常见的索引越界问题。

实战场景:无操作系统的任务调度器雏形

考虑一个简单的协作式调度器,它维护一个函数指针数组(任务列表),并在主循环中依次执行。Rust 的函数指针和闭包让这一实现更加安全。

type Task = fn();

const TASK_LIST: [Task; 2] = [task1, task2];

fn task1() {
    // 读取传感器
}

fn task2() {
    // 更新 LED 状态
}

fn main() {
    loop {
        for task in TASK_LIST.iter() {
            task(); // 执行任务
        }
        // 进入低功耗模式
    }
}

常见误区与总结

  • 误区:在 match 分支中忘记处理所有可能情况。Rust 要求 match 必须是穷尽的,否则编译失败。这虽然增加了代码量,但避免了遗漏。
  • 误区:在嵌入式主循环中使用 for i in 0..100 进行软件延时。这种阻塞式延时浪费 CPU 周期,应使用硬件定时器。
  • 总结:善用 match 和表达式特性,可以让状态机代码更清晰、更不易出错。函数作为“一等公民”可以存储在数组中,实现灵活的任务调度。

3.3 所有权、借用与生命周期:Rust 的灵魂

核心原理:所有权三大规则

所有权是 Rust 最独特的特性,它让 Rust 在没有垃圾回收(GC)的情况下保证内存安全。其核心规则如下:

  1. Rust 中的每一个值都有一个被称为其所有者(Owner)的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃(Drop)。

当所有权发生转移(例如将变量赋值给另一个变量,或将变量传入函数),原变量将无法再被使用。这被称为移动(Move)。对于栈上的简单类型(如 u32),Rust 实现了 Copy 语义,赋值时是复制而非移动。

借用与引用

如果函数需要访问数据但不获取所有权,可以使用引用(Reference)。创建引用称为借用(Borrowing)。Rust 的借用规则是:

  • 在任意给定时间,要么只能有一个可变引用(&mut T),要么只能有多个不可变引用(&T)。
  • 引用必须始终有效(即不能存在悬垂引用)。

这一规则在编译期杜绝了数据竞争(Data Race)。在嵌入式多中断或 RTOS 多线程环境中,这一保证至关重要。

生命周期:确保引用有效

生命周期(Lifetime)是编译器用来分析引用有效性的机制。它确保一个引用在其被使用的整个过程中,其指向的数据都是有效的。在大多数情况下,生命周期是隐式推断的。但在涉及多个引用时,需要显式标注。

// 显式生命周期标注:'a 表示返回的引用与参数 x 和 y 中较短的那个生命周期一致
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

在嵌入式开发中,生命周期常用于管理对外设寄存器的引用。例如,一个 SPI 总线的句柄可能包含对底层寄存器块的引用,其生命周期必须与总线本身的有效期绑定。

案例拆解:驱动一个外部 SPI Flash 芯片

假设我们有一个 SPI 外设驱动,它需要访问一个全局的寄存器映射结构体。我们使用生命周期来确保驱动实例不能比它借用的寄存器活得久。

// 模拟 SPI 寄存器块
struct SpiRegisters {
    cr1: u32, // 控制寄存器1
    dr: u32,  // 数据寄存器
}

// 驱动结构体,持有对寄存器的可变引用
struct SpiDriver<'a> {
    regs: &'a mut SpiRegisters,
}

impl<'a> SpiDriver<'a> {
    fn new(regs: &'a mut SpiRegisters) -> Self {
        SpiDriver { regs }
    }

    fn transfer(&mut self, byte: u8) -> u8 {
        // 写入数据寄存器
        self.regs.dr = byte as u32;
        // 等待传输完成(简化)
        // 读取数据
        self.regs.dr as u8
    }
}

fn main() {
    // 静态分配寄存器(在嵌入式中通常由 linker script 提供地址)
    static mut SPI1_REGS: SpiRegisters = SpiRegisters { cr1: 0, dr: 0 };
    
    // 注意:在 unsafe 块中获取可变引用
    let driver = unsafe { SpiDriver::new(&mut SPI1_REGS) };
    // 使用 driver...
}

在这个例子中,SpiDriver 的生命周期 'a 被限制为不能超过 SPI1_REGS 的生命周期。如果尝试在 SPI1_REGS 被销毁后使用 driver,编译器将报错。这有效防止了悬垂指针。

常见误区与排障指南

常见错误 编译器提示 解决方案
使用已移动的值 use of moved value 使用 .clone()(如果类型支持)或重新设计数据流,考虑使用引用。
同时存在可变和不可变借用 cannot borrow as mutable because it is also borrowed as immutable 缩小可变引用的作用域,或使用 Cell/RefCell 实现内部可变性(需谨慎)。
生命周期标注缺失 missing lifetime specifier 根据函数逻辑添加正确的生命周期参数 <'a>。
经验之谈:在嵌入式开发中,如果遇到所有权和借用冲突,首先考虑是否真的需要所有权。通常,使用 &mut 引用传递外设寄存器是更符合硬件访问模型的做法。对于全局共享的可变状态(如中断标志),可以使用 AtomicBool 或 Mutex(在 RTOS 中)来安全地共享访问。

总结复盘