- 文档
- 教程
- 技术博客
- 操作系统
【免费下载链接】blog_os
Writing an OS in Rust
本篇技术指南聚焦于 Rust 操作系统开发项目 blog_os("Writing an OS in Rust")系列的第二篇教程,讲解如何基于上一篇的"独立式 Rust 可执行程序"(freestanding binary),构建一个能够真实引导启动、并向屏幕打印 "Hello World!" 的最小 64 位内核。读完本文,你将掌握自定义裸机目标平台的 JSON 规范编写、通过build-std为裸机重编译core库、利用bootimage工具将内核与引导程序链接成磁盘映像,以及用 QEMU 和真机 USB 启动内核的完整链路。
本文对应的原始教程文档位于 blog/content/edition-2/posts/02-minimal-rust-kernel/index.md(本仓库同时收录了俄文、简体中文等多语言翻译版本),完整源码托管在仓库的post-02分支。在动手之前,建议先阅读同系列的 《独立式 Rust 可执行程序》 掌握no_std、no_main、#[panic_handler]与_start入口点的基础。
计算机的启动过程:固件、BIOS 与引导程序
当我们按下电源开关,主板 [ROM](只读存储器)中存储的固件代码开始执行:它会运行加电自检(Power-On Self-Test),检测可用内存,并预初始化 CPU 与硬件,随后寻找可引导的磁盘并开始引导操作系统内核。
在 x86 架构上存在两种固件标准:
- BIOS(Basic Input/Output System,基本输入输出系统):标准古老,但实现简单,自 1980 年代以来几乎所有 x86 机器都支持;
- UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口):更现代、功能更多,但配置更复杂。
本教程当前只提供 BIOS 支持,UEFI 支持已在计划之中(相关讨论见仓库历史 issue #349)。
BIOS 启动:从 16 位实模式到 64 位长模式
几乎所有 x86 系统都支持 BIOS 启动,包括使用模拟 BIOS 的现代 UEFI 机器——这意味着同一套启动逻辑可以覆盖数十年的硬件。但宽泛兼容性也是 BIOS 最大的缺点:启动前 CPU 必须进入 16 位的兼容模式(实模式,real mode),以便 1980 年代的老式引导程序仍能运行。
完整的 BIOS 启动流程如下:
- 电脑开机后,从主板上的特殊闪存加载 BIOS 固件;
- BIOS 执行硬件自检与初始化例程,然后寻找可引导磁盘;
- 找到后,控制权转交给磁盘开头的 512 字节可执行代码——引导程序(bootloader);
- 大多数引导程序超过 512 字节,因此通常拆分为:能塞进 512 字节的第一阶段,以及由第一阶段随后加载的第二阶段。
引导程序承担三项核心职责:
- 确定内核镜像在磁盘上的位置,并将其加载进内存;
- 将 CPU 从 16 位实模式切换到 32 位保护模式(protected mode),再切换到 64 位长模式(long mode)——只有在长模式下才能访问全部 64 位寄存器与完整主内存;
- 向 BIOS 查询特定信息(如内存映射表 memory map)并传递给内核。
编写引导程序颇为繁琐:需要汇编语言,还要做大量"把这个魔数写进那个寄存器"之类的无趣工作。因此本教程不讲解引导程序的实现,而是使用bootimage工具,它能把引导程序自动附加到你的内核前面。
Multiboot 标准及其局限
为避免每个操作系统都实现只兼容自家内核的引导程序,自由软件基金会(FSF)于 1995 年制定了开放标准Multiboot,定义了引导程序与操作系统之间的统一接口,使任何符合 Multiboot 的引导程序都能加载任何符合 Multiboot 的操作系统。其参考实现是 Linux 系统上最流行的引导程序GNU GRUB。
让内核兼容 Multiboot 只需在文件开头插入一个 Multiboot 头。但 GRUB 与 Multiboot 标准存在若干问题:
- 仅支持 32 位保护模式,切换到 64 位长模式的 CPU 配置仍需自行完成;
- 设计目标是简化引导程序而非内核:例如内核必须以调整过的默认页大小链接,否则 GRUB 找不到 Multiboot 头;传递给内核的引导信息也充满架构相关的结构体,而非干净的抽象;
- GRUB 与 Multiboot 标准文档稀少;
- 创建可引导磁盘映像要求在宿主机安装 GRUB,使 Windows 或 macOS 上的开发更困难。
鉴于上述缺点,本系列决定不使用 GRUB 或 Multiboot 标准(但计划在 bootimage 工具中增加 Multiboot 支持;若想编写 Multiboot 兼容内核,可参考仓库中的 第一版(edition-1)教程)。
UEFI 现状
截至目前,本系列暂未提供 UEFI 支持,处于计划阶段。在 UEFI 上的引导加载需要在启动环境中调用 UEFI 固件提供的协议与服务,与 BIOS 的实模式加载路径完全不同,需要单独的引导程序实现。
最小内核:从宿主系统到目标系统
现在,我们对启动机制有了整体认识,目标明确:创建一个启动后在屏幕上打印 "Hello World!" 的磁盘映像。实现建立在上一篇的独立式 Rust 可执行程序之上。
上一篇中,我们用cargo构建独立式二进制,但入口点名称与编译标志随操作系统而异——因为cargo默认针对宿主系统(你正在运行的系统)编译。对内核来说这毫无意义:运行在 Windows 之上的内核算什么内核?我们需要的,是面向一个明确定义的目标系统(target system)编译。
安装 Rust Nightly
Rust 有三个发布通道:stable、beta、nightly。构建操作系统需要一些仅 nightly 提供的实验性功能,因此必须安装 nightly 版本。
推荐使用 [rustup] 管理 Rust 安装:它可以并排安装 nightly、beta、stable 编译器,并简化升级。有两种方式让当前目录使用 nightly:
rustup override set nightly或者在项目根目录创建内容为nightly的rust-toolchain文件。可通过rustc --version验证:版本号末尾应包含-nightly。
nightly 编译器允许通过源码顶部的特性标签(feature flag)开启实验功能,例如在main.rs顶部写#![feature(asm)]即可启用实验性的内联汇编asm!宏。需要强调的是,这些实验特性完全不稳定,未来版本可能随时修改或移除,因此只应在绝对必要时使用。
自定义目标平台规范(Target Specification)
cargo通过--target参数支持不同的目标系统,目标由目标三元组(target triple)描述:CPU 架构、厂商、操作系统与 ABI。例如x86_64-unknown-linux-gnu表示 x86_64 CPU、厂商未知、Linux 系统、GNU ABI。Rust 支持大量目标三元组,如 Android 的arm-linux-androideabi、WebAssembly 的wasm32-unknown-unknown。
但我们的目标系统需要特殊配置(例如没有底层操作系统),现成的目标三元组都不满足。幸运的是,Rust 允许通过 JSON 文件自定义目标。作为参考,描述x86_64-unknown-linux-gnu的 JSON 长这样:
{ "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 }字段分三类:大部分是 LLVM 生成该平台代码所必需的(如data-layout定义了各种整数、浮点数与指针类型的大小);有些供 Rust 条件编译使用(如target-pointer-width);还有些定义 crate 如何构建(如pre-link-args指定传给链接器的参数)。
内核同样面向 x86_64,因此规范与上面非常相似。创建x86_64-blog_os.json(文件名可自选),先写入公共内容:
{ "llvm-target": "x86_64-unknown-none", "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": "none", "executables": true }注意:llvm-target与os都改成了none,因为我们运行在裸机上。接着逐项添加针对内核构建的配置:
"linker-flavor": "ld.lld", "linker": "rust-lld",平台默认链接器可能不支持目标平台,这里改用随 Rust 一起分发的跨平台链接器LLD。
"panic-strategy": "abort",该设置声明目标不支持 panic 时的栈展开(stack unwinding),panic 时应直接中止。这与Cargo.toml中panic = "abort"效果相同,因此可以从 Cargo.toml 移除该选项。注意:与 Cargo.toml 选项不同,这个目标级选项在稍后重新编译core库时同样生效——即使你保留 Cargo.toml 选项,也务必在目标规范中包含它。
"disable-redzone": true,我们编写内核,迟早要处理中断。为了安全地处理中断,必须禁用一种叫红区(red zone)的栈指针优化,否则会导致栈损坏。红区是 System V ABI 允许函数在不调整栈指针的情况下临时使用的栈帧下方 128 字节区域;一旦异常或硬件中断发生时 CPU 与异常处理程序覆盖了这块数据,被中断的函数将无法正确恢复执行,可能引发极难排查的诡异 bug。详细原理见仓库中的 《禁用红区》短文。
"features": "-mmx,-sse,+soft-float",features字段启用/禁用目标 CPU 特性:前缀-禁用,前缀+启用。注意各标志之间不能有空格,否则 LLVM 无法解析该字符串。
mmx与sse决定单指令多数据(SIMD)指令的支持程度。SIMD 虽能显著加速程序,但大型 SIMD 寄存器在内核中会引发性能问题:内核必须在继续执行被中断的程序前恢复所有寄存器,这意味着每次系统调用或硬件中断都要把完整 SIMD 状态(512~1600 字节)保存到主内存。由于中断可能非常频繁,这些额外存取会严重损害性能。因此我们对内核禁用 SIMD(注意:不针对运行在内核之上的应用程序)。详细背景可阅读仓库中的 《禁用 SIMD》短文。
禁用 SIMD 带来一个新问题:x86_64 上浮点运算默认依赖 SIMD 寄存器。解决办法是启用soft-float特性,它用基于普通整数的软件函数模拟全部浮点运算。
完整的目标规范
把所有配置项整合起来,最终的x86_64-blog_os.json如下:
{ "llvm-target": "x86_64-unknown-none", "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": "none", "executables": true, "linker-flavor": "ld.lld", "linker": "rust-lld", "panic-strategy": "abort", "disable-redzone": true, "features": "-mmx,-sse,+soft-float" }编译内核:入口点与core库的缺失
针对新目标编译时,因为ld.lld链接器风格指示 LLVM 以-flavor gnu方式编译,会遵循 Linux 约定——需要名为_start的入口点(正如上一篇所述)。src/main.rs的最小骨架:
// src/main.rs #![no_std] // 不链接 Rust 标准库 #![no_main] // 禁用所有 Rust 层级的入口点 use core::panic::PanicInfo; /// 这个函数将在 panic 时被调用 #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } #[no_mangle] // 不重整函数名 pub extern "C" fn _start() -> ! { // 链接器默认寻找名为 `_start` 的函数,因此这就是入口点 loop {} }无论宿主操作系统是什么,入口点都必须叫_start。现在把 JSON 文件名作为--target传入编译:
> cargo build --target x86_64-blog_os.json error[E0463]: can't find crate for `core`编译失败了!错误说明 Rust 编译器找不到core库。core包含Result、Option、迭代器等基础类型,会被隐式链接到所有no_stdcrate。
原因在于:core库是随 Rust 编译器以预编译形式分发的,只对受支持的主机三元组(如x86_64-unknown-linux-gnu)有效,对我们的自定义目标无效。要为其他目标编译代码,必须先为这些目标重新编译core。
build-std:按需重编译标准库
cargo 的build-std特性恰好解决此问题:它允许按需重编译core及其他标准库 crate,而不是使用随 Rust 安装分发的预编译版本。该特性很新、尚未完工,被标记为 "unstable",仅 nightly Rust 可用。
启用方式是在.cargo/config.toml(.cargo目录与src同级)写入:
# in .cargo/config.toml [unstable] build-std = ["core", "compiler_builtins"]这指示 cargo 重编译core与compiler_builtins——后者是core的依赖,必不可少。重编译需要 Rust 源码,安装方式:
rustup component add rust-src注意:
unstable.build-std配置键要求至少 2020-07-15 之后的 Rust nightly 版本。
配置好unstable.build-std并安装rust-src组件后,重新构建:
> cargo build --target x86_64-blog_os.json Compiling core v0.0.0 (/…/rust/src/libcore) Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core) Compiling compiler_builtins v0.1.32 Compiling blog_os v0.1.0 (/…/blog_os) Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs可以看到cargo build现在为自定义目标重编译了core、rustc-std-workspace-core(compiler_builtins的依赖)与compiler_builtins三个库。
内存相关内置函数(Memory Intrinsics)
Rust 编译器假定所有系统都提供一组内建函数,大部分由刚重编译的compiler_builtins提供。但其中一些内存相关函数默认未启用,因为它们通常由系统 C 库提供:
memset:把内存块中所有字节设为给定值;memcpy:把一个内存块复制到另一个;memcmp:比较两个内存块。
虽然编译当前内核还用不到它们,但只要代码一复杂起来(例如复制结构体)就会需要。由于无法链接宿主操作系统的 C 库,必须另寻出路。
一个直观方案是自行实现这些函数并加#[no_mangle]属性(避免编译期自动改名)。但这很危险:底层函数任何微小的错误都可能引发未定义行为。例如用for循环实现memcpy可能造成无限递归——for循环会隐式调用IntoIterator::into_itertrait 方法,而它可能再次调用memcpy。因此复用现成的、经过良好测试的实现才是正解。
幸运的是compiler_builtins已包含全部所需实现,只是默认禁用,以免与 C 库实现冲突。启用方式是设置 cargo 的build-std-features标志为["compiler-builtins-mem"]。它既可像build-std一样以-Z标志传参,也可配置在.cargo/config.toml的unstable表中。因为我们始终需要它,配置文件方式更合理:
# in .cargo/config.toml [unstable] build-std-features = ["compiler-builtins-mem"] build-std = ["core", "compiler_builtins"](compiler-builtins-mem特性支持是近期才加入的,因此至少需要 2020-09-30 之后的 Rust nightly 版本。)
该标志在幕后为compiler_builtinscrate 启用了mem特性,效果是让其中的memcpy等实现带上#[no_mangle]属性,从而对链接器可见。经过这一步,内核拥有了编译器要求的全部函数实现,即使代码变复杂也能继续编译。
设置默认编译目标
每次cargo build都传--target很繁琐,可以在.cargo/config.toml中覆盖默认目标:
# in .cargo/config.toml [build] target = "x86_64-blog_os.json"有了这份配置,当没有显式传入--target时,cargo 会自动使用x86_64-blog_os.json——现在一条cargo build就能为裸机编译内核。
向屏幕输出:VGA 文本缓冲区
现在内核可以编译了,但_start入口点(将来会被引导程序调用)仍是空循环。是时候让它输出点什么了。
现阶段打印文本最直接的方式是VGA 文本缓冲区:一块映射到 VGA 硬件的特殊内存区域,其内容就是屏幕显示的内容。它通常包含 25 行、每行 80 个字符单元;每个字符单元显示一个 ASCII 字符,并带有前景色与背景色。(缓冲区精确的内存布局将在下一篇《VGA 文本模式》中讨论,那里我们会编写第一个小型驱动。)
打印 "Hello World!" 只需知道两点:缓冲区位于地址0xb8000,每个字符单元由一个 ASCII 字节加一个颜色字节组成。实现如下:
static HELLO: &[u8] = b"Hello World!"; #[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 {} }代码要点:
- 定义静态字节字符串
HELLO; - 将整数
0xb8000转换为裸指针(raw pointer); - 用
enumerate迭代HELLO的每个字节,同时取得序号i; - 在循环体内用
offset偏移裸指针并解引用:偶数偏移写字符串字节,奇数偏移写颜色字节(0xb是淡青色)。
注意所有内存写入都被unsafe块包围。原因很简单:编译器无法证明我们创建的裸指针有效——它们可能指向任何地方、导致数据损坏。放进unsafe块,等于向编译器声明"我确信这些操作是合法的"。需要澄清:unsafe块并没有关闭 Rust 的安全检查,它只是额外允许你做少数几件特定的事情(如解引用裸指针)。
必须强调:这不是 Rust 推荐的做法!在unsafe块内操作裸指针极易出错——稍不留意就可能写出缓冲区末尾。因此应尽可能最小化unsafe的使用。Rust 的做法是创建安全抽象:例如设计一个封装全部不安全操作的 VGA 缓冲区类型,从外部保证"不可能做错任何事"。这样只需要最小量的unsafe代码,并能确信没有违反内存安全。下一篇文章就会创建这样一个安全的 VGA 缓冲区抽象。
运行内核:从 ELF 到可引导磁盘映像
内核已能产生可见输出,是时候运行它了。第一步,把编译好的内核与引导程序链接成可引导磁盘映像;第二步,在 QEMU 虚拟机中运行,或用 USB 在真机上启动。
创建可引导映像
如前所述,引导程序负责初始化 CPU 并加载内核。我们不自己编写引导程序(那本身就是个独立项目),而是使用bootloadercrate——它用纯 Rust 与内联汇编实现了一个基础 BIOS 引导程序,没有任何 C 依赖。在Cargo.toml添加依赖:
# in Cargo.toml [dependencies] bootloader = "0.9"注意:本教程仅兼容
bootloader v0.9。更新版本采用了不同的构建系统,照本教程操作会引发构建错误。
仅添加依赖还不够:我们需要在编译完成后把内核与引导程序链接起来,而 cargo 原生不支持构建后脚本(见相关 issue #545)。
为此,本系列提供了bootimage工具:它先编译内核与引导程序,再把二者链接成可引导磁盘映像。安装命令(建议在 cargo 项目目录之外执行):
cargo install bootimage运行bootimage并编译引导程序需要 rustup 组件llvm-tools-preview:
rustup component add llvm-tools-preview安装完毕后,回到项目目录执行:
> cargo bootimage可以看到该工具先用cargo build重编译我们的内核,因此会自动拾取你做的任何修改;随后编译引导程序(可能较慢),但与所有 crate 依赖一样只编译一次并被缓存,后续构建会快得多;最后bootimage把引导程序与内核组合成可引导磁盘映像。
执行完成后,target/x86_64-blog_os/debug目录下会出现bootimage-blog_os.bin。可以把它放进虚拟机启动,或复制到 U 盘在真机上启动。(注意:这不是 CD 映像,格式不同,刻录到光盘不起作用。)
bootimage 在幕后做了什么?
bootimage工具实际执行三个步骤:
- 把内核编译成 ELF 文件;
- 把
bootloader依赖编译成独立可执行文件; - 把内核 ELF 文件的字节拼接到引导程序之后。
启动时,引导程序读取并解析拼接在后的 ELF 文件:将程序段映射到页表中的虚拟地址,清零.bss段,建立栈;最后读取入口点地址(即我们的_start函数)并跳转过去。
在 QEMU 中启动
在 QEMU 虚拟机中引导磁盘映像:
> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]会弹出一个独立窗口:
屏幕左上角显示青蓝色的 "Hello World!"——我们的最小内核成功运行了。
在真机上运行
也可以把映像写入 U 盘在真机启动:
> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync其中sdX是你的 U 盘设备名。务必仔细核对设备名,因为该设备上的所有数据都会被覆盖!写入后用特殊启动菜单或在 BIOS 配置中调整启动顺序,从 U 盘引导即可。注意:由于bootloadercrate 尚无 UEFI 支持,目前在 UEFI 机器上无法启动。
使用cargo run一键启动
为简化在 QEMU 中的运行,可给 cargo 设置runner配置键:
# in .cargo/config.toml [target.'cfg(target_os = "none")'] runner = "bootimage runner"target.'cfg(target_os = "none")'表适用于所有目标配置文件"os"字段为"none"的目标——包括我们的x86_64-blog_os.json。runner键指定cargo run应调用的命令:成功构建后,该命令会以可执行文件路径作为第一个参数被调用。
bootimage runner专为runner角色设计:它把给定可执行文件与项目的引导程序依赖链接,然后启动 QEMU。至此,cargo run一条命令即可编译内核并在 QEMU 中启动。
下一步
下一篇将深入探讨 VGA 文本缓冲区,为它编写安全的 Rust 接口,并添加println!宏支持。在继续之前,可以回顾仓库中的相关支撑材料:
- 第一篇:独立式 Rust 可执行程序——
no_std、#[panic_handler]、_start入口点与链接器错误处理; - 禁用红区详解——为什么内核必须关闭红区优化;
- 禁用 SIMD 详解——MMX/SSE/AVX 与
soft-float的取舍; - 本系列整体说明见仓库根目录 README.md,每篇教程的完整源码对应
post-01、post-02等独立分支。
- 文档
- 教程
- 技术博客
- 操作系统
【免费下载链接】blog_os
Writing an OS in Rust
相关推荐
用 Rust 编写最小 x86_64 内核:从裸机目标到 QEMU 里的 Hello World(blog_os 实战指南)
用 Rust 编写最小 x86_64 内核:从裸机目标到 QEMU 里的 Hello World(blog_os 实战指南) 本指南以 blog_os 项目《A
文档教程技术博客操作系统styled-components createGlobalStyle 多实例去重与跨环境字节稳定:SSR、客户端与 Server Components 的统一输出语义
styled components createGlobalStyle 多实例去重与跨环境字节稳定:SSR、客户端与 Server Components 的统一
文档教程技术博客操作系统从零编写操作系统:blog_os 系列之「Multiboot 2 最小内核」完整实战
从零编写操作系统:blog_os 系列之「Multiboot 2 最小内核」完整实战 本篇是开源项目 Writing an OS in Rust (仓库 blo
文档教程技术博客操作系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考