裸机编程基础:无标准库环境与启动流程

好,咱们正式开始动手了。这一章要解决一个根本问题:没有操作系统,Rust 怎么跑起来?

你想想看,平时写 Rust 程序,println!、Vec、Box 随手就用。但在裸机上,这些全没了。标准库依赖操作系统提供底层服务——文件系统、网络、堆管理。嵌入式设备上这些都不存在。

所以第一步,就是告诉编译器:「别给我链接标准库,我自己来。」

#![no_std]:告别标准库

在 Rust 里,#![no_std] 是一个 crate 级别的属性。把它放在 main.rs 或 lib.rs 的顶部,编译器就不会自动引入 std。

// 放在文件最开头
#![no_std]

// 现在只能用 core 库里的东西
use core::panic::PanicInfo;

core 库是标准库的一个子集,它不依赖任何操作系统。提供了基本类型、迭代器、闭包、原子操作等。但像 Vec、String、Box 这些需要堆分配的类型,就不在 core 里了。

核心区别:std = core + 堆分配 + 操作系统接口。裸机只能用 core。

我个人习惯,新建嵌入式项目时第一件事就是加 #![no_std]。然后编译一次,看看哪些依赖偷偷用了 std,一个个清理掉。

全局分配器:让 Vec 和 Box 回来

没有堆分配器,你就没法用 Vec、String、Box。但很多时候我们确实需要动态内存。怎么办?自己写一个分配器。

Rust 提供了一个 trait:GlobalAlloc。你实现它,然后用 #[global_allocator] 注册。

use core::alloc::{GlobalAlloc, Layout};

// 一个最简单的 bump allocator
pub 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);
        if aligned + size > self.heap_end {
            return core::ptr::null_mut(); // 内存不足
        }
        self.next.store(aligned + size, core::sync::atomic::Ordering::Relaxed);
        aligned as *mut u8
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // bump allocator 不支持释放单个对象
        // 实际项目中需要更复杂的分配器
    }
}

// 声明全局分配器
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {
    heap_start: 0x2000_0000, // 假设堆起始地址
    heap_end: 0x2001_0000,
    next: core::sync::atomic::AtomicUsize::new(0x2000_0000),
};

注意:上面的 bump allocator 只是教学演示。实际项目中,我建议用 linked_list_allocator 或 embedded_alloc 这类成熟 crate。我曾经在项目里自己手写了一个伙伴分配器,调试了整整两天才发现一个边界对齐的 bug……嗯,从那以后我学乖了。

有了全局分配器,Vec 和 Box 就能用了。但要注意:分配器必须线程安全。如果你的设备有中断,分配器内部需要加锁或使用原子操作。

panic 处理:崩溃了怎么办?

没有标准库,panic 时的默认行为是调用 abort。但裸机上没有 abort 系统调用。所以你必须自己实现 panic 处理函数。

use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    // 在裸机上,通常的做法是:
    // 1. 打印错误信息到串口
    // 2. 进入死循环或重启

    // 假设我们有一个串口输出函数
    // serial_println!("Panic: {}", info);

    loop {} // 死循环,防止返回
}

#[panic_handler] 是一个特殊属性。整个程序只能有一个 panic handler。它的返回类型是 !(never type),表示这个函数永远不会返回。

调试技巧:开发阶段,我习惯在 panic handler 里闪烁 LED 或输出错误码。比如:快闪 3 次表示内存越界,慢闪 2 次表示除零错误。这样不用接调试器也能知道哪里出了问题。

启动流程与入口点

这是裸机编程最绕的地方。普通 Rust 程序从 main 开始,但那是操作系统帮你准备好的。裸机上,CPU 上电后直接跳转到复位向量指向的地址。你得自己处理一切。

典型的启动流程如下:

  1. 复位向量:CPU 从固定地址读取初始栈指针和入口地址。
  2. 汇编启动:初始化栈指针、清零 BSS 段、拷贝数据段。
  3. Rust 入口:调用 Rust 函数,初始化全局分配器、设置中断向量表。
  4. 进入 main:调用用户的 main 函数。

在 Rust 里,我们通常用 cortex-m-rt(ARM Cortex-M)或 riscv-rt(RISC-V)这类 crate 来处理启动流程。它们帮你生成了汇编启动代码和链接脚本。

// 使用 cortex-m-rt 的例子
#![no_std]
#![no_main] // 不使用标准库的 main

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    // 你的代码从这里开始
    loop {}
}

#[entry] 属性宏会生成正确的入口点。它确保在调用你的 main 之前,所有必要的初始化都已完成。

关键点:#![no_main] 告诉 Rust 不要生成标准库的 main 入口。我们自己用 #[entry] 定义入口。

知识结构总览

下面这张图把本章的核心概念串起来了。你可以看到从 CPU 上电到用户代码执行的全路径。

裸机启动流程与关键组件 CPU 上电 读取复位向量 汇编启动代码 初始化栈、BSS、数据段 Rust 入口 #[entry] 宏展开 全局分配器初始化 #[global_allocator] 中断向量表 设置异常处理函数 外设初始化 时钟、GPIO、串口 用户 main 函数 #![no_std] 环境下的业务逻辑 panic 处理 #[panic_handler] 异常/中断 硬件错误处理 虚线表示 异常路径

链接脚本:告诉编译器内存布局

启动流程离不开链接脚本。它告诉链接器:代码放哪里、数据放哪里、栈放哪里。

一个典型的 Cortex-M 链接脚本片段:

MEMORY
{
    FLASH : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text : {
        KEEP(*(.vector_table))
        *(.text*)
    } > FLASH

    .data : {
        *(.data*)
    } > RAM AT > FLASH

    .bss : {
        *(.bss*)
    } > RAM
}

启动代码会负责:

  • 把 .data 段从 Flash 拷贝到 RAM
  • 把 .bss 段清零
  • 设置栈指针为 RAM 的末尾

常见坑:我曾经在项目里忘记在链接脚本中给栈留足够空间。结果函数调用一深,栈就溢出到全局变量区。现象非常诡异——变量莫名其妙被修改。调试了两天才发现是栈溢出。从那以后,我每次都会在链接脚本里显式定义栈大小,并在启动代码中检查栈指针范围。

本章核心概念速查表

概念 作用 关键属性/宏
#![no_std] 禁用标准库,只使用 core 库 crate 级别属性
全局分配器 提供堆内存分配能力 #[global_allocator]
panic handler 处理运行时错误 #[panic_handler]
入口点 定义程序起始地址 #[entry] / #![no_main]
链接脚本 描述内存布局 .ld 文件

嗯,这一章的内容就这些。说白了,裸机编程就是自己接管操作系统干的那堆活。刚开始会觉得繁琐,但一旦你理解了启动流程和内存布局,后面写驱动、调性能就顺手多了。