Stage 1 最小化内核
https://os.phil-opp.com/zh-CN/
https://osdev.wiki/wiki/Expanded_Main_Page
Rust编译出无系统库以来的程序
cargo new blog_os
rust创建
禁用 no_std 的方式
#![no_std]
加上这个,让主程序为空,编译还是不通过,因为缺少
- panic 处理函数
- 语言项
实现 panic 处理函数
use core::panic::PanicInfo;
/// 这个函数将在 panic 时被调用
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
!是不返回的标记,panic函数要求从不返回
语言项
语言项(Language Item) 是 Rust 编译器内部与标准库(或运行时)之间的一种特殊接口机制。它告诉编译器:
“当你需要实现某种内建行为时,要去调用这个具体的函数、类型或 trait。”
换句话说:
语言项是编译器识别的特殊标记,用于连接编译器逻辑与 Rust 代码实现。
编译器内部不会直接硬编码调用哪个函数、哪个 trait。而是通过像这样一个属性进行绑定:
#[lang = "copy"]
trait Copy { }
这告诉编译器:
“当你在语义分析中需要检查类型是否可复制(
Copy)时,请使用我这个 trait。”
eh_personality(exception handling personality function,异常处理个性函数)是 Rust 编译器内部定义的一个语言项(lang item)。它用于支持 异常处理机制,尤其是在发生 panic 时,负责协助实现 栈展开(stack unwinding) 的底层逻辑。
换句话说:
eh_personality是编译器在生成异常处理代码时调用的“钩子函数”(hook)。
对于我们现阶段来说,可以禁用栈展开, 在 toml 加入
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
start
我们不能用main,因为它也用到了标准库。我们使用 no_mangle 标记这个函数,来对它禁用名称重整(name mangling)——这确保 Rust 编译器输出一个名为 _start 的函数;否则,编译器可能最终生成名为_ZN3blog_os4_start7hb173fedf945531caE 的函数,链接器不能识别。
我们还将函数标记为 extern "C",告诉编译器这个函数应当使用 C 语言的调用约定,而不是 Rust 语言的调用约定。
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
loop {}
}
在程序开头还要加上 #![no_main] 然后删掉 main 函数
编译
我们要选择特点的target,我们可以另选一个底层没有操作系统的运行环境。这样的运行环境被称作裸机环境,例如目标三元组 thumbv7em-none-eabihf 描述了一个 ARM 嵌入式系统,要为这个目标编译,我们需要使用 rustup 添加它:
rustup target add thumbv7em-none-eabihf
然后编译
cargo build --target thumbv7em-none-eabihf
OS怎么启动的
当我们启动电脑时,主板 ROM内存储的固件(firmware)将会运行:它将负责电脑的加电自检(power-on self test),可用内存(available RAM)的检测,以及 CPU 和其它硬件的预加载。这之后,它将寻找一个可引导的存储介质(bootable disk),并开始引导启动其中的内核(kernel)。
x86 架构支持两种固件标准: BIOS 和 UEFI
我们暂时只提供 BIOS 固件的引导启动方式
BIOS 启动
几乎所有的 x86 硬件系统都支持 BIOS 启动,但这种兼容性有时也是 BIOS 引导启动最大的缺点,因为这意味着在系统启动前,你的 CPU 必须先进入一个 16 位系统兼容的实模式。
当电脑启动时,主板上特殊的闪存中存储的 BIOS 固件将被加载。BIOS 固件将会加电自检、初始化硬件,然后它将寻找一个可引导的存储介质。如果找到了,那电脑的控制权将被转交给引导程序(bootloader):一段存储在存储介质的开头的、512字节长度的程序片段。
大多数的引导程序长度都大于512字节——所以通常情况下,引导程序都被切分为一段优先启动、长度不超过512字节、存储在介质开头的第一阶段引导程序(first stage bootloader),和一段随后由其加载的、长度可能较长、存储在其它位置的第二阶段引导程序(second stage bootloader)
引导程序必须决定内核的位置,并将内核加载到内存。引导程序还需要将 CPU 从 16 位的实模式,先切换到 32 位的保护模式(protected mode),最终切换到 64 位的长模式(long mode):此时,所有的 64 位寄存器和整个主内存(main memory)才能被访问。引导程序的第三个作用,是从 BIOS 查询特定的信息,并将其传递到内核;如查询和传递内存映射表(memory map)。
因此,我们不会讲解如何编写自己的引导程序,而是推荐 bootimage 工具——它能够自动并且方便地为你的内核准备一个引导程序。
Multiboot
multiboot是bootloader的一个标准,为了避免每个操作系统都要是实现自己的bootloader。GRUB就是一个multiboot的实现。
VGA Buffer
要向屏幕打印字符,可以用这个buffer。我们必须将它写入硬件提供的 VGA 字符缓冲区(VGA text buffer)通常状况下,VGA 字符缓冲区是一个 25 行、80 列的二维数组,它的内容将被实时渲染到屏幕。
这个数组的元素被称作字符单元(character cell),它使用下面的格式描述一个屏幕上的字符:
| Bit(s) | Value |
|---|---|
| 0-7 | ASCII code point |
| 8-11 | Foreground color |
| 12-14 | Background color |
| 15 | Blink |
第一个字节表示了应当输出的 ASCII 编码,更加准确的说,类似于 437 字符编码表 中字符对应的编码,但又有细微的不同。 这里为了简化表达,我们在文章里将其简称为ASCII字符。
第二个字节则定义了字符的显示方式,前四个比特定义了前景色,中间三个比特定义了背景色,最后一个比特则定义了该字符是否应该闪烁,以下是可用的颜色列表:
| Number | Color | Number + Bright Bit | Bright Color |
|---|---|---|---|
| 0x0 | Black | 0x8 | Dark Gray |
| 0x1 | Blue | 0x9 | Light Blue |
| 0x2 | Green | 0xa | Light Green |
| 0x3 | Cyan | 0xb | Light Cyan |
| 0x4 | Red | 0xc | Light Red |
| 0x5 | Magenta | 0xd | Pink |
| 0x6 | Brown | 0xe | Yellow |
| 0x7 | Light Gray | 0xf | White |
实现
static HELLO: &[u8] = b"Hello World!";
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
运行
Nightly Rust
Rust 语言有三个发行频道(release channel),分别是 stable、beta 和 nightly。你可以输入
rustup override add nightly
来选择在当前目录使用 nightly 版本的 Rust。
Nightly 版本的编译器允许我们在源码的开头插入特性标签(feature flag),来自由选择并使用大量实验性的功能。举个例子,要使用实验性的内联汇编(asm!宏),我们可以在 main.rs 的顶部添加 #![feature(asm)]。
为了编写我们的目标系统,并且鉴于我们需要做一些特殊的配置(比如没有依赖的底层操作系统),已经支持的目标三元组都不能满足我们的要求。幸运的是,只需使用一个 JSON 文件,Rust 便允许我们定义自己的目标系统;这个文件常被称作目标配置清单(target specification)。比如,一个描述 x86_64-unknown-linux-gnu 目标系统的配置清单大概长这样:
{
"llvm-target": "x86_64-unknown-linux-gnu",
"data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
"arch": "x86_64",
"target-endian": "little",
"target-pointer-width": 64,
"target-c-int-width": 32,
"os": "linux",
"executables": true,
"linker-flavor": "gcc",
"pre-link-args": ["-m64"],
"morestack": false
}
一个配置清单中包含多个配置项(field)。
安装
rustup component add rust-src llvm-tools-preview --toolchain nightly-x86_64-pc-windows-msvc
rustup override set nightly
然后在 .cargo/config.toml 加入
[build]
target = "osconfig.json"
[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]
让编译器可以编译core
build
cargo +nightly build --target osconfig.json
这样就build好了, 文件在 target/osconfig/debug/hello 这个东西
bootloader
cargo install bootimage
rustup component add llvm-tools-preview
然后再 Cargo.toml 加入
[dependencies]
bootloader = "0.9"
带上这个build,输入命令
cargo bootimage
qemu 启动
使用cargo run启动
在 .cargo/config.toml 加入
[target.'cfg(target_os = "none")']
runner = "bootimage runner"
Plan
阶段 1:最小化内核 (A Freestanding Executable)
你的第一个目标是创建一个能在裸机 (bare metal) 上运行的程序,它不依赖任何操作系统的库。
禁用标准库 (#![no_std]):
标准库 std 依赖于底层操作系统提供的功能(如文件、网络、内存分配)。在内核中,这些功能都不存在,所以必须禁用它。
你将使用 core 库,它是 std 的一个子集,不依赖任何 OS。
定义入口点 (_start):
常规 Rust 程序从 main 函数开始,但这是由 C 运行时环境 (crt0) 设置好的。在裸机上,你需要自己定义程序的入口点,通常命名为 _start。
你需要告诉编译器不要修改这个函数名 (#[no_mangle]),并用 extern "C" 来指定它的调用约定。
创建 Panic Handler:
当代码 panic! 时,no_std 环境下没有默认的处理函数。你需要提供一个 #[panic_handler] 函数,例如让它进入一个无限循环。
交叉编译 (Cross Compilation):
你的开发环境(例如 Windows/macOS/Linux)和目标环境(一个裸机 x86_64 系统)是不同的。你需要为目标系统进行交叉编译。
你需要创建一个目标配置文件 (target specification JSON file),定义 CPU 架构、链接器等信息。一个常见的目标三元组是 x86_64-unknown-none。
链接 (Linking):
你需要控制最终可执行文件的内存布局,这通常通过链接器脚本 (linker script) 来完成,以确保代码和数据被加载到正确的内存地址。
打包成引导镜像:
你需要一个引导加载程序 (Bootloader),如 GRUB 或 Limine,或者自己编写一个简单的。推荐使用 bootloader crate,它可以将你的内核打包成一个可启动的磁盘镜像。
成果:一个可以在 QEMU 中启动,但什么也不做的内核。这是你成功的“第一步”。
阶段 2:屏幕输出 (VGA Text Buffer)
让你的内核“说话”,最简单的方式是直接向屏幕的 VGA 文本缓冲区写入数据。
理解 VGA 文本缓冲区:
在 x86 架构上,VGA 文本缓冲区是一块位于物理内存地址 0xb8000 的特殊内存区域。
向这个地址写入数据,对应的字符和颜色就会显示在屏幕上。
实现一个安全的打印宏:
直接操作裸指针是不安全的。你可以创建一个安全的抽象,例如一个 Writer 结构体,来管理缓冲区的写入位置和光标。
实现 core::fmt::Write trait,这样你就可以使用 Rust 的格式化宏 write! 和 writeln!,像在标准库中一样方便地打印。
成果:一个可以在 QEMU 屏幕上打印出 "Hello, World!" 的内核。这是一个巨大的里程碑!
阶段 3:中断处理 (Interrupt Handling)
中断是操作系统与硬件交互以及处理异常的核心机制。
CPU 异常:
处理像“除零错误”或“缺页中断 (Page Fault)”这样的 CPU 异常。如果不安然处理它们,CPU 会进入三重错误 (Triple Fault) 并重启。
中断描述符表 (IDT):
你需要设置一个 IDT,它是一个数据结构,映射了每个中断向量到一个处理函数。
当中断发生时,CPU 会查找 IDT 并跳转到对应的处理函数。
硬件中断:
通过配置中断控制器 (如 8259 PIC 或更新的 APIC),你可以开始接收来自硬件(如键盘、定时器)的中断信号。
实现一个键盘驱动,让你可以在内核中接收按键输入。
实现一个定时器中断,这是实现多任务调度的基础。
成果:你的内核可以响应 CPU 异常(如断点 int3)和硬件事件(如键盘输入)。
阶段 4:内存管理 (Memory Management)
这是最复杂也最核心的部分之一。
分页 (Paging):
现代 CPU 使用分页机制将虚拟地址转换为物理地址。这提供了内存保护和隔离。
你需要设置页表 (Page Tables) 来映射内核所使用的虚拟内存。这是一个非常精细且容易出错的过程。x86_64 crate 提供了很好的抽象来帮助你管理页表。
堆内存分配 (Heap Allocation):
为了使用 Box、Vec、String 等需要动态分配内存的数据结构,你需要在内核中实现一个堆分配器 (heap allocator)。
你可以实现一个简单的链表分配器 (linked list allocator) 或更高效的分配算法。
成果:你的内核拥有了虚拟内存,并可以在堆上动态分配内存。
阶段 5:多任务与调度 (Concurrency)
让你的操作系统同时运行多个任务。
任务/线程:
定义一个任务(或线程)的数据结构,它需要包含 CPU 状态(寄存器)、栈等信息。
调度器 (Scheduler):
实现一个简单的调度算法,如轮询调度 (Round Robin)。
利用之前设置的定时器中断,在每次中断时进行上下文切换 (Context Switch),即保存当前任务的状态,加载下一个任务的状态。
异步/Await (Advanced):
Rust 的 async/await 语法非常适合在内核中实现协作式多任务,可以避免阻塞操作。
成果:你的内核可以在多个任务之间来回切换,实现了并发执行的假象。
阶段 6:驱动、文件系统与用户空间 (Higher-Level Concepts)
到此为止,你已经有了一个微核。接下来是扩展它的功能。
驱动程序: 为硬盘 (AHCI)、网络设备 (e1000) 等编写驱动。
文件系统: 实现一个简单的文件系统,如 FAT32 的只读支持。
系统调用 (Syscalls): 设计一套系统调用接口,这是用户程序请求内核服务的唯一方式。