使用Rust编写操作系统,屏蔽掉过于底层的硬件细节和汇编语言,这有助于将主要精力放在内存管理、进程管理、文件系统等核心模块。
对操作系统启动之前的主引导,跳入保护模式,加载内核等过程有兴趣的可以参考reduced-os(已封存)
要编写一个操作系统内核,我们的代码应当不基于任何的操作系统特性。这意味着我们不能使用线程、文件、堆内存、网络、随机数、标准输出,或其它任何需要特定硬件和操作系统抽象的特性。
这意味着我们不能使用Rust标准库的大部分。
#![no_std] // 禁用标准库链接
在no_std
环境中,我们需要定义自己的panic处理函数。
禁用栈展开
一个典型的使用标准库的Rust程序,它的运行将从名为crt0
的运行时库开始。crt0
意为C runtime zero,它能建立一个适合运行C语言程序的环境,这包含了栈的创建和可执行程序参数的传入。这之后,这个运行时库会调用Rust的运行时入口点,这个入口点被称作start语言项("start" language item)。
这之后,运行时将会调用main函数。
我们的独立式可执行程序并不能访问Rust运行时或crt0
库,所以我们需要定义自己的入口点。
链接器的默认配置假定程序依赖于C语言的运行时环境,但我们的程序并不依赖于它。
为了解决这个错误,我们需要告诉链接器,它不应该包含(include)C语言运行环境。
-
可以提供特定的链接器参数(linker argument),
-
也可以选择编译为裸机目标(bare metal target),即底层没有操作系统的运行环境。
# 安装thumbv7em-none-eabihf裸机环境,一个ARM嵌入式系统 rustup target add thumbv7em-none-eabihf # 编译 cargo build --target thumbv7em-none-eabihf
我们将向显示器打印字符串,最终打包内核为能引导启动的磁盘映像(disk image)
- BIOS启动
- Multiboot标准
在默认情况下,cargo
会为特定的宿主系统(host system)构建源码,比如为你正在运行的系统构建源码。
这并不是我们想要的,因为我们的内核不应该基于另一个操作系统——我们想要编写的,就是这个操作系统。
确切地说,我们想要的是,编译为一个特定的目标系统(target system)
Rust语言有三个发行频道(release channel),分别是stable、beta和nightly.
# 使用rustup安装nightly版本
rustup override add nightly
# 查看rust版本
rustc --version
目标配置清单:只需使用一个JSON文件,Rust便允许我们定义自己的目标系统。
# 安装Cargo xbuild
cargo install cargo-xbuild
# 编译
cargo xbuild --target x86_64-os_626.json
设置默认目标,避免每次使用cargo xbuild
传递参数
# in .cargo/config
[build]
target = "x86_64-os_626.json"
我们预先定义了一个字节串(byte string)类型的静态变量(static variable),名为HELLO
。
我们首先将整数0xb8000
转换(cast)为一个裸指针(raw pointer)。
这之后,我们迭代HELLO
的每个字节,使用enumerate获得一个额外的序号变量i
。
在for
语句的循环体中,我们使用offset偏移裸指针,解引用它,来将字符串的每个字节和对应的颜色字节——0xb
代表淡青色——写入内存位置。
-
引入bootloader包
-
安装bootimage工具
-
编译镜像
# 编译镜像
cargo bootimage
1. 编译我们的内核为一个ELF(Executable and Linkable Format)文件;
2. 编译引导程序为独立的可执行文件;
3. 将内核ELF文件按字节拼接(append by bytes)到引导程序的末端。
-
在QEMU中启动内核
当机器启动时,引导程序将会读取并解析拼接在其后的ELF文件。这之后,它将把程序片段映射到分页表(page table)中的虚拟地址(virtual address),清零BSS段(BSS segment),还将创建一个栈。最终它将读取入口点地址(entry point address)——我们程序中
_start
函数的位置——并跳转到这个位置。qemu-system-x86_64 -drive format=raw,file=bootimage-blog_os.bin
-
使用cargo run
#在cargo配置文件中设置`runner`配置项 # in .cargo/config [target.'cfg(target_os = "none")'] runner = "bootimage runner" # 启动 cargo xrun
VGA字符缓冲区是一个25行、80列的二维数组,它的内容将被实时渲染到屏幕。这个数组的元素被称作字符单元(character cell)
Bit(s) | Value |
---|---|
0-7 | ASCII code point |
8-11 | Foreground color |
12-14 | Background color |
15 | Blink |
# 引入vga_buffer模块
// in src/main.rs
mod vga_buffer;
# 这个函数首先创建一个指向0xb8000地址VGA缓冲区的Writer。
# 实现这一点,我们需要编写的代码可能看起来有点奇怪:
# 首先,我们把整数0xb8000强制转换为一个可变的裸指针(raw pointer);
# 之后,通过运算符*,我们将这个裸指针解引用;最后,我们再通过&mut,再次获得它的可变借用。
# 这些转换需要**unsafe语句块**(unsafe block),因为编译器并不能保证这个裸指针是有效的。
let mut writer = Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
};
支持Rust提供的格式化宏(formatting macros)也是一个相当棒的主意。
通过这种途径,我们可以轻松地打印不同类型的变量,如整数或浮点数。
为了支持它们,我们需要实现core::fmt::Write
trait;
要实现它,唯一需要提供的方法是write_str
,它和我们先前编写的write_string
方法差别不大,只是返回值类型变成了fmt::Result
:
Rust有一个内置的测试框架(built-in test framework):无需任何设置就可以进行单元测试。
不幸的是,Rust的测试框架会隐式的调用内置的test
库。
这也就是说我们的 #[no_std]
内核无法使用默认的测试框架
Rust支持通过使用不稳定的自定义测试框架(custom_test_frameworks
) 功能来替换默认的测试框架。该功能不需要额外的库,因此在 #[no_std]
环境中它也可以工作。
QEMU支持一种名为 isa-debug-exit
的特殊设备,它提供了一种从客户系统(guest system)里退出QEMU的简单方式。为了使用这个设备,我们需要向QEMU传递一个-device
参数。
要在控制台上查看测试输出,我们需要以某种方式将数据从内核发送到宿主系统。
发送数据的一个简单的方式是通过串行端口,为了查看QEMU的串行输出,我们需要使用-serial
参数将输出重定向到stdout。
在Rust中,集成测试(integration tests)的约定是将其放到项目根目录中的tests
目录下(即src
的同级目录)。无论是默认测试框架还是自定义测试框架都将自动获取并执行该目录下所有的测试。
下面是一些对于未来的测试的设想:
- CPU异常:当代码执行无效操作(例如除以零)时,CPU就会抛出异常。内核会为这些异常注册处理函数。集成测试可以验证在CPU异常时是否调用了正确的异常处理程序,或者在可解析的异常之后程序是否能正确执行;
- 页表:页表定义了哪些内存区域是有效且可访问的。通过修改页表,可以重新分配新的内存区域,例如,当你启动一个软件的时候。我们可以在集成测试中调整
_start
函数中的一些页表项,并确认这些改动是否会对#[test_case]
的函数产生影响; - 用户空间程序:用户空间程序是只能访问有限的系统资源的程序。例如,他们无法访问内核数据结构或是其他应用程序的内存。集成测试可以启动执行禁止操作的用户空间程序验证认内核是否会将这些操作全都阻止。
# 运行should_panic.rs中的测试
cargo xtest --test should_panic
-
感谢 @luojia65同学由blog_os项目译制的 使用Rust编写操作系统 一书。
-
感谢 rCore-Tutorial V3 文档的编写者。