Skip to content

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): 设计一套系统调用接口,这是用户程序请求内核服务的唯一方式。

用户空间 (Userspace): 这是最终目标。加载并运行一个独立于内核的可执行文件。这需要设置正确的页表、CPU 权限级别(从 Ring 0 切换到 Ring 3),并通过系统调用与内核通信。