裸机编程基础:无标准库环境与全局分配器
好,咱们进入第三章。说实话,这一章是很多 Rust 嵌入式新手的第一道坎。为什么?因为你要跟 std 说再见了。没有 Vec,没有 Box,没有 println!,甚至连 panic 都没人帮你处理。听起来有点慌?别急,我带你一步步搭起来。
为什么需要 #![no_std]?
你想想看,在 Cortex-M 或者 RISC-V 的 MCU 上,哪来的操作系统?没有 OS,就没有文件系统、没有网络栈、没有线程调度。std 库底层依赖这些东西,所以在裸机上根本跑不起来。
我个人习惯,新建一个嵌入式项目时,第一件事就是在 main.rs 顶部写上:
#![no_std]
#![no_main]
这两行告诉编译器:别给我链接标准库,也别用默认的 main 入口。嗯,这里要注意,#![no_main] 是因为裸机程序的入口是 reset_handler 或者 _start,不是 C 语言的 main。
核心区别一览
| 特性 | std 环境 | no_std 环境 |
|---|---|---|
| 堆分配 | 默认全局分配器 | 需要手动实现 |
| panic 处理 | 栈展开 + 终止 | 自定义 panic_handler |
| println! | 直接可用 | 需要自己实现输出 |
| 入口函数 | main | 自定义入口 |
全局分配器:让堆动起来
没有 std,你连 Box::new(42) 都写不了。为什么?因为 Box 需要全局分配器。在 std 里,这个分配器是系统提供的。在裸机上,你得自己写。
我在项目中遇到过最坑的事:一个同事把堆大小设成了 128 字节,结果 Box::new([0u8; 256]) 直接静默失败,查了两天才发现是分配器返回了空指针。所以,分配器必须可靠。
Rust 提供了一个 trait:GlobalAlloc。你只需要实现 alloc 和 dealloc 两个方法。最简单的实现——固定大小 bump allocator:
use core::alloc::{GlobalAlloc, Layout};
use core::ptr;
pub struct BumpAllocator {
heap_start: usize,
heap_end: usize,
next: usize,
}
unsafe impl GlobalAlloc for BumpAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// 对齐
let align = layout.align();
let size = layout.size();
let current = (self.next + align - 1) & !(align - 1);
if current + size > self.heap_end {
ptr::null_mut() // 堆溢出
} else {
self.next = current + size;
current as *mut u8
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// bump allocator 不支持释放单个对象
// 实际项目中建议用 linked_list_allocator 或 buddy allocator
}
}
警告:上面的 bump allocator 只能用于教学。实际产品中,如果你需要频繁分配释放,请用 linked_list_allocator crate 或者自己实现 slab 分配器。我曾经在一个传感器数据采集项目里用 bump allocator,结果内存泄漏导致三天后系统崩溃——教训深刻。
注册全局分配器很简单:
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
heap_start: 0x2000_4000, // 假设 SRAM 起始地址
heap_end: 0x2000_8000,
next: 0x2000_4000,
};
然后你就可以愉快地使用 alloc::boxed::Box 和 alloc::vec::Vec 了。记得在 Cargo.toml 里加上 alloc 依赖:
[dependencies]
alloc = { version = "1.0", features = ["nightly"] }
panic 处理:别让系统静默死掉
没有 std,panic 发生时不会打印 "thread 'main' panicked at ..."。实际上,如果你不提供 #[panic_handler],编译器会直接报错。
我建议至少做两件事:
- 把 panic 信息输出到某个串口或 ITM
- 进入死循环,方便调试器挂住
一个典型的 panic handler:
use core::panic::PanicInfo;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// 假设你有一个串口输出函数
// uart_send_str("PANIC: ");
// uart_send_str(info.message().unwrap_or("unknown"));
// 或者用 cortex-m 的 semihosting
// hprintln!("PANIC: {}", info).ok();
loop {}
}
小技巧:调试阶段,我习惯用 cortex-m-semihosting 或者 rtt-target 把 panic 信息实时吐到主机终端。等产品定型了,再换成静默的 loop {}。这样开发效率和安全性两不误。
知识结构总览
下面这张图把本章的核心逻辑串起来了。你从 #![no_std] 出发,需要自己搞定三件事:入口、内存、异常。这三根柱子撑起了整个裸机程序。
实际项目中的配置清单
每次新建一个裸机项目,我都是按这个清单来检查的。你可以直接拿来用:
| 步骤 | 操作 | 常见坑 |
|---|---|---|
| 1 | 添加 #![no_std] 和 #![no_main] |
忘记加 #![no_main] 会导致链接错误 |
| 2 | 实现 #[panic_handler] |
不提供的话,编译器报 "panic_handler not found" |
| 3 | 定义堆空间并实现 GlobalAlloc |
堆大小不够时,alloc 返回 null,但 Rust 不会自动检查 |
| 4 | 用 #[global_allocator] 注册 |
只能注册一个,重复注册会编译失败 |
| 5 | 在 Cargo.toml 添加 alloc 依赖 |
需要 nightly Rust,stable 不支持自定义分配器 |
曾经有个坑:我在 STM32F103 上做项目,堆放在 0x2000_0000 开始的 SRAM 里。结果忘了 linker script 里把堆段和栈段分开,堆和栈重叠了。程序跑起来偶尔死机,查了两天才发现是堆栈碰撞。后来我强制在 linker script 里给堆和栈各划了独立区域,再也没出过问题。