2. 嵌入式Rust生态概览
好,咱们正式开始动手了。这一章要解决一个根本问题:没有操作系统,Rust 怎么跑起来?
你想想看,平时写 Rust 程序,println!、Vec、Box 随手就用。但在裸机上,这些全没了。标准库依赖操作系统提供底层服务——文件系统、网络、堆管理。嵌入式设备上这些都不存在。
所以第一步,就是告诉编译器:「别给我链接标准库,我自己来。」
#![no_std]:告别标准库
在 Rust 里,#![no_std] 是一个 crate 级别的属性。把它放在 main.rs 或 lib.rs 的顶部,编译器就不会自动引入 std。
// 放在文件最开头
#![no_std]
// 现在只能用 core 库里的东西
use core::panic::PanicInfo;
core 库是标准库的一个子集,它不依赖任何操作系统。提供了基本类型、迭代器、闭包、原子操作等。但像 Vec、String、Box 这些需要堆分配的类型,就不在 core 里了。
核心区别:std = core + 堆分配 + 操作系统接口。裸机只能用 core。
我个人习惯,新建嵌入式项目时第一件事就是加 #![no_std]。然后编译一次,看看哪些依赖偷偷用了 std,一个个清理掉。
全局分配器:让 Vec 和 Box 回来
没有堆分配器,你就没法用 Vec、String、Box。但很多时候我们确实需要动态内存。怎么办?自己写一个分配器。
Rust 提供了一个 trait:GlobalAlloc。你实现它,然后用 #[global_allocator] 注册。
use core::alloc::{GlobalAlloc, Layout};
// 一个最简单的 bump allocator
pub struct BumpAllocator {
heap_start: usize,
heap_end: usize,
next: core::sync::atomic::AtomicUsize,
}
unsafe impl GlobalAlloc for BumpAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let size = layout.size();
let align = layout.align();
let current = self.next.load(core::sync::atomic::Ordering::Relaxed);
let aligned = (current + align - 1) & !(align - 1);
if aligned + size > self.heap_end {
return core::ptr::null_mut(); // 内存不足
}
self.next.store(aligned + size, core::sync::atomic::Ordering::Relaxed);
aligned as *mut u8
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// bump allocator 不支持释放单个对象
// 实际项目中需要更复杂的分配器
}
}
// 声明全局分配器
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
heap_start: 0x2000_0000, // 假设堆起始地址
heap_end: 0x2001_0000,
next: core::sync::atomic::AtomicUsize::new(0x2000_0000),
};
注意:上面的 bump allocator 只是教学演示。实际项目中,我建议用 linked_list_allocator 或 embedded_alloc 这类成熟 crate。我曾经在项目里自己手写了一个伙伴分配器,调试了整整两天才发现一个边界对齐的 bug……嗯,从那以后我学乖了。
有了全局分配器,Vec 和 Box 就能用了。但要注意:分配器必须线程安全。如果你的设备有中断,分配器内部需要加锁或使用原子操作。
panic 处理:崩溃了怎么办?
没有标准库,panic 时的默认行为是调用 abort。但裸机上没有 abort 系统调用。所以你必须自己实现 panic 处理函数。
use core::panic::PanicInfo;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// 在裸机上,通常的做法是:
// 1. 打印错误信息到串口
// 2. 进入死循环或重启
// 假设我们有一个串口输出函数
// serial_println!("Panic: {}", info);
loop {} // 死循环,防止返回
}
#[panic_handler] 是一个特殊属性。整个程序只能有一个 panic handler。它的返回类型是 !(never type),表示这个函数永远不会返回。
调试技巧:开发阶段,我习惯在 panic handler 里闪烁 LED 或输出错误码。比如:快闪 3 次表示内存越界,慢闪 2 次表示除零错误。这样不用接调试器也能知道哪里出了问题。
启动流程与入口点
这是裸机编程最绕的地方。普通 Rust 程序从 main 开始,但那是操作系统帮你准备好的。裸机上,CPU 上电后直接跳转到复位向量指向的地址。你得自己处理一切。
典型的启动流程如下:
- 复位向量:CPU 从固定地址读取初始栈指针和入口地址。
- 汇编启动:初始化栈指针、清零 BSS 段、拷贝数据段。
- Rust 入口:调用 Rust 函数,初始化全局分配器、设置中断向量表。
- 进入 main:调用用户的
main函数。
在 Rust 里,我们通常用 cortex-m-rt(ARM Cortex-M)或 riscv-rt(RISC-V)这类 crate 来处理启动流程。它们帮你生成了汇编启动代码和链接脚本。
// 使用 cortex-m-rt 的例子
#![no_std]
#![no_main] // 不使用标准库的 main
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
// 你的代码从这里开始
loop {}
}
#[entry] 属性宏会生成正确的入口点。它确保在调用你的 main 之前,所有必要的初始化都已完成。
关键点:#![no_main] 告诉 Rust 不要生成标准库的 main 入口。我们自己用 #[entry] 定义入口。
知识结构总览
下面这张图把本章的核心概念串起来了。你可以看到从 CPU 上电到用户代码执行的全路径。
链接脚本:告诉编译器内存布局
启动流程离不开链接脚本。它告诉链接器:代码放哪里、数据放哪里、栈放哪里。
一个典型的 Cortex-M 链接脚本片段:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : {
KEEP(*(.vector_table))
*(.text*)
} > FLASH
.data : {
*(.data*)
} > RAM AT > FLASH
.bss : {
*(.bss*)
} > RAM
}
启动代码会负责:
- 把
.data段从 Flash 拷贝到 RAM - 把
.bss段清零 - 设置栈指针为 RAM 的末尾
常见坑:我曾经在项目里忘记在链接脚本中给栈留足够空间。结果函数调用一深,栈就溢出到全局变量区。现象非常诡异——变量莫名其妙被修改。调试了两天才发现是栈溢出。从那以后,我每次都会在链接脚本里显式定义栈大小,并在启动代码中检查栈指针范围。
本章核心概念速查表
| 概念 | 作用 | 关键属性/宏 |
|---|---|---|
#![no_std] |
禁用标准库,只使用 core 库 | crate 级别属性 |
| 全局分配器 | 提供堆内存分配能力 | #[global_allocator] |
| panic handler | 处理运行时错误 | #[panic_handler] |
| 入口点 | 定义程序起始地址 | #[entry] / #![no_main] |
| 链接脚本 | 描述内存布局 | .ld 文件 |
嗯,这一章的内容就这些。说白了,裸机编程就是自己接管操作系统干的那堆活。刚开始会觉得繁琐,但一旦你理解了启动流程和内存布局,后面写驱动、调性能就顺手多了。