4. Rust 的 no_std 与 alloc:理解 #![no_std] 的含义、实现全局分配器、使用 alloc crate 管理堆内存、在嵌入式环境中的内存布局
好,我们进入第四章。这一章要啃的,是 Rust 嵌入式开发的基石——no_std 环境。很多从桌面开发转过来的朋友,第一次看到 #![no_std] 都会懵:没有标准库,我还能干啥?
别急。我当年第一次在 STM32 上跑 Rust,也是对着编译错误发愁。后来才明白,no_std 不是限制,而是解放——它让你真正掌控每一字节的内存。
4.1 #![no_std] 到底在说什么?
简单说,#![no_std] 就是告诉编译器:我不依赖 Rust 标准库。标准库依赖操作系统,比如文件系统、网络、线程。嵌入式环境里,这些统统没有。
但 no_std 不等于 no_core。Rust 的核心库 core 是永远可用的,它提供了基本类型、迭代器、模式匹配这些语言层面的东西。你想想看,core 不需要系统调用,纯语言特性,所以裸机也能用。
| 特性 | std | core | alloc |
|---|---|---|---|
| Vec / String | ✅ | ❌ | ✅ |
| Box / Rc | ✅ | ❌ | ✅ |
| 文件 I/O | ✅ | ❌ | ❌ |
| 线程 | ✅ | ❌ | ❌ |
| panic 处理 | 自动 | 需手动 | 需手动 |
核心结论:no_std 环境下,你失去了 std::fs、std::net、std::thread,但保留了 core 的全部能力。如果你需要动态内存分配,就引入 alloc crate。
4.2 全局分配器:让堆内存活起来
好,现在问题来了。你想用 Vec 或 Box,但 no_std 下没有默认的堆分配器。怎么办?自己写一个。
Rust 提供了一个 trait:GlobalAlloc。你只要实现它,然后用 #[global_allocator] 标注,整个程序就能用堆了。
我个人习惯用 linked_list_allocator 这个 crate,它实现了一个简单的空闲链表分配器。但如果你想深入理解原理,我建议自己手写一个——哪怕只是玩具级的。
use core::alloc::{GlobalAlloc, Layout};
use core::ptr::NonNull;
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);
let new_next = aligned + size;
if new_next > self.heap_end {
return core::ptr::null_mut();
}
self.next.store(new_next, core::sync::atomic::Ordering::Relaxed);
aligned as *mut u8
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// bump 分配器不支持释放,适合临时任务
}
}
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
heap_start: 0x2000_0000, // 假设的堆起始地址
heap_end: 0x2001_0000,
next: core::sync::atomic::AtomicUsize::new(0x2000_0000),
};
注意:上面的 bump 分配器只是教学示例。它不支持释放内存,实际 RTOS 中千万别这么用。我曾经在一个原型项目里偷懒用了 bump,结果任务一多,内存直接爆了。
4.3 使用 alloc crate 管理堆内存
有了全局分配器,你就可以引入 alloc crate 了。在 lib.rs 或 main.rs 里加上:
extern crate alloc;
然后就能用 Vec、Box、String 了。但要注意,这些操作都可能失败——内存不足时,alloc 会返回错误。在嵌入式环境里,我建议你总是用 try_reserve 或 try_alloc 系列方法,而不是直接 push。
举个例子,RTOS 的任务控制块(TCB)列表,我习惯用 Vec 管理:
use alloc::vec::Vec;
struct TaskControlBlock {
id: u32,
stack_ptr: usize,
state: TaskState,
}
enum TaskState {
Ready,
Running,
Blocked,
}
fn add_task(tasks: &mut Vec<TaskControlBlock>, id: u32) -> Result<(), &'static str> {
if tasks.try_reserve(1).is_err() {
return Err("Out of memory for task list");
}
tasks.push(TaskControlBlock {
id,
stack_ptr: 0,
state: TaskState::Ready,
});
Ok(())
}
我的经验:在 RTOS 初始化阶段,我会预先分配好所有静态任务的内存。动态分配只在运行时创建的任务中使用,而且数量严格控制。这样既能灵活,又能避免运行时内存碎片。
4.4 嵌入式环境中的内存布局
搞清楚了分配器,我们来看看整个内存长什么样。嵌入式系统的内存布局,说白了就是三块:
- 代码区(Flash):只读,存放程序指令和常量。
- 静态区(SRAM):存放全局变量、静态变量。
- 堆和栈:运行时动态分配和函数调用用的。
在 no_std 环境下,你需要自己定义链接脚本(linker script),告诉编译器这些区域在哪里。以 Cortex-M3 为例,典型的链接脚本会这样划分:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : { *(.text*) } > FLASH
.rodata : { *(.rodata*) } > FLASH
.data : { *(.data*) } > SRAM AT> FLASH
.bss : { *(.bss*) } > SRAM
.heap : { . = ALIGN(8); _heap_start = .; . += 32K; _heap_end = .; } > SRAM
.stack : { . = ALIGN(8); _stack_start = .; . += 8K; _stack_end = .; } > SRAM
}
嗯,这里要注意:.data 段在 Flash 里存储初始值,但运行时必须拷贝到 SRAM。这个拷贝工作,通常由启动代码(startup.s)完成。我们的 RTOS 内核启动时,也会做同样的事。
为什么堆和栈要分开?因为栈是 LIFO,分配释放非常规律;堆是随机分配,容易产生碎片。分开管理,栈不会把堆的空间吃掉,反之亦然。
我见过一个项目,把堆和栈放在同一个区域,结果栈溢出把堆管理结构给覆盖了——调试了整整两天才发现。从那以后,我坚持在链接脚本里明确分开。
4.5 避坑指南:no_std 下的常见陷阱
最后,分享几个我踩过的坑:
- 忘记提供 panic_handler:
no_std下没有默认的 panic 处理,你必须自己写一个。哪怕只是loop {},也得有。 - 分配器未初始化就使用:全局分配器在
main之前就可能被调用。我建议在链接脚本里预留堆空间,并在分配器初始化时检查堆地址是否有效。 - 栈溢出检测:嵌入式环境没有 MMU 保护,栈溢出会静默覆盖堆数据。我曾经在调试一个任务切换 bug 时,发现栈指针跑到了堆区域——从那以后,我每个任务都加了栈边界检查。
- 中断中分配内存:中断处理函数里调用
alloc可能导致死锁。如果分配器用到了锁,而中断优先级高于锁的持有者,就完了。我的建议是:中断里绝对不用堆分配。
总结一下:no_std 让你摆脱了操作系统依赖,但你必须自己管理内存。实现一个 GlobalAlloc,引入 alloc crate,理解链接脚本中的内存布局——这三步走完,你的 RTOS 内核就有了坚实的内存基础。