第2章:Rust嵌入式基础回顾:所有权与生命周期、裸机编程模型、Cortex-M架构基础、中断向量表

好,咱们正式开始之前,先聊点实在的。嵌入式 Rust 和桌面端 Rust 最大的区别在哪?说白了,就是「资源受限」四个字。你没法随便 new 一个堆上的对象,也没法依赖操作系统帮你管内存。这时候,Rust 的所有权和生命周期机制,就成了你的救命稻草——而不是绊脚石。

我个人习惯把这一章叫做「热身运动」。别急着上手 DMA 和中断,先把这几块地基夯实了,后面才能跑得快。

2.1 所有权与生命周期:嵌入式场景下的「内存警察」

很多从 C 转过来的朋友,第一次看到 Rust 的所有权规则,第一反应是:「这编译器管得也太宽了吧?」

嗯,我刚开始也这么想。直到有一次,我在一个 STM32F4 项目里,因为 C 语言的野指针问题,整整调了三天 bug。从那以后,我再也不敢说 Rust 的编译器「多管闲事」了。

核心规则(嵌入式视角):

  • 每个值在任一时刻只能有一个所有者。
  • 当所有者离开作用域,值被自动释放——对应嵌入式里的「释放外设资源」。
  • 引用(&T 和 &mut T)必须遵循借用规则:要么多个不可变引用,要么一个可变引用。

在裸机环境下,没有 RAII 的析构函数帮你释放 GPIO、定时器这些外设。但 Rust 的所有权机制天然做到了这一点:当你把某个外设的句柄传进函数,函数用完后,所有权回到调用者手里,或者直接丢弃——外设寄存器就被「归还」了。

我的小技巧: 在嵌入式项目中,我习惯用 take() 方法获取外设单例。这个方法会消耗掉外设的「所有权令牌」,确保同一时刻只有一个地方能操作这个外设。你想想看,这比 C 语言里到处传全局指针安全了多少?

生命周期标注在嵌入式里也很常见。比如你要写一个 SPI 驱动,驱动内部持有一个对 GPIO 引脚的引用。这时候,你必须告诉编译器:这个引用的有效期不能超过 GPIO 引脚本身的生命周期。

// 一个简单的 SPI 引脚封装
pub struct SpiPins<'a> {
    sck: &'a mut GpioPin,
    mosi: &'a mut GpioPin,
    miso: &'a mut GpioPin,
}

impl<'a> SpiPins<'a> {
    pub fn new(sck: &'a mut GpioPin, mosi: &'a mut GpioPin, miso: &'a mut GpioPin) -> Self {
        Self { sck, mosi, miso }
    }
}

这段代码里,'a 就是生命周期参数。它保证了 SpiPins 实例不会活得比它引用的 GPIO 引脚更久。我曾经见过一个同事,在 C 语言里因为 SPI 引脚被提前释放,导致总线锁死——这种问题在 Rust 里,编译阶段就被掐死了。

2.2 裸机编程模型:没有操作系统,怎么活?

裸机编程,说白了就是「你一个人扛下所有」。没有 Linux 帮你调度任务,没有 MMU 帮你隔离内存,甚至连个 printf 都没有。

Rust 的裸机编程模型,核心就三个东西:cortex-m-rt(启动代码)、embedded-hal(硬件抽象层)、以及 panic-halt(错误处理)。

组件 作用 我踩过的坑
cortex-m-rt 提供中断向量表、启动代码、内存初始化 忘记设置正确的链接脚本,导致程序跑飞
embedded-hal 定义 GPIO、SPI、I2C 等外设的 trait 接口 不同芯片的 HAL 实现细节差异很大,别想当然
panic-halt 发生 panic 时直接死循环,适合调试阶段 生产环境建议换成 panic-abort,减少代码体积

裸机程序的入口点,不是 main,而是 #[entry] 宏标记的函数。这个宏会帮你处理好栈指针初始化、全局变量清零等底层工作。

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;

#[entry]
fn main() -> ! {
    // 你的裸机代码从这里开始
    loop {}
}

注意那个 -> !,它表示这个函数永远不会返回。裸机程序没有操作系统来「退出」,所以主循环必须是死循环。

警告: 千万不要在裸机程序里用 println! 或者 std::vec。这些依赖标准库的东西,在 #![no_std] 环境下根本不存在。如果你需要调试输出,用 cortex_m_semihosting::hprintln! 或者串口打印。

2.3 Cortex-M 架构基础:你每天都在用的 CPU

Cortex-M 系列是嵌入式领域最常见的 ARM 内核。从 M0 到 M7,虽然性能差异很大,但核心架构是相通的。

我个人觉得,理解 Cortex-M 的关键就三点:寄存器模型、异常处理、内存映射。

寄存器模型

Cortex-M 有 16 个通用寄存器(R0-R15),其中 R13 是栈指针(SP),R14 是链接寄存器(LR),R15 是程序计数器(PC)。还有一个特殊寄存器——程序状态寄存器(xPSR),它保存了条件标志位和中断状态。

在 Rust 中,你很少直接操作这些寄存器。但当你写中断服务函数或者上下文切换代码时,理解它们就很重要了。

异常处理

Cortex-M 的异常系统分为两类:系统异常(如 HardFault、SVCall)和 外部中断(由外设触发)。所有异常都有一个固定的优先级,数字越小优先级越高。

我记得有一次,我在一个项目中同时使能了定时器中断和 UART 中断。结果 UART 中断一直抢占定时器中断,导致定时器计数不准。后来查手册才发现,默认情况下所有中断的优先级是一样的——谁先来谁先服务。解决办法是给定时器中断设置一个更高的优先级。

内存映射

Cortex-M 的内存空间是统一编址的。外设寄存器、RAM、Flash 都在同一个 4GB 地址空间里。Rust 的 cortex_m::Peripherals 模块,就是通过内存映射来访问外设的。

// 获取外设实例
let peripherals = cortex_m::Peripherals::take().unwrap();
let gpioa = &peripherals.GPIOA;
// 现在你可以直接操作 GPIOA 的寄存器了
gpioa.odr.modify(|_, w| w.odr0().set_bit());

这段代码背后,其实就是往 0x40020000 这个地址写数据。Rust 的类型系统帮你封装好了,你不需要关心具体地址——但心里要有数。

SVG:Cortex-M 内存映射概览

Cortex-M 内存映射结构 Flash (0x0800_0000 - 0x0801_FFFF) SRAM (0x2000_0000 - 0x2000_FFFF) 外设寄存器 (0x4000_0000 - 0x400F_FFFF) 系统控制 (0xE000_E000 - 0xE00F_FFFF) 地址从低到高:Flash → SRAM → 外设 → 系统控制

2.4 中断向量表:CPU 的「紧急联络簿」

中断向量表,说白了就是一张表格。表格里每一行对应一个中断号,存的是该中断服务函数(ISR)的入口地址。当 CPU 收到中断信号时,它会暂停当前任务,查这张表,跳转到对应的 ISR 去执行。

Cortex-M 的中断向量表固定在地址 0x0000_0000(或者通过 VTOR 寄存器重映射)。前 16 个是系统异常,后面的是外部中断。

向量索引 异常类型 优先级 典型用途
0 栈顶地址 - 初始 SP 值
1 Reset -3(最高) 系统启动入口
2 NMI -2 不可屏蔽中断
3 HardFault -1 硬件错误处理
11 SVCall 可编程 系统调用
14 PendSV 可编程 任务切换
15 SysTick 可编程 系统定时器
16+ 外部中断 可编程 GPIO、定时器、UART 等

在 Rust 中,你不需要手动填写这张表。cortex-m-rt 会在编译时自动生成。你只需要用 #[interrupt] 宏标记你的 ISR 函数即可。

use cortex_m::peripheral::NVIC;
use stm32f4xx_hal::pac::interrupt;

// 声明中断服务函数
#[interrupt]
fn TIM2() {
    // 定时器 2 中断处理
    // 注意:这里不能有参数,不能有返回值
}

fn main() {
    // 使能 TIM2 中断
    unsafe {
        NVIC::unmask(interrupt::TIM2);
    }
    loop {}
}

重要提醒: ISR 函数内部要尽量短小精悍。我曾经见过有人在中断里做浮点运算、打印日志——结果中断延迟飙升,系统直接崩了。记住:中断里只做最必要的事,比如设置一个标志位,或者把数据塞进队列。重活交给主循环去干。

另外,中断向量表的位置可以通过 VTOR 寄存器修改。这在做 Bootloader 或者双区 OTA 时特别有用——你可以把中断向量表重映射到应用程序的起始地址。

// 重映射中断向量表到 0x0801_0000(假设这是应用程序的起始地址)
const APP_BASE: u32 = 0x0801_0000;
unsafe {
    // SCB 是系统控制块
    let scb = &*cortex_m::peripheral::SCB::ptr();
    scb.vtor.write(APP_BASE);
}

嗯,这里要注意:修改 VTOR 必须在关闭全局中断的情况下进行,否则可能引发不可预知的行为。我当初第一次写 Bootloader 时,就忘了关中断,结果跳转后直接 HardFault——教训深刻。

避坑指南: 如果你在调试阶段发现中断不触发,先检查三件事:

  1. 中断是否在 NVIC 中使能了?
  2. 外设本身的中断使能位是否置位?
  3. 中断优先级是否被意外设置为屏蔽状态?

我曾经花了一整个下午,最后发现是忘记在定时器配置里使能更新中断——低级错误,但很容易犯。

好了,这一章的内容就到这里。所有权和生命周期让你写出安全的内存代码,裸机编程模型让你在无 OS 环境下站稳脚跟,Cortex-M 架构和中断向量表则是你与硬件对话的基础。把这些吃透了,后面讲 DMA 和中断协同优化时,你才能游刃有余。