Rust OS 入门:从 main 到 _start

师兄很早之前就推荐了这个项目学习,最近终于有时间来体验一下。看完第一节感觉对计算机的了解很有帮助,于是整理这份笔记。

本文内容整理自 Philipp Oppermann 的《Writing an OS in Rust》系列教程(https://os.phil-opp.com/),配合个人学习笔记结构化为中文说明,示例与结论均可在该教程中溯源。

📚 基本概念速读

名称 定义 省流
std Rust 标准库,提供依赖 OS 的高级能力 站在 OS 肩膀上
core 不依赖 OS 的 Rust 核心库 裸机也能用
alloc 提供 VecBox 等动态分配抽象,需要自己提供 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
2
3
4
5
6
7
8
9
Linux kernel
↓ 创建进程地址空间、建立初始用户栈、设置 RSP
ELF loader 跳转到 e_entry

crt0 中的 _start(解析 argc / argv / envp)

libc / Rust startup glue

main()


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” 指的是从 _startmain() 之间的启动与运行时衔接代码。它并不是一个类似 JVM/.NET 那样独立、庞大的运行时系统,而是由 libc、Rust startup code 等组件共同完成相关工作。

当程序被 OS loader 加载后,CPU 会从 ELF 文件头 e_entry 字段指定的入口地址开始执行,而不是直接”找到”某个叫 _start 的符号。在典型 Linux 用户程序中,这个地址通常对应 crt0 中的 _start

1
2
3
4
5
6
7
源代码中的 _start
↓ 链接器解析符号
确定 _start 的地址

ELF header: e_entry = _start 的地址
↓ OS Loader 加载 ELF
CPU 从 e_entry 开始执行

链接器确定程序入口地址,通常将其设置为 _start 的地址main() 只是这条链上”最后被调用”的那个普通函数。

🧩 stdcoreno_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
2
3
4
5
#![no_std]

fn main() {
println!("Hello"); // 报错:cannot find macro `println!`
}

为什么 println! 找不到?因为 println! 是宏,最终通过 std::io 使用 stdout 对应的文件描述符,并通过操作系统提供的 I/O 接口完成输出:

1
2
3
4
5
6
7
8
9
10
11
println!

std::io

stdout 对应的文件描述符

write 等系统调用

Linux kernel

终端 / pipe / 重定向文件……

而裸机环境下只有:

1
2
3
程序

硬件

标准库不再替你提供 stdout。所以:

1
2
no_std ≠ 什么都没有
no_std = 不自动链接 std,默认以 core 为基础

注意:no_std 不是”不能输出”。OS kernel 完全可以自己实现 print!——例如直接写 VGA framebuffer、串口,或者通过其他硬件接口输出(这正是 phil-opp 教程后面 VGA 文本模式章节要做的事)。

另外,no_std 也不等于”只能用 core“:

1
2
3
4
5
#![no_std]
extern crate alloc; // 显式引入 alloc
use alloc::vec::Vec;

// 只要自己提供 allocator,就能使用 Vec、Box 等动态分配抽象

no_std 会阻止自动链接 std,而 alloc 可以显式引入(前提是你提供了一个 allocator)。

🧩 Panic:panic_handler 与 panic 策略

这一节要分清两个不同概念panic_handler(no_std binary 必须提供的处理入口)和 panic 策略 unwind / abort(panic 的实现方式)。

panic_handlerno_std binary 必须提供的处理入口

最小程序:

1
2
3
#![no_std]

fn main() {}

会报错:

1
#[panic_handler] function required, but not found

因为 Rust 必须知道:程序发生 panic 时应该怎么办? 这个处理机制原本由 std 提供,no_std 环境下就要自己定义:

1
2
3
4
5
6
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

其中 PanicInfo 包含 panic 的相关信息;返回类型 -> ! 表示这个函数永远不会返回(发散函数)。

panic 策略:unwind 还是 abort

这是另一个独立的概念:Rust 可以选择 panic 的实现策略。

unwind(栈展开):

1
2
3
4
5
panic

沿调用栈展开(逐层退出函数,运行局部变量析构函数)

最终终止 / 被捕获

abort(直接终止):

1
2
3
panic

直接终止

为什么选 abort

栈展开需要额外的展开信息和运行时支持,例如 Linux 环境常见的 libunwind、Windows 的 SEH 等。

1
2
3
4
5
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

注意区分:#[panic_handler]no_std binary 必须提供的 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
#![no_main]

告诉 Rust:不要按照普通 Rust 程序的方式寻找 main()。然后自己定义入口:

1
2
3
4
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
loop {}
}

_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.ocrti.ocrtn.o 等多个启动对象组成。

关系:在典型 Linux 用户程序中,_start 由这套启动代码提供——_start 不是启动代码之外的下一层,而是启动代码里的那个入口函数:

1
2
3
4
启动代码(传统上统称 crt0;现代 Linux 常拆分为 crt1.o / crti.o / crtn.o 等)
├── 提供入口符号 _start
├── 启动相关代码(解析 argc / argv / envp、初始化 libc / runtime)
└── 最终调用 main()

_start = 启动代码中的入口符号(从哪里开始)crt0 = 传统上对这套启动代码的统称(开始之后帮你做什么)

这里要特别澄清一个容易形成的错误认识:crt0 并不负责”准备栈”。在 Linux 用户程序启动时,初始用户栈已经由内核在加载程序时建立好了:

1
2
3
4
5
6
7
8
9
Linux kernel
↓ 创建进程地址空间
↓ 建立初始用户栈
↓ 设置 RSP
↓ 跳转到 _start
crt1 / _start
↓ 解析 argc / argv / envp
↓ 初始化 libc / runtime
main()

所以 crt0 做的事是”在已有栈上解析参数、初始化运行环境”,而不是”申请一块内存并设置 RSP”。

为什么 Rust OS 不使用默认的 crt0

因为面向 Linux 用户态程序的 crt0 假设下面有操作系统:

1
2
3
4
5
6
7
8
9
10
11
Linux kernel

ELF loader

crt0 / _start

libc startup

Rust startup glue

main()

而 Rust OS 的启动链是:

1
2
3
4
5
6
7
8
9
Firmware / boot environment(传统 PC 上通常是 BIOS 或 UEFI)

bootloader

kernel entry

kernel initialization

kernel_main()

下面既没有 Linux/Windows,也没有 libc,所以不能使用默认的 crt0。但要注意:Rust OS 并不是完全没有”启动代码”——bootloader 和 kernel 自己承担了建立执行环境、设置栈以及进入 kernel 主逻辑等职责,它们本身就是一种 startup code。更准确的表述是:不使用面向 Linux 用户态程序的默认 crt0,相应的启动职责由 bootloader + kernel 自己承担

🧩 栈从哪里来?

栈并不是”单纯申请一块内存,然后保存一个地址”,要分情况:

普通 Linux 用户程序

1
2
3
4
5
Linux kernel
↓ 建立进程地址空间
↓ 建立用户栈
↓ 设置 RSP
↓ 跳到 _start

Rust OS

1
2
3
4
5
6
BIOS / UEFI(传统 PC)

bootloader
↓ 建立 kernel 执行环境
↓ 设置 RSP
↓ 跳到 kernel entry

在 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
2
3
4
5
Rust 源码
↓ rustc
目标文件 .o
↓ 链接器(多个 .o + 库 + 入口点 + 地址布局)
Executable

编译器负责把源代码变成目标文件;链接器负责把这些目标文件和库组合成最终可执行文件。

为什么会出现链接器错误

因为你在 #![no_std] + #![no_main] 之后,仍然使用 x86_64-unknown-linux-gnu 这个 target。它意味着”我要生成一个 Linux 用户程序”,于是链接器默认认为需要 crt0、libc、Linux runtime;而你的程序没有 std、没有 libc、没有 main,于是报:

1
2
undefined reference
entry point not found

一种临时方案:手动传链接器参数

1
cargo rustc -- -C link-arg=-nostartfiles

含义:cargo → rustc → linker → -nostartfiles,告诉链接器不要自动链接标准的 C startup object files

1
2
crt0 ❌
你的 _start ✅

各平台参数为什么不同

平台 可执行格式 默认入口 禁用 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
2
3
4
5
crt0 / libc startup
├── 解析 argc / argv / envp
├── libc 初始化
├── TLS / runtime 等相关初始化
└── 最终调用 main()

所以”编译成功、运行崩溃”不能一概而论:一个非常简单的 Linux no_std + no_main 程序,只要入口、ABI、链接等处理正确,完全可以运行——例如上面那个空 loop {}_start 甚至可以一直运行。是否崩溃,取决于你的 _start 是否依赖了上面这些缺失的初始化。这个方法之所以”不推荐”,是因为它在”假装自己是 Linux 程序”,却没有承担 Linux 程序应有的完整启动环境,越复杂的程序越容易踩坑。

🧩 正解:Bare Metal Target

真正的方向是:

1
2
不要假装自己是 Linux 程序
而是告诉 Rust:我根本没有操作系统

例如嵌入式常用的 thumbv7em-none-eabihf

1
2
3
thumbv7em   → ARM Cortex-M 等 Thumb-2 / ARMv7E-M 子架构
none → 没有操作系统
eabihf → Embedded ABI + hard-float

这样 Rust 就不会默认假设存在 Linux、Windows、libc、crt0。

Target Triple 是什么

Rust 用 Target Triple 描述目标环境。其一般格式为 <arch><sub>-<vendor>-<sys>-<abi>,但有些 target 会省略某些部分,严格说不一定是”四元组”。例如 x86_64-unknown-linux-gnu

1
2
3
4
x86_64    → CPU 架构
unknown → vendor
linux → OS
gnu → ABI

thumbv7em-none-eabihf 中的 none 就表示没有 OS。

Host 与 Target

  • Host:运行编译器的机器,例如 Windows x86-64。
  • Target:你希望程序运行的机器,例如 x86_64-blog_os 裸机 kernel。
1
2
3
Windows 电脑
↓ rustc(交叉编译)
x86_64 裸机 kernel

这种”在一种平台上编译出另一种平台程序”的做法叫交叉编译(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
普通 Linux 用户程序
OS loader

_start

libc / Rust startup

main()

Rust OS
Firmware / boot environment

Bootloader

Kernel entry

Kernel initialization

kernel_main()

关键变化:

组件 普通程序 Rust OS
std 使用 不使用
默认 main 入口机制 使用 不使用
用户态 crt0 使用 不使用
Linux 用户态 runtime 依赖 不依赖

但注意:启动代码 ≠ 消失。只是从”操作系统提供的 crt0/libc 启动流程”,换成了”由 bootloader + kernel 自己承担”的启动流程——这才是这篇文章最值得强调的核心思想。

如果你想亲手把这个过程跑一遍(包括自定义 target 文件、VGA 文本模式输出、println! 宏等),推荐按顺序阅读:

Happy Hacking! 🎉