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 本章知识结构图
下面这张图总结了裸机程序从启动到点灯的全流程:
3.7 常见问题与避坑
- 程序烧进去没反应? 先检查时钟是否使能。我见过太多人忘了开外设时钟,然后怀疑人生。
- LED 亮度异常? 可能是 GPIO 模式配置错了。推挽输出和开漏输出差别很大。
- 编译报错说找不到 main? 确认加了
#![no_main]和#[entry]。 - 调试时程序卡在 HardFault? 检查向量表对齐,或者栈空间是否溢出。
调试建议: 先用 cortex-m-semihosting 打印信息,确认程序跑到了哪一步。等逻辑调通了,再换成 GPIO 操作。这样能省下大量排查时间。
好了,这一章的内容就到这。你学会了如何从零搭建一个 Rust 裸机项目,理解了启动代码、向量表和 MMIO 的核心原理。这些知识在后续的 RTOS 移植中会反复用到——任务切换依赖 SysTick 中断,外设驱动依赖 MMIO,启动代码则是整个系统的地基。