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,分配释放非常规律;堆是随机分配,容易产生碎片。分开管理,栈不会把堆的空间吃掉,反之亦然。

我见过一个项目,把堆和栈放在同一个区域,结果栈溢出把堆管理结构给覆盖了——调试了整整两天才发现。从那以后,我坚持在链接脚本里明确分开。

嵌入式系统内存布局(Cortex-M 典型) Flash (512 KB) .text(程序指令) .rodata(常量) .data 初始值(只读拷贝) SRAM (128 KB) .data(运行时,从 Flash 拷贝) 全局已初始化变量 .bss(零初始化) 全局未初始化变量 堆(32 KB) 动态分配(Vec, Box, String) 由 GlobalAlloc 管理 栈(8 KB) 函数调用、局部变量 启动时拷贝 💡 关键点 堆和栈必须分开,避免栈溢出破坏堆管理结构。链接脚本中的 .heap 和 .stack 区域就是干这个的。

4.5 避坑指南:no_std 下的常见陷阱

最后,分享几个我踩过的坑:

  • 忘记提供 panic_handler:no_std 下没有默认的 panic 处理,你必须自己写一个。哪怕只是 loop {},也得有。
  • 分配器未初始化就使用:全局分配器在 main 之前就可能被调用。我建议在链接脚本里预留堆空间,并在分配器初始化时检查堆地址是否有效。
  • 栈溢出检测:嵌入式环境没有 MMU 保护,栈溢出会静默覆盖堆数据。我曾经在调试一个任务切换 bug 时,发现栈指针跑到了堆区域——从那以后,我每个任务都加了栈边界检查。
  • 中断中分配内存:中断处理函数里调用 alloc 可能导致死锁。如果分配器用到了锁,而中断优先级高于锁的持有者,就完了。我的建议是:中断里绝对不用堆分配。

总结一下:no_std 让你摆脱了操作系统依赖,但你必须自己管理内存。实现一个 GlobalAlloc,引入 alloc crate,理解链接脚本中的内存布局——这三步走完,你的 RTOS 内核就有了坚实的内存基础。