Rust OS 入门:从 main 到 _start
师兄很早之前就推荐了这个项目学习,最近终于有时间来体验一下。看完第一节感觉对计算机的了解很有帮助,于是整理这份笔记。
本文内容整理自 Philipp Oppermann 的《Writing an OS in Rust》系列教程(https://os.phil-opp.com/),配合个人学习笔记结构化为中文说明,示例与结论均可在该教程中溯源。
📚 基本概念速读
| 名称 | 定义 | 省流 |
|---|---|---|
std |
Rust 标准库,提供依赖 OS 的高级能力 | 站在 OS 肩膀上 |
core |
不依赖 OS 的 Rust 核心库 | 裸机也能用 |
alloc |
提供 Vec、Box 等动态分配抽象,需要自己提供
allocator |
有堆才能用 |
no_std |
不自动链接 std,改用 core
作为基础环境 |
不依赖 std |
panic_handler |
no_std 下必须自己提供的 panic 处理函数 |
崩溃了怎么办 |
crt0 |
传统上对 C 程序启动代码(startup code)的统称;在典型 Linux
用户程序中,启动代码会提供 _start
等入口相关代码,并负责进入 libc/运行时初始化流程 |
启动的”保洁员” |
_start |
启动代码中的入口符号;在典型 Linux 用户程序中由 C runtime startup code 提供 | 一切从这开始 |
no_main |
告诉 Rust 不要按普通程序的方式寻找 main() |
入口我自己定 |
| Target Triple | 描述目标平台的字符串,通常包含架构、厂商、操作系统和 ABI 等信息 | 给谁编译 |
🧩 main()
不是程序真正的入口
这是理解 Rust OS 最重要的一步:main()
是应用程序逻辑的入口,而不是 CPU/OS 意义上的最底层入口。
普通程序的实际调用链是:
1 | Linux kernel |
graph TD
A["Linux kernel"] -->|"创建进程地址空间 / 建立初始用户栈 / 设置 RSP"| B["ELF loader 跳转到 e_entry"]
B -->|"入口地址通常对应 crt0 中的 _start"| C["crt0 中的 _start"]
C -->|"解析 argc / argv / envp"| D["libc / Rust startup glue"]
D --> E["main()"]
这里的 “libc / Rust startup glue” 指的是从 _start 到
main() 之间的启动与运行时衔接代码。它并不是一个类似
JVM/.NET 那样独立、庞大的运行时系统,而是由 libc、Rust startup
code 等组件共同完成相关工作。
当程序被 OS loader 加载后,CPU 会从 ELF 文件头
e_entry
字段指定的入口地址开始执行,而不是直接”找到”某个叫
_start 的符号。在典型 Linux 用户程序中,这个地址通常对应
crt0 中的 _start:
1 | 源代码中的 _start |
链接器确定程序入口地址,通常将其设置为 _start
的地址。main()
只是这条链上”最后被调用”的那个普通函数。
🧩 std、core 与
no_std
Rust 标准库可以理解成两层:
graph TD
S["std 标准库"] --> S1["操作系统相关:文件 / 网络 / 线程"]
S --> S2["println! / Vec / String"]
S --> C["core 核心库"]
C --> C1["基本类型 / Option / Result / Iterator"]
C --> C2["基本 trait / panic 基础机制"]
core
不是直接和硬件交互的库,而是一个不依赖操作系统的 Rust
基础库。它可以运行在 Linux、Windows、裸机、嵌入式甚至 OS Kernel
中。std 则建立在 core
等基础设施之上,大量依赖操作系统提供的能力。
#![no_std] 做了什么
1 |
|
为什么 println! 找不到?因为 println!
是宏,最终通过 std::io 使用 stdout
对应的文件描述符,并通过操作系统提供的 I/O 接口完成输出:
1 | println! |
而裸机环境下只有:
1 | 程序 |
标准库不再替你提供 stdout。所以:
1 | no_std ≠ 什么都没有 |
注意:no_std 不是”不能输出”。OS kernel
完全可以自己实现 print!——例如直接写 VGA
framebuffer、串口,或者通过其他硬件接口输出(这正是 phil-opp 教程后面
VGA 文本模式章节要做的事)。
另外,no_std 也不等于”只能用 core“:
1 |
|
no_std 会阻止自动链接 std,而
alloc 可以显式引入(前提是你提供了一个 allocator)。
🧩
Panic:panic_handler 与 panic 策略
这一节要分清两个不同概念:panic_handler(no_std
binary 必须提供的处理入口)和 panic 策略 unwind /
abort(panic 的实现方式)。
panic_handler:no_std
binary 必须提供的处理入口
最小程序:
1 |
|
会报错:
1 | #[panic_handler] function required, but not found |
因为 Rust 必须知道:程序发生 panic
时应该怎么办? 这个处理机制原本由 std
提供,no_std 环境下就要自己定义:
1 | use core::panic::PanicInfo; |
其中 PanicInfo 包含 panic 的相关信息;返回类型
-> !
表示这个函数永远不会返回(发散函数)。
panic 策略:unwind
还是 abort
这是另一个独立的概念:Rust 可以选择 panic 的实现策略。
unwind(栈展开):
1 | panic |
abort(直接终止):
1 | panic |
为什么选 abort
栈展开需要额外的展开信息和运行时支持,例如 Linux
环境常见的 libunwind、Windows 的 SEH 等。
1 | [profile.dev] |
注意区分:
#[panic_handler]是no_stdbinary 必须提供的 panic 处理入口;panic = "abort"是选择 panic 的实现策略。
🧩 #![no_main]
与自定义入口 _start
加入 #![no_std] 和 panic = "abort"
之后,又会出现:
1 | using `fn main` requires the standard library |
原因前面已经说过:对于这种 freestanding no_std
binary,不再使用 Rust
默认的程序启动入口机制,因此不能依赖默认启动代码去调用
main()。这与 phil-opp 教程的表述一致:freestanding
executable 没有 Rust runtime 和 crt0,因此不能直接定义普通
main。所以:
1 |
告诉 Rust:不要按照普通 Rust 程序的方式寻找
main()。然后自己定义入口:
1 |
|
_start
的几个关键修饰符
| 修饰符 | 作用 |
|---|---|
#[unsafe(no_mangle)] |
禁止 Rust name mangling,使最终符号名保持为 _start |
pub |
使该 Rust item 对 crate 外可见;在这种入口函数中通常这样声明 |
extern "C" |
使用稳定的 C ABI,而不是 Rust ABI |
-> ! |
表示入口函数不应该返回 |
关于 name mangling:Rust 默认会把函数名改写成带 crate
名和类型信息的符号,例如 fn start() 可能变成
_ZN3blog_os5start...,但链接器/bootloader 想找的是字面量
_start,所以 no_mangle
才是保证符号名正确的关键。
🧩 crt0 与
_start 的区别
这是最容易混淆的一对概念:
_start:启动代码中的入口符号;在典型 Linux 用户程序中由 C runtime startup code 提供,“从哪里开始”。crt0:传统上对 C 程序启动代码(startup code)的统称,“开始之后帮你做什么”。注意它不是一个固定文件——现代 Linux/glibc 中启动代码实际由crt1.o、crti.o、crtn.o等多个启动对象组成。
关系:在典型 Linux 用户程序中,_start
由这套启动代码提供——_start
不是启动代码之外的下一层,而是启动代码里的那个入口函数:
1 | 启动代码(传统上统称 crt0;现代 Linux 常拆分为 crt1.o / crti.o / crtn.o 等) |
_start = 启动代码中的入口符号(从哪里开始),crt0 = 传统上对这套启动代码的统称(开始之后帮你做什么)。
这里要特别澄清一个容易形成的错误认识:crt0 并不负责”准备栈”。在 Linux 用户程序启动时,初始用户栈已经由内核在加载程序时建立好了:
1 | Linux kernel |
所以 crt0 做的事是”在已有栈上解析参数、初始化运行环境”,而不是”申请一块内存并设置 RSP”。
为什么 Rust OS 不使用默认的
crt0?
因为面向 Linux 用户态程序的 crt0 假设下面有操作系统:
1 | Linux kernel |
而 Rust OS 的启动链是:
1 | Firmware / boot environment(传统 PC 上通常是 BIOS 或 UEFI) |
下面既没有 Linux/Windows,也没有 libc,所以不能使用默认的 crt0。但要注意:Rust OS 并不是完全没有”启动代码”——bootloader 和 kernel 自己承担了建立执行环境、设置栈以及进入 kernel 主逻辑等职责,它们本身就是一种 startup code。更准确的表述是:不使用面向 Linux 用户态程序的默认 crt0,相应的启动职责由 bootloader + kernel 自己承担。
🧩 栈从哪里来?
栈并不是”单纯申请一块内存,然后保存一个地址”,要分情况:
普通 Linux 用户程序:
1 | Linux kernel |
Rust OS:
1 | BIOS / UEFI(传统 PC) |
在 Linux
用户程序中,初始用户栈已经由内核在加载程序时建立,_start
拿到的是一个合法的栈;而在 Rust OS 中,建立执行环境(包括设置栈指针)是
bootloader / kernel 早期启动代码自己的职责。
更准确地说,栈是:
一种由软件约定定义的数据结构/内存使用方式,通常由一段内存区域和一个栈指针寄存器共同实现。 CPU 并不存在一个叫”栈”的东西。
x86-64 上通过 RSP 指向当前栈顶,用 push /
pop / call / ret /
sub rsp, ... / add rsp, ... 等指令操作它。
那么 _start 为什么写 loop {} 而不是
return?因为 _start 是 ELF
的程序入口,不是通过普通 call
指令调用的函数,因此没有一个正常的调用者向它提供返回地址。若
_start 返回,CPU
会尝试按照函数返回约定取出返回地址继续执行,而入口环境并不保证存在这样的返回地址。因此这种程序入口通常定义为
-> !,并在结束后进入死循环、关机或其他不会返回的路径。
🧩 链接器与链接错误
编译器与链接器的分工
1 | Rust 源码 |
编译器负责把源代码变成目标文件;链接器负责把这些目标文件和库组合成最终可执行文件。
为什么会出现链接器错误
因为你在 #![no_std] + #![no_main]
之后,仍然使用 x86_64-unknown-linux-gnu 这个
target。它意味着”我要生成一个 Linux 用户程序”,于是链接器默认认为需要
crt0、libc、Linux runtime;而你的程序没有 std、没有 libc、没有
main,于是报:
1 | undefined reference |
一种临时方案:手动传链接器参数
1 | cargo rustc -- -C link-arg=-nostartfiles |
含义:cargo → rustc → linker →
-nostartfiles,告诉链接器不要自动链接标准的 C
startup object files:
1 | crt0 ❌ |
各平台参数为什么不同
| 平台 | 可执行格式 | 默认入口 | 禁用 C startup |
|---|---|---|---|
| Linux | ELF | _start |
-nostartfiles |
| Windows | PE | mainCRTStartup |
/ENTRY:_start + /SUBSYSTEM:console |
| macOS | Mach-O | 自定义 | -e __start(配合
-static、-nostartfiles) |
因为它们的可执行文件格式、ABI、默认启动方式、链接器都不同。
为什么不推荐这个方法
-nostartfiles 去掉的是默认的 C startup object
files,并不会让 Linux loader 不再提供初始用户栈——你的
_start 仍然会从 Linux
建立好的初始栈开始执行,只是不会经过默认的 libc/Rust 启动流程。
真正缺少的是 crt0 / libc startup 做的事:
1 | crt0 / libc startup |
所以”编译成功、运行崩溃”不能一概而论:一个非常简单的 Linux
no_std + no_main
程序,只要入口、ABI、链接等处理正确,完全可以运行——例如上面那个空
loop {} 的 _start
甚至可以一直运行。是否崩溃,取决于你的 _start
是否依赖了上面这些缺失的初始化。这个方法之所以”不推荐”,是因为它在”假装自己是
Linux 程序”,却没有承担 Linux
程序应有的完整启动环境,越复杂的程序越容易踩坑。
🧩 正解:Bare Metal Target
真正的方向是:
1 | 不要假装自己是 Linux 程序 |
例如嵌入式常用的 thumbv7em-none-eabihf:
1 | thumbv7em → ARM Cortex-M 等 Thumb-2 / ARMv7E-M 子架构 |
这样 Rust 就不会默认假设存在 Linux、Windows、libc、crt0。
Target Triple 是什么
Rust 用 Target Triple 描述目标环境。其一般格式为
<arch><sub>-<vendor>-<sys>-<abi>,但有些
target 会省略某些部分,严格说不一定是”四元组”。例如
x86_64-unknown-linux-gnu:
1 | x86_64 → CPU 架构 |
而 thumbv7em-none-eabihf 中的 none
就表示没有 OS。
Host 与 Target
- Host:运行编译器的机器,例如 Windows x86-64。
- Target:你希望程序运行的机器,例如
x86_64-blog_os裸机 kernel。
1 | Windows 电脑 |
这种”在一种平台上编译出另一种平台程序”的做法叫交叉编译(cross compilation)。
🧩 两种启动链的最终对比
graph TD
subgraph "普通 Linux 用户程序"
A1["硬件"] --> B1["操作系统"]
B1 --> C1["ELF loader"]
C1 --> D1["crt0 / _start"]
D1 --> E1["libc / Rust startup"]
E1 --> F1["main()"]
end
subgraph "Rust OS"
A2["硬件"] --> B2["固件 / boot environment(传统 PC 上为 BIOS / UEFI)"]
B2 --> C2["Bootloader"]
C2 --> D2["Kernel entry"]
D2 --> E2["Kernel initialization(含设置栈等)"]
E2 --> F2["kernel_main()"]
end
典型的 Rust Linux
用户程序依赖的是操作系统提供的用户态执行环境,并使用
std、默认 main
入口机制以及相应的用户态启动代码和运行时支持;Rust OS
不使用这一套,相应职责由 bootloader + kernel
自己承担。
✅ 总结
no_std是”不使用 Rust 标准库”;no_main是”不让 Rust 按普通程序方式处理main“;_start是你自己定义的底层入口;真正写 OS 时,我们不使用面向 Linux 用户态程序的默认 crt0——相应的启动职责由 bootloader 和 kernel 自己承担。
两条启动链的完整模型:
1 | 普通 Linux 用户程序 |
关键变化:
| 组件 | 普通程序 | Rust OS |
|---|---|---|
std |
使用 | 不使用 |
默认 main 入口机制 |
使用 | 不使用 |
| 用户态 crt0 | 使用 | 不使用 |
| Linux 用户态 runtime | 依赖 | 不依赖 |
但注意:启动代码 ≠ 消失。只是从”操作系统提供的 crt0/libc 启动流程”,换成了”由 bootloader + kernel 自己承担”的启动流程——这才是这篇文章最值得强调的核心思想。
如果你想亲手把这个过程跑一遍(包括自定义 target 文件、VGA
文本模式输出、println! 宏等),推荐按顺序阅读:
- 《Writing an OS in Rust》A Freestanding Rust Binary:https://os.phil-opp.com/freestanding-rust-binary/
- 《Writing an OS in Rust》系列主页:https://os.phil-opp.com/
Happy Hacking! 🎉