Chapter 09 · Rust 嵌入式开发

内存、堆栈与 alloc

为什么裸机默认无堆、RAM 如何布局、heapless 如何在栈上提供 Vec/String/Queue、需要时如何接入全局分配器,以及如何用 flip-link 防止栈溢出静默损坏数据。

课程进度75%

关键概念

栈 (Stack)
存放函数局部变量、调用返回地址的内存区,由 SP(栈指针)管理,随函数调用向下增长、返回时回收,速度快、无碎片。裸机所有局部变量、大多数数据都在栈上。栈大小有限(几 KB),过深递归或过大的局部数组会导致栈溢出。
堆 (Heap)
运行时动态分配、大小不定的内存区,由分配器管理(malloc/free 或 Rust 的 alloc)。堆灵活但有代价:分配耗时不确定、会产生碎片、可能分配失败。嵌入式默认不用堆,以获得确定的内存行为和实时性。
静态内存 (.bss / .data)
static 全局变量占用的固定 RAM。.data 存有初值的全局(初值从 Flash 拷来)、.bss 存零初值全局(启动清零)。它们在整个程序生命周期存在、地址固定,是共享数据、大缓冲区的常用落脚点(配合 Mutex 保证安全)。
heapless
提供固定容量、无需堆的集合类型的 crate。heapless::Vec<T, N>heapless::String<N>heapless::spsc::Queue<T, N> 等,容量 N 是编译期常量,数据存在结构体内部(栈或静态区)。API 与 std 版几乎一致,只是 push 满了返回 Err 而非自动扩容。这是嵌入式动态集合的首选。
SPSC 队列 (Single-Producer Single-Consumer)
heapless::spsc::Queue 提供无锁环形队列,可 split() 成生产者 Producer 和消费者 Consumer 两半。经典用法:ISR 作为生产者往队列塞数据、main 作为消费者取出处理。因为单生产者单消费者且用原子索引,无需临界区即可安全跨 ISR/main 传递数据。
全局分配器 (Global Allocator)
要在裸机用 alloc(Box/Vec/String),必须提供一个实现 GlobalAlloc trait 的分配器,用 #[global_allocator] 注册,并给它一块 RAM 作为堆区。常用 embedded-alloc(基于 linked_list_allocator 或 TLSF)。注册后 alloc 类型就能用了,但要接受不确定的分配时间与失败风险。
栈溢出 (Stack Overflow)
栈向下增长时越过边界、侵入 .bss/.data 区域,静默破坏全局变量——这是嵌入式最恐怖的 bug 之一,表现为莫名其妙的数据损坏,极难定位。默认链接布局下溢出不会立即报错。需要专门手段(flip-link)把它变成可检测的 HardFault。
flip-link
一个链接器包装器,它翻转 RAM 布局:把栈放在 RAM 低地址、静态变量放高地址。这样栈溢出时会撞到 RAM 边界触发 HardFault(立即暴露),而不是静默覆盖数据。零运行时开销,只需在链接参数里加 -C linker=flip-link,是嵌入式项目强烈推荐的安全网。

RAM 布局:栈、静态与(可选)堆

理解 RAM 里各区域如何排布,就理解了栈溢出的危害与 flip-link 的价值:

默认 RAM 布局 vs flip-link 布局 ═══════════════════════════════════════════════════ 默认布局(危险) flip-link 布局(安全) 高地址 ┌────────────┐ 高地址 ┌────────────┐ │ 栈 Stack │↓ 向下增长 │ .bss/.data │ │ │ │ 静态变量 │ │ (可用空隙) │ ├────────────┤ ├────────────┤ │ (可用空隙) │ │ .bss/.data │ │ │ │ 静态变量 │ │ 栈 Stack │↓ 低地址 └────────────┘ 低地址 └────────────┘ │ 栈溢出 → 静默覆盖静态变量 栈溢出 → 撞 RAM 底 → HardFault (数据损坏,难查) (立即报错,好定位)
几乎无脑加上 flip-link

flip-link 没有运行时开销、不改代码,只在 .cargo/config.toml 里把 rustflags 加一行 "-C", "linker=flip-link"(先 cargo install flip-link)。它把"栈溢出静默毁数据"变成"栈溢出立即 HardFault",对调试期和生产期都是巨大的安全提升。任何认真的嵌入式 Rust 项目都应默认启用。

heapless:不用堆的动态集合

大多数"看起来需要 Vec"的场景,其实容量有上界。heapless 让你用熟悉的 API、却把数据放在固定大小的内联存储里:

use heapless::{Vec, String};

// 容量 8 的 Vec,数据内联在结构体里(无堆分配)
let mut samples: Vec<u16, 8> = Vec::new();
samples.push(100).unwrap();   // 满了返回 Err(value),不会自动扩容
samples.push(200).unwrap();

// 计算平均值,和 std 的迭代器 API 完全一样
let avg: u32 = samples.iter().map(|&x| x as u32).sum::<u32>()
    / samples.len() as u32;

// 容量 32 字节的定长字符串,用 write! 格式化,无需堆
use core::fmt::Write;
let mut msg: String<32> = String::new();
write!(msg, "avg={}", avg).unwrap();

SPSC 队列:ISR 到 main 的无锁传输

use heapless::spsc::{Queue, Producer, Consumer};

// 静态队列:容量 16,跨 ISR/main 共享
static mut Q: Queue<u8, 16> = Queue::new();

// 在 init 中 split 成生产者/消费者两半(此处示意)
// let (mut producer, mut consumer) = unsafe { Q.split() };

// —— ISR 侧:生产者,串口收到字节就入队 ——
// producer.enqueue(byte).ok();   // 满则丢弃,无需临界区

// —— main 侧:消费者,取出处理 ——
// while let Some(b) = consumer.dequeue() { process(b); }

// SPSC 用原子读写索引,单生产者单消费者天然无数据竞争
heapless 满了不会扩容

与 std 的 Vec(自动扩容)不同,heapless 容量固定,push/enqueue 满了返回 Err。这是特性不是缺陷——它逼你在设计时就想清楚"最大能有多少个元素",从而保证内存用量在编译期完全确定、永不 OOM。忽略返回值(用 .unwrap().ok())前,请确认满了时的行为(panic 还是丢弃)是你想要的。

确实需要堆时:接入全局分配器

解析变长协议、用某个只支持 alloc 的库时,可以开一小块堆。代价是引入不确定性,应谨慎:

#![no_std]
#![no_main]
extern crate alloc;                         // 启用 alloc 库
use alloc::vec::Vec;                          // 这才是会分配堆的 Vec
use embedded_alloc::LlffHeap as Heap;

// 注册全局分配器
#[global_allocator]
static HEAP: Heap = Heap::empty();

#[cortex_m_rt::entry]
fn main() -> ! {
    // 给分配器一块 8KB 的静态 RAM 作为堆区
    {
        use core::mem::MaybeUninit;
        const HEAP_SIZE: usize = 8192;
        static mut MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
        unsafe { HEAP.init(&raw mut MEM as usize, HEAP_SIZE) }
    }

    // 现在可以用 Box / Vec / String(alloc 版)了
    let mut v: Vec<u32> = Vec::new();
    v.push(42);                                 // 动态扩容,从堆分配

    loop {}
}

选型决策

需求推荐理由
局部临时数据栈上普通变量/数组最快,自动回收
已知上限的集合heapless::Vec/String无堆、内存确定
ISR↔main 传数据heapless::spsc::Queue无锁、无竞争
大缓冲区/共享状态static + Mutex地址固定、生命周期全程
确实变长/第三方 alloc 库embedded-alloc 全局分配器不得已时才用,接受不确定性
堆分配失败在裸机是硬故障

桌面程序 malloc 失败可以优雅处理,但裸机堆很小、无虚拟内存,分配失败往往意味着设计缺陷。Rust 的 alloc 分配失败默认会调用 alloc_error_handler(通常 panic)。因此嵌入式的黄金法则是:能不用堆就不用堆。绝大多数嵌入式应用完全可以零堆运行——用 heapless + static 就够了。引入堆前,先问自己是否真的必要。

本章小结

嵌入式默认不用堆以换取确定的内存行为与实时性。RAM 分为栈(局部变量、SP 管理、有限几 KB)、静态区(.bss/.data,static 全局,地址固定)和可选的堆。heapless 提供固定容量、内联存储的 Vec<T, N>/String<N>/spsc::Queue<T, N>,API 类似 std 但满了返 Err 不扩容,是动态集合首选;其中 SPSC 队列可 split 成生产者/消费者,用原子索引实现 ISR↔main 的无锁传输。确实需要堆时用 embedded-alloc 实现 #[global_allocator] 并划一块静态 RAM 作堆,但要接受分配时间不定、可能失败(裸机分配失败通常等于设计缺陷)。栈溢出会静默侵蚀静态变量、极难排查,用 flip-link 翻转 RAM 布局(栈在低地址)让溢出撞边界触发 HardFault,零运行时开销、强烈推荐默认启用。选型口诀:局部用栈、有上限用 heapless、ISR 传数据用 spsc、大缓冲用 static+Mutex、能不用堆就不用堆。