裸机编程基础:无标准库环境与全局分配器

好,咱们进入第三章。说实话,这一章是很多 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],编译器会直接报错。

我建议至少做两件事:

  1. 把 panic 信息输出到某个串口或 ITM
  2. 进入死循环,方便调试器挂住

一个典型的 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] 出发,需要自己搞定三件事:入口、内存、异常。这三根柱子撑起了整个裸机程序。

裸机编程核心三要素 #![no_std] 裸机程序 入口:no_main 自定义 reset_handler 内存:全局分配器 实现 GlobalAlloc trait 异常:panic_handler 自定义 panic 行为 向量表 栈初始化 Bump / Slab #[global_allocator] 串口输出 死循环 三者缺一不可 缺少任何一个,编译器都会报错或运行时静默崩溃

实际项目中的配置清单

每次新建一个裸机项目,我都是按这个清单来检查的。你可以直接拿来用:

步骤 操作 常见坑
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 里给堆和栈各划了独立区域,再也没出过问题。

小结