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裸机启动流程如下:
- 复位向量:CPU上电后,从向量表取出复位向量地址,跳转执行。
- 汇编启动代码:初始化栈指针、清零BSS段、拷贝数据段。
- Rust运行时初始化:调用
init函数,设置全局变量。 - 进入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 知识体系结构图
下面这张图总结了本章的核心知识点和它们之间的关系:
2.5 本章小结
这一章我们拆解了no_std裸机编程的底层结构:
- no_std不是「残缺版Rust」,而是「精简版Rust」——你失去了便利,但获得了对硬件的完全控制。
- core crate是你在裸机上的左膀右臂,熟悉它的API是必备技能。
- compiler_builtins是幕后英雄,链接错误时记得检查它。
- panic_handler是最后一道防线,别让它空转。
- 启动流程从复位向量到main,每一步都需要你亲手搭建。
说实话,刚接触no_std的时候,我也觉得这些东西又繁琐又底层。但当你亲手点亮第一个LED、看到串口输出第一行日志时,那种掌控感是任何高级框架都给不了的。