3. Rust 裸机编程基础:从零点亮一颗LED

好,咱们正式开始动手了。这一章的目标很纯粹——在没有任何操作系统的情况下,用 Rust 写一个能跑的程序,让开发板上的 LED 闪起来。听起来简单,但背后涉及的东西可不少:启动代码、中断向量表、内存映射 IO。这些都是 RTOS 内核的基石,绕不开的。

我个人习惯把裸机编程比作「从零搭积木」。没有操作系统帮你初始化硬件,没有标准库帮你管理内存,一切都要自己来。嗯,这恰恰是嵌入式开发的魅力所在。

3.1 为什么需要启动代码?

你想想看,CPU 上电后,它怎么知道从哪里开始执行?寄存器初始值是什么?栈指针指向哪里?这些都需要启动代码来告诉它。

在 ARM Cortex-M 系列中,复位后硬件会自动做两件事:

  • 从地址 0x0000_0000 加载栈指针(MSP)的初始值
  • 从地址 0x0000_0004 加载复位向量,跳转执行

这就是中断向量表的前两个条目。说白了,向量表就是一张「异常发生时该去哪」的跳转表。

核心概念: Cortex-M 的向量表必须放在起始地址,且第一个字是栈顶地址,第二个字是复位入口。这是硬件规定的,没得商量。

3.2 使用 cortex-m-rt 编写启动代码

手动写汇编启动代码当然可以,但我建议直接用 cortex-m-rt crate。它帮我们处理了向量表生成、内存初始化、异常处理框架等脏活累活。

我曾经在一个项目中手写过启动文件,结果因为对齐问题折腾了两天。后来改用 cortex-m-rt,十分钟搞定。所以,别重复造轮子了。

3.2.1 添加依赖

[dependencies]
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"  # 用于调试输出

[profile.release]
debug = true
lto = true
codegen-units = 1

3.2.2 编写入口函数

用 #[entry] 属性标记你的主函数,cortex-m-rt 会自动生成复位向量指向它。

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;  // 发生 panic 时 halt

#[entry]
fn main() -> ! {
    // 你的代码在这里
    loop {}
}

注意 #![no_std] 和 #![no_main]。裸机环境下没有标准库,也没有操作系统提供的 main 函数入口。所有东西都得自己来。

小技巧: 调试阶段可以用 cortex-m-semihosting 配合 hprintln! 宏打印信息,比点灯方便多了。但记得发布固件时去掉,否则会卡死。

3.3 配置中断向量表

cortex-m-rt 默认生成完整的向量表,包含所有 Cortex-M 异常和中断。但有时候我们需要自定义某些中断处理函数。

举个例子,系统滴答定时器(SysTick)的中断:

use cortex_m::peripheral::syst::SystClkSource;
use cortex_m_rt::exception;

#[exception]
fn SysTick() {
    // 每 1ms 触发一次
    // 这里可以放任务切换逻辑
}

用 #[exception] 属性,库会自动把函数地址填入向量表的对应位置。你不需要手动计算偏移量,省心不少。

向量表位置 异常/中断 优先级 说明
0x0000_0000 MSP 初始值 - 栈顶地址
0x0000_0004 Reset -3(最高) 复位入口
0x0000_0008 NMI -2 不可屏蔽中断
0x0000_000C HardFault -1 硬件错误
0x0000_003C SysTick 可配置 系统滴答定时器

避坑指南: 我曾经在 STM32F103 上遇到过 HardFault 不打印任何信息的情况。后来发现是向量表对齐出了问题——Cortex-M 要求向量表按 512 字节对齐。用 cortex-m-rt 默认帮你对齐了,但如果你手动修改链接脚本,千万注意。

3.4 理解 Memory-Mapped IO

在嵌入式世界里,控制外设就是读写内存地址。每个外设寄存器都映射到一段固定的地址空间。这就是 Memory-Mapped IO(MMIO)。

比如 STM32F103 的 GPIOA 端口输出数据寄存器(ODR)地址是 0x4001_080C。往这个地址写数据,就能控制 PA0-PA15 引脚的电平。

在 Rust 中,我们通常用 core::ptr::write_volatile 来操作这些地址:

const GPIOA_ODR: *mut u32 = 0x4001_080C as *mut u32;

unsafe {
    // 点亮 PA0(假设 LED 接在 PA0)
    core::ptr::write_volatile(GPIOA_ODR, 1 << 0);
}

为什么用 write_volatile?因为编译器可能会优化掉看似「无用」的写操作。比如你连续写同一个地址两次,编译器可能只保留最后一次。但对外设来说,每次写都有意义。volatile 告诉编译器:别动我的操作顺序,照实执行。

关键点: MMIO 操作必须用 volatile。这是 Rust 保证外设操作正确性的重要机制。忘了加 volatile,调试时可能遇到各种诡异问题。

3.5 实现 LED 闪烁程序

好了,理论讲完了,咱们来点实际的。以 STM32F103 蓝 pill 板为例,PC13 引脚上通常有一颗 LED。

完整代码:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;
use core::ptr;

// 外设基地址
const RCC_BASE: u32 = 0x4002_1000;
const GPIOC_BASE: u32 = 0x4001_1000;

// 寄存器偏移
const RCC_APB2ENR: *mut u32 = (RCC_BASE + 0x18) as *mut u32;
const GPIOC_CRH: *mut u32 = (GPIOC_BASE + 0x04) as *mut u32;
const GPIOC_ODR: *mut u32 = (GPIOC_BASE + 0x0C) as *mut u32;

fn delay(cycles: u32) {
    for _ in 0..cycles {
        unsafe { core::ptr::read_volatile(&0xE000_E010 as *const u32); }
    }
}

#[entry]
fn main() -> ! {
    // 1. 使能 GPIOC 时钟
    unsafe {
        ptr::write_volatile(RCC_APB2ENR, ptr::read_volatile(RCC_APB2ENR) | (1 << 4));
    }

    // 2. 配置 PC13 为推挽输出(CNF=00, MODE=10)
    unsafe {
        let crh = ptr::read_volatile(GPIOC_CRH);
        ptr::write_volatile(GPIOC_CRH, (crh & !(0xF << 20)) | (0x2 << 20));
    }

    // 3. 闪烁
    loop {
        unsafe {
            ptr::write_volatile(GPIOC_ODR, ptr::read_volatile(GPIOC_ODR) | (1 << 13));
        }
        delay(500_000);

        unsafe {
            ptr::write_volatile(GPIOC_ODR, ptr::read_volatile(GPIOC_ODR) & !(1 << 13));
        }
        delay(500_000);
    }
}

这段代码做了三件事:开时钟、配模式、翻转引脚。每一步都对应一个 MMIO 操作。你想想看,如果没有启动代码,连栈指针都没初始化,这段代码根本跑不起来。

3.6 本章知识结构图

下面这张图总结了裸机程序从启动到点灯的全流程:

裸机程序启动与LED控制流程 裸机程序启动与LED控制流程 上电复位 硬件加载向量表 cortex-m-rt 启动代码 进入 main() MMIO 操作外设 LED 闪烁 关键依赖: cortex-m-rt cortex-m panic-halt 核心概念: 向量表 MMIO volatile

3.7 常见问题与避坑

  1. 程序烧进去没反应? 先检查时钟是否使能。我见过太多人忘了开外设时钟,然后怀疑人生。
  2. LED 亮度异常? 可能是 GPIO 模式配置错了。推挽输出和开漏输出差别很大。
  3. 编译报错说找不到 main? 确认加了 #![no_main] 和 #[entry]。
  4. 调试时程序卡在 HardFault? 检查向量表对齐,或者栈空间是否溢出。

调试建议: 先用 cortex-m-semihosting 打印信息,确认程序跑到了哪一步。等逻辑调通了,再换成 GPIO 操作。这样能省下大量排查时间。

好了,这一章的内容就到这。你学会了如何从零搭建一个 Rust 裸机项目,理解了启动代码、向量表和 MMIO 的核心原理。这些知识在后续的 RTOS 移植中会反复用到——任务切换依赖 SysTick 中断,外设驱动依赖 MMIO,启动代码则是整个系统的地基。