内存、堆栈与 alloc
为什么裸机默认无堆、RAM 如何布局、heapless 如何在栈上提供 Vec/String/Queue、需要时如何接入全局分配器,以及如何用 flip-link 防止栈溢出静默损坏数据。
为什么裸机默认无堆、RAM 如何布局、heapless 如何在栈上提供 Vec/String/Queue、需要时如何接入全局分配器,以及如何用 flip-link 防止栈溢出静默损坏数据。
static 全局变量占用的固定 RAM。.data 存有初值的全局(初值从 Flash 拷来)、.bss 存零初值全局(启动清零)。它们在整个程序生命周期存在、地址固定,是共享数据、大缓冲区的常用落脚点(配合 Mutex 保证安全)。heapless::Vec<T, N>、heapless::String<N>、heapless::spsc::Queue<T, N> 等,容量 N 是编译期常量,数据存在结构体内部(栈或静态区)。API 与 std 版几乎一致,只是 push 满了返回 Err 而非自动扩容。这是嵌入式动态集合的首选。heapless::spsc::Queue 提供无锁环形队列,可 split() 成生产者 Producer 和消费者 Consumer 两半。经典用法:ISR 作为生产者往队列塞数据、main 作为消费者取出处理。因为单生产者单消费者且用原子索引,无需临界区即可安全跨 ISR/main 传递数据。alloc(Box/Vec/String),必须提供一个实现 GlobalAlloc trait 的分配器,用 #[global_allocator] 注册,并给它一块 RAM 作为堆区。常用 embedded-alloc(基于 linked_list_allocator 或 TLSF)。注册后 alloc 类型就能用了,但要接受不确定的分配时间与失败风险。-C linker=flip-link,是嵌入式项目强烈推荐的安全网。理解 RAM 里各区域如何排布,就理解了栈溢出的危害与 flip-link 的价值:
flip-link 没有运行时开销、不改代码,只在 .cargo/config.toml 里把 rustflags 加一行 "-C", "linker=flip-link"(先 cargo install flip-link)。它把"栈溢出静默毁数据"变成"栈溢出立即 HardFault",对调试期和生产期都是巨大的安全提升。任何认真的嵌入式 Rust 项目都应默认启用。
大多数"看起来需要 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();
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 用原子读写索引,单生产者单消费者天然无数据竞争
与 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、能不用堆就不用堆。