2. Rust嵌入式基础

好,咱们正式开始动手写裸机代码。这一章我会带你拆开Rust的「标准库」这层壳,看看底下到底长什么样。

你想想看,在PC上写Rust程序,println!、Vec、HashMap随手就来。但到了嵌入式环境——没有操作系统、没有文件系统、没有堆分配器——这些东西全都没了。怎么办?

答案就是:no_std。

2.1 什么是no_std?为什么需要它?

简单说,no_std就是告诉编译器:「我不依赖标准库,请只给我最核心的那套东西。」

标准库(std)依赖操作系统提供的底层服务,比如线程、文件I/O、网络。而裸机环境里这些统统不存在。所以我们必须用#![no_std]属性来声明:我的程序不需要这些。

核心区别一句话:

  • std = core + 操作系统服务 + 堆分配器 + 运行时初始化
  • no_std = core + 你自己搞定剩下的所有事

我在项目中遇到过不少新手,上来就写use std::vec::Vec,然后编译报错一脸懵。其实只要理解了no_std的边界,这些问题都能提前避免。

2.2 核心crate:裸机编程的三根支柱

进入no_std世界后,有三个crate是你绕不开的。我把它们称为「裸机三件套」。

Crate名称 作用 谁提供
core 提供语言核心类型、trait、基本操作(如迭代器、Option、Result) Rust编译器自带
compiler_builtins 提供编译器内部函数(如整数除法、内存拷贝) Rust编译器自带
panic_handler 定义程序崩溃时的行为 开发者自己实现

2.2.1 core crate

core是Rust标准库的精简版。它包含了所有不依赖操作系统的语言特性:基本类型、trait、闭包、迭代器、原子操作等等。

我个人习惯在no_std项目里这样引入:

// 在lib.rs或main.rs顶部
#![no_std]

// 使用core中的类型
use core::cell::RefCell;
use core::sync::atomic::{AtomicU32, Ordering};

注意:core是隐式链接的,你不需要在Cargo.toml里添加依赖。它就像空气一样,一直都在。

2.2.2 compiler_builtins crate

这个crate比较隐蔽,但非常重要。它提供了一些编译器在生成代码时「偷偷调用」的函数。

举个例子:当你对两个64位整数做除法,而目标CPU没有硬件除法指令时,编译器会生成一个对__udivdi3函数的调用。这个函数就由compiler_builtins提供。

小提示: 如果你在链接时遇到类似undefined reference to __aeabi_uidiv的错误,多半是compiler_builtins没有正确链接。检查你的Cargo.toml是否添加了:

[dependencies]
compiler_builtins = { version = "0.1", features = ["mem"] }

我曾经在一个ARM Cortex-M3项目上折腾了整整半天,就为了搞定这个链接错误。后来发现是feature没开对——嗯,这种坑踩过一次就记住了。

2.2.3 panic_handler

这是no_std项目里你必须亲手实现的东西。标准库有默认的panic处理逻辑(打印错误信息、展开栈、退出进程)。但在裸机上,这些全都不存在。

你需要用#[panic_handler]属性定义一个函数,告诉编译器:当程序panic时,请执行这个函数。

// 最简单的panic处理:死循环
#[panic_handler]
fn panic(_info: &core::panic::PanickInfo) -> ! {
    loop {}
}

当然,实际项目中你不会让它空转。更常见的做法是:

  • 点亮一个红色LED
  • 通过串口打印panic信息
  • 进入调试模式等待连接

注意: panic处理函数的返回类型是!(never type),表示它永远不会返回。这意味着你的panic处理程序必须是一个无限循环,或者直接触发系统复位。

2.3 裸机入口与启动流程

好了,现在你有了core、compiler_builtins、panic_handler。但程序从哪里开始执行?

在PC上,入口是main函数。但在裸机上,事情要复杂得多。

2.3.1 启动流程概览

一个典型的ARM Cortex-M裸机启动流程如下:

  1. 复位向量:CPU上电后,从向量表取出复位向量地址,跳转执行。
  2. 汇编启动代码:初始化栈指针、清零BSS段、拷贝数据段。
  3. Rust运行时初始化:调用init函数,设置全局变量。
  4. 进入main:调用你写的main函数。

这个过程,说白了就是「从硬件复位到你的业务代码之间,需要有人铺好路」。在Rust裸机编程中,这个铺路的工作通常由启动文件(startup.s)和链接脚本(linker script)共同完成。

2.3.2 最小启动代码示例

下面是一个最简的Rust裸机程序,它可以在QEMU模拟的STM32F103上运行:

// main.rs
#![no_std]
#![no_main]

use core::panic::PanickInfo;

// 定义向量表
#[link_section = ".vector_table"]
#[no_mangle]
pub static __vector_table: [unsafe extern "C" fn(); 16] = [
    reset_handler,  // 复位向量
    // ... 其他中断向量填0
    [0usize; 15],   // 简化处理
];

// 复位处理函数
#[no_mangle]
pub unsafe extern "C" fn reset_handler() -> ! {
    // 初始化全局变量(实际项目需要更复杂的处理)
    main_loop()
}

// 主循环
fn main_loop() -> ! {
    loop {
        // 你的业务代码
    }
}

// panic处理
#[panic_handler]
fn panic(_info: &PanickInfo) -> ! {
    loop {}
}

注意几个关键点:

  • #![no_main]:告诉编译器不要使用标准的main入口。
  • #[link_section = ".vector_table"]:把向量表放到特定的内存段。
  • #[no_mangle]:防止Rust对函数名做修饰,保证链接器能找到符号。

2.3.3 链接脚本的作用

光有代码还不够,你还需要一个链接脚本告诉链接器:代码放在哪里,数据放在哪里,栈放在哪里。

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

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

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

    .text : {
        *(.text*)
    } > FLASH

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

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

这里定义了:

  • FLASH从0x08000000开始,64KB大小
  • RAM从0x20000000开始,20KB大小
  • 向量表和代码放在FLASH
  • 数据段在FLASH中存储,运行时拷贝到RAM
  • BSS段直接在RAM中清零

避坑指南: 我曾经犯过一个低级错误——链接脚本里RAM的起始地址写错了,结果程序一运行就写到了未映射的地址空间,导致硬错误(HardFault)。排查了整整两天才发现是链接脚本的问题。所以,拿到一块新板子,第一件事就是核对芯片手册里的内存映射表。

2.4 知识体系结构图

下面这张图总结了本章的核心知识点和它们之间的关系:

no_std裸机编程知识体系 no_std裸机编程核心知识体系 #![no_std] core crate compiler_builtins panic_handler 提供:基本类型、trait、 迭代器、原子操作 提供:__udivdi3、memcpy 等编译器内部函数 必须由开发者实现 定义panic时的行为 裸机启动流程 ① 复位向量 ② 汇编启动 ③ Rust初始化 ④ main() 关键支撑:链接脚本(linker script)+ 向量表 + 内存映射

2.5 本章小结

这一章我们拆解了no_std裸机编程的底层结构:

  • no_std不是「残缺版Rust」,而是「精简版Rust」——你失去了便利,但获得了对硬件的完全控制。
  • core crate是你在裸机上的左膀右臂,熟悉它的API是必备技能。
  • compiler_builtins是幕后英雄,链接错误时记得检查它。
  • panic_handler是最后一道防线,别让它空转。
  • 启动流程从复位向量到main,每一步都需要你亲手搭建。

说实话,刚接触no_std的时候,我也觉得这些东西又繁琐又底层。但当你亲手点亮第一个LED、看到串口输出第一行日志时,那种掌控感是任何高级框架都给不了的。