3. Rust语言快速入门:变量、数据类型、函数、控制流、所有权、借用与生命周期等核心语法
Rust 语言以其内存安全、无垃圾回收和高性能的特性,成为嵌入式系统开发领域备受瞩目的系统级编程语言。对于从 C/C++ 背景转向 Rust 的开发者而言,理解其核心语法与独特的内存管理模型是迈入嵌入式 Rust 开发的第一步。本章将聚焦于 Rust 的基础语法骨架,并深入剖析所有权(Ownership)、借用(Borrowing)与生命周期(Lifetime)这三个 Rust 最核心的语言特性。这些概念不仅是编写安全、高效嵌入式固件的基石,更是理解 Rust 编译器如何在不牺牲性能的前提下杜绝悬垂指针、数据竞争和内存泄漏的关键。
上图清晰地展示了 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)的情况下保证内存安全。其核心规则如下:
- Rust 中的每一个值都有一个被称为其所有者(Owner)的变量。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃(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 中)来安全地共享访问。