ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

使用 Rust 编写最小 x86_64 内核:从裸机二进制到可引导的 “Hello World“(blog_os 实战指南)

使用 Rust 编写最小 x86_64 内核:从裸机二进制到可引导的 “Hello World“(blog_os 实战指南) 文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载本篇技术指南围绕开源仓库blog_osWriting an OS in Rust第二版的第二篇文章展开讲解如何把上一篇文章创建的 独立式 Rust 可执行程序 升级为一个面向 x86 架构的 64 位最小内核并将其打包成能够引导启动、在屏幕上打印 Hello World! 的磁盘映像。读完本文你将掌握自定义 Rust 编译目标target specification、使用build-std重新编译core、通过bootimage生成引导映像以及在 QEMU 虚拟机与真机上运行内核的完整流程。引导启动Boot Process当我们启动电脑时主板 [ROM] 内存储的固件会首先运行它负责加电自检power-on self-test、可用内存的检测以及 CPU 和其它硬件的预初始化。这之后固件会寻找一个可引导的存储介质并开始引导启动其中的操作系统内核。x86 架构支持两种固件标准BIOSBasic Input/Output System和UEFIUnified Extensible Firmware Interface。BIOS 标准陈旧过时但实现简单并为 1980 年代以来的所有 x86 设备所支持UEFI 则更现代化、功能更全面但开发和构建更复杂。在 blog_os 项目中目前只提供 BIOS 固件的引导支持UEFI 支持仍在计划中。BIOS 启动几乎所有的 x86 硬件系统都支持 BIOS 启动包括使用模拟 BIOS进行向后兼容的新型 UEFI 设备。这是一件好事因为上世纪与现在的硬件系统可以复用同一套引导逻辑但这种广泛兼容性同时也是 BIOS 启动最大的缺点——在引导之前CPU 必须进入 16 位的兼容模式实模式real mode以便 1980 年代古老的引导程序仍然能够工作。BIOS 启动的完整流程如下电脑启动时主板特殊闪存中存储的 BIOS 固件被加载BIOS 执行硬件自检与初始化例程随后寻找可引导的磁盘找到磁盘后控制权被转交给引导程序bootloader——一段存储在磁盘开头、长度 512 字节的可执行代码由于大多数引导程序超过 512 字节它们通常被拆分为两阶段容纳在 512 字节内的第一阶段以及由第一阶段随后加载的第二阶段。引导程序承担三项核心职责确定内核镜像在磁盘上的位置并将其加载到内存将 CPU 从 16 位实模式切换到 32 位保护模式protected mode再切换到 64 位长模式long mode此时 64 位寄存器与完整主内存才可用从 BIOS 查询特定信息如内存映射表 memory map并传递给操作系统内核。编写引导程序相当繁琐它需要汇编语言还要执行大量意图不明显的步骤例如把这个魔法值写入这个处理器寄存器。因此本系列并不讲解如何编写引导程序而是提供一个名为bootimage的工具它会自动为你的内核预先拼接一个引导程序。Multiboot 标准为了避免每个操作系统都实现只对自己有效的引导程序1995 年自由软件基金会Free Software Foundation颁布了开源的引导程序标准Multiboot。该标准定义了引导程序与操作系统之间的统一接口因此任何符合 Multiboot 的引导程序都能加载任何符合 Multiboot 的操作系统。其参考实现是 GNU GRUB它是 Linux 系统最流行的引导程序。要让内核符合 Multiboot只需在内核文件开头插入一个所谓的Multiboot 头Multiboot header这样就能非常容易地从 GRUB 引导操作系统。但 GRUB 和 Multiboot 标准也存在一些问题它们只支持 32 位保护模式引导之后仍需自行配置 CPU 切换到 64 位长模式它们被设计为简化引导程序而非内核例如内核必须以调整过的默认页大小链接否则 GRUB 无法找到 Multiboot 头传递给内核的引导信息boot information包含大量与架构相关的结构而非提供干净的抽象GRUB 与 Multiboot 标准的文档都很稀少要由内核文件生成可引导磁盘映像宿主机必须安装 GRUB这加大了在 Windows 或 macOS 上开发的难度。鉴于上述缺点blog_os 决定不使用 GRUB 或 Multiboot 标准但计划在 bootimage 工具中加入 Multiboot 支持。对编写 Multiboot 兼容内核感兴趣的读者可以查阅本系列的 第一版文档。UEFI截至本文写作时blog_os 尚未提供 UEFI 支持相关讨论见仓库历史中的 issue #349仅支持 BIOS 引导。这一点也决定了后续真机运行部分的适用范围。最小内核现在我们大致了解了电脑的启动方式是时候创建自己的最小内核了。我们的目标是创建一个磁盘映像使其在启动时向屏幕打印一行 Hello World!实现基于上一篇文章的独立式可执行程序。在上一篇文章中我们通过cargo构建了独立式二进制但根据操作系统的不同需要不同的入口点名称和编译选项——这是因为cargo默认会为宿主系统host system即当前运行环境构建。这对内核没有意义运行在 Windows 之上的内核并不是我们要的东西。我们真正需要的是为定义明确的目标系统target system进行编译。安装 Nightly RustRust 有三个发行频道stable、beta和nightly。构建操作系统需要一些仅在 nightly 频道可用的实验性功能因此必须安装 nightly 版本的 Rust。管理 Rust 安装时强烈推荐使用rustup它可以并排安装 nightly、beta 和 stable 编译器并方便地更新。有两种方式为当前目录启用 nightlyrustup override set nightly或者在项目根目录创建一个内容为nightly的rust-toolchain文件。可以通过rustc --version验证版本号末尾应包含-nightly。nightly 编译器允许我们通过文件顶部的特性标签feature flag按需启用各种实验性功能。例如在main.rs顶部添加#![feature(asm)]可以启用实验性的内联汇编asm!宏。需要注意的是这类实验性功能完全不稳定未来版本可能在不预先通知的情况下修改或移除它们因此只在绝对必要时才使用。目标规范Target Specificationcargo通过--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指定传给链接器linker的参数。我们的内核同样面向 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因为我们将在裸机bare metal无底层操作系统上运行。接下来添加与构建相关的字段linker-flavor: ld.lld, linker: rust-lld,不使用平台默认链接器它可能不支持我们的目标而是使用随 Rust 一起发布的跨平台LLD链接器来链接内核。panic-strategy: abort,该设置指定目标不支持 panic 时的栈展开stack unwinding因此程序应直接中止。这与Cargo.toml中的panic abort选项效果相同所以可以从那里移除。需要注意的是与 Cargo.toml 选项不同这一目标选项在稍后重新编译core库时同样生效——因此即使你想保留 Cargo.toml 选项也必须保留这一项。disable-redzone: true,我们在编写内核迟早要处理中断。要安全地做到这一点必须禁用一项名为红区red zone的栈指针优化否则它会造成栈损坏。红区是 System V ABI 的一项优化允许函数在不调整栈指针的情况下临时使用其栈帧下方 128 字节但当函数正使用红区时发生异常或硬件中断CPU 与异常处理器会覆盖红区中的数据而被中断的函数仍需要这些数据从而产生极难排查的诡异 bug。更深入的解释与图示见仓库中的专题文章 禁用红区Disable the Red Zone。features: -mmx,-sse,soft-float,features字段用于启用/禁用目标 CPU 特性通过前缀-号禁用mmx与sse通过前缀号启用soft-float。注意各特性之间不能有空格否则 LLVM 无法解析特性字符串。mmx和sse特性决定了对单指令多数据Single Instruction Multiple DataSIMD指令的支持这类指令常常能显著提升程序性能。然而在内核中使用庞大的 SIMD 寄存器会导致性能问题内核在继续被中断的程序之前必须把所有寄存器恢复到原始状态这意味着每次系统调用或硬件中断时都要将完整的 SIMD 状态保存到主内存。由于 SIMD 状态非常大512–1600 字节而中断可能非常频繁这些额外的保存/恢复操作会严重损害性能。因此我们对内核禁用 SIMD但不会影响运行于其上的应用程序。禁用 SIMD 的一个问题是x86_64 上浮点运算默认需要 SIMD 寄存器。为了解决这个问题我们添加soft-float特性——它用基于普通整数的软件函数模拟所有浮点运算。相关细节可参考仓库中的专题文章 禁用 SIMDDisable SIMD。整合目标规范将上述字段组合起来完整的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 }构建内核为我们的新目标编译会采用 Linux 约定ld.lld链接器风格指示 LLVM 以 GNU flavor 编译这意味着需要一个名为_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]: cant find crate for core构建失败了错误信息告诉我们编译器找不到core库。core库包含Result、Option、迭代器等基础 Rust 类型会被隐式链接到所有no_stdcrate。问题的根源在于core库随 Rust 编译器以预编译库的形式分发因此只对受支持的宿主三元组如x86_64-unknown-linux-gnu有效而对我们自定义的目标无效。要为其它目标编译代码我们必须先为这些目标重新编译core。build-std选项这正是 cargo 的build-std特性发挥作用的地方它允许按需重新编译core及其它标准库 crate而不是使用 Rust 安装中自带的预编译版本。该特性非常新且尚未完善因此被标记为 unstable仅对 nightly Rust 编译器可用。要使用该特性需要创建本地 cargo 配置文件即项目根目录.cargo/config.toml.cargo文件夹应与src文件夹同级内容如下# 在 .cargo/config.toml [unstable] build-std [core, compiler_builtins]这告诉 cargo 重新编译core与compiler_builtins库。后者是core的依赖因此必不可少。为了重新编译这些库cargo 需要访问 Rust 源码可以通过rustup component add rust-src安装。注意unstable.build-std配置键要求 Rust nightly 版本不早于 2020-07-15。设置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-corecompiler_builtins的依赖以及compiler_builtins。内存相关内置函数IntrinsicsRust 编译器假设所有系统都提供一组内置函数。其中大部分由我们刚刚重新编译的compiler_builtinscrate 提供。然而该 crate 中一些与内存相关的函数默认没有启用因为正常情况下它们由系统的 C 库提供。这些函数包括memset把内存块的所有字节设为给定值、memcpy把一块内存复制到另一块以及memcmp比较两块内存。目前编译内核还不需要它们但一旦向内核添加更多代码例如复制struct就会用到。由于无法链接操作系统的 C 库我们需要以其它方式向编译器提供这些函数。一种思路是自己实现memset等函数并加上#[no_mangle]属性避免编译时自动改名——但这是危险的因为实现中的丝毫错误都可能引发未定义行为。例如用for循环实现memcpy可能导致无限递归for循环会隐式调用 trait 方法 [IntoIterator::into_iter]而它可能再次调用memcpy。因此更好的做法是复用现有且经过充分测试的实现。幸运的是compiler_builtinscrate 已经包含全部所需函数的实现只是默认被禁用以免与 C 库的实现冲突。我们可以把 cargo 的build-std-features标志设为[compiler-builtins-mem]来启用它们。与build-std一样该标志既可以通过命令行-Z传入也可以配置在.cargo/config.toml的unstable表中。由于我们希望始终以该标志构建配置文件的方式更合适# 在 .cargo/config.toml [unstable] build-std-features [compiler-builtins-mem] build-std [core, compiler_builtins]compiler-builtins-mem特性支持是较新添加的因此至少需要 Rust nightly 2020-09-30 之后的版本。在后台该标志启用了compiler_builtinscrate 的mem特性效果是将其memcpy等实现应用#[no_mangle]属性使它们对链接器可用。经过这一改动内核具备了编译器要求的所有函数的有效实现即使代码变得更复杂也能继续编译。设置默认目标为避免每次调用cargo build都传入--target参数可以在.cargo/config.toml中覆盖默认目标# 在 .cargo/config.toml [build] target x86_64-blog_os.json这告诉 cargo当没有显式传入--target参数时使用我们的x86_64-blog_os.json目标。这意味着现在只需简单的cargo build就能为裸机目标构建内核。更多 cargo 配置选项参见官方文档。至此我们能够为裸机目标构建内核但将被引导程序调用的_start入口点仍然是个空循环。是时候让它向屏幕输出点什么了。向屏幕打印现阶段向屏幕打印文本最简单的方式是使用VGA 文本缓冲区VGA text buffer这是一段映射到 VGA 硬件的特殊内存区域存放着屏幕上显示的内容。它通常由 25 行组成每行包含 80 个字符单元character cell每个字符单元显示一个 ASCII 字符并带有前景色和背景色。我们将在下一篇文章中详细讨论 VGA 缓冲区的精确布局并为其编写第一个小型驱动程序。对于打印 Hello World!只需要知道缓冲区位于地址0xb8000每个字符单元由一个 ASCII 字节和一个颜色字节组成。实现如下static HELLO: [u8] bHello 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 {} }首先我们把整数0xb8000转换为一个裸指针raw pointer。然后遍历静态的HELLO字节字符串中的每个字节并使用enumerate方法额外获得序号i。在for循环体内我们使用offset方法写入字符串字节以及对应的颜色字节0xb是淡青色。注意所有内存写入都被一个unsafe块包裹。原因是 Rust 编译器无法证明我们创建的裸指针是有效的——它们可能指向任何地方并导致数据损坏。把它们放进unsafe块本质上是在告诉编译器我们确信这些操作是有效的。但unsafe块并不会关闭 Rust 的安全检查它只是允许你做额外的一些操作。必须强调这并不是 Rust 中做事的正确方式在unsafe块中操作裸指针很容易出错——比如稍不注意就可能写到缓冲区末尾之外。因此我们希望尽可能减少unsafe的使用。Rust 提供了通过创建安全抽象来达成这一目标的能力。例如我们可以创建一个封装了所有不安全性的 VGA 缓冲区类型确保从该类型外部不可能做错任何事。这样一来只需要极少量unsafe代码并能确信我们没有违反内存安全。下一篇文章将创建这样一个安全的 VGA 缓冲区抽象。运行内核现在我们有了一个能产生可感知输出的可执行文件是时候运行它了。首先需要将编译好的内核与引导程序链接转换为可引导的磁盘映像然后可以在 QEMU 虚拟机中运行该映像或通过 U 盘在真机上引导。创建引导映像要将编译好的内核转换为可引导磁盘映像需要把它与引导程序链接。引导程序负责初始化 CPU 并加载我们的内核。与其自己编写引导程序这本身就是一个完整项目我们使用bootloadercrate。该 crate 实现了一个没有任何 C 依赖的基础 BIOS 引导程序只有 Rust 代码与内联汇编。要使用它引导我们的内核需要在Cargo.toml中添加依赖# 在 Cargo.toml [dependencies] bootloader 0.9注意本文仅兼容bootloader v0.9版本法文版原文指定了0.9.8。更新版本使用不同的构建系统按本文步骤操作会出现构建错误。只把 bootloader 添加为依赖并不足以创建可引导磁盘映像。问题在于我们需要在编译之后把内核与 bootloader 链接但 cargo 不支持编译后脚本post-build scripts。为解决这个问题我们创建了一个名为bootimage的工具它先编译内核与引导程序然后把它们链接在一起生成可引导磁盘映像。安装该工具的命令如下cargo install bootimage要运行bootimage并构建 bootloader还需要安装 rustup 组件llvm-tools-previewrustup component add llvm-tools-preview安装bootimage并添加llvm-tools-preview组件后回到 cargo 项目目录执行 cargo bootimage可以看到该工具先通过cargo build重新编译内核因此会自动拾取你的任何改动然后编译引导程序——这可能要花一些时间。与所有 crate 依赖一样它只构建一次并缓存后续构建会快得多。最后bootimage把引导程序与内核组合成可引导磁盘映像。执行命令后你会在target/x86_64-blog_os/debug目录中看到一个名为bootimage-blog_os.bin的可引导磁盘映像。可以把它放在虚拟机中引导或复制到 U 盘在真机上引导。注意这不是 CD 映像两者格式不同刻录到 CD 上无法工作。它是如何工作的bootimage工具在后台执行以下步骤将内核编译为 [ELF] 文件把 bootloader 依赖编译为独立可执行文件将内核 ELF 文件的字节链接到 bootloader。引导时bootloader 读取并解析附加的 ELF 文件将程序段映射到页表中的虚拟地址清零.bss段并设置栈。最后它读取入口点地址我们的_start函数并跳转过去。在 QEMU 中启动现在可以在虚拟机中引导磁盘映像。在QEMU中启动的命令如下 qemu-system-x86_64 -drive formatraw,filetarget/x86_64-blog_os/debug/bootimage-blog_os.bin warning: TCG doesnt support requested feature: CPUID.01H:ECX.vmx [bit 5]在部分环境可能出现一条关于 TCG 不支持 VMX 特性的警告可忽略。这会打开一个独立窗口显示效果如下可以看到Hello World! 已经显示在屏幕上——我们的第一个内核成功运行了。在真机运行也可以把映像写入 U 盘并在真机上引导但务必小心选择正确的设备名因为该设备上的所有数据都会被覆盖 dd iftarget/x86_64-blog_os/debug/bootimage-blog_os.bin of/dev/sdX sync其中sdX是你的 U 盘设备名。写入完成后可以通过从 U 盘引导在真机上运行。你可能需要使用特殊的引导菜单或在 BIOS 配置中更改引导顺序。注意由于bootloadercrate 目前还不支持 UEFI这在 UEFI 机器上无法工作。使用cargo run为了让内核在 QEMU 中运行更方便可以为 cargo 设置runner配置键# 在 .cargo/config.toml [target.cfg(target_os none)] runner bootimage runnertarget.cfg(target_os none)表适用于所有目标配置文件中os字段为none的目标——这包含我们的x86_64-blog_os.json目标。runner键指定cargo run应调用的命令该命令在成功构建后执行并把可执行文件路径作为第一个参数传入。bootimage runner命令专门设计为可充当runner可执行文件它将给定的可执行文件与项目的 bootloader 依赖链接然后启动 QEMU。现在我们可以用cargo run编译内核并在 QEMU 中启动它。下一步在下一篇文章中我们将更细致地探索 VGA 文本缓冲区为它编写安全的接口并添加println!宏的支持。这一系列后续文章VGA 文本模式、测试、CPU 异常、双重故障、硬件中断、分页、堆分配、异步等均收录于仓库 README.md 的章节列表中代码分别存放于各post-XX分支。仓库参考索引本文涉及的源码与文档路径目标规范示例与完整内核代码对应的文章edition-2/posts/02-minimal-rust-kernel前一篇独立式 Rust 可执行程序专题文章禁用红区专题文章禁用 SIMD第一版系列文章索引cargo 配置文件参考blog/config.toml赞分享文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载相关推荐用 Rust 编写最小 x86_64 内核从裸机目标到 QEMU 里的 Hello Worldblog_os 实战指南用 Rust 编写最小 x86_64 内核从裸机目标到 QEMU 里的 Hello Worldblog_os 实战指南 本指南以 blog_os 项目《A文档教程技术博客操作系统gin-vue-admin 用户长期偏好记忆设计基于 AGENT.MD aiDoc 单一真源的分层文档架构gin vue admin 用户长期偏好记忆设计基于 AGENT.MD aiDoc 单一真源的分层文档架构 aiDoc/memory/long term/文档教程技术博客操作系统用树莓派写裸机操作系统Lesson 1 详解与“Hello, World”内核实战用树莓派写裸机操作系统Lesson 1 详解与“Hello, World”内核实战 本文对应本仓库教程第 1 课第 1.1 节 韩文原文档 https://示例工程上一篇【有手就能部署】Pixtral-12B多模态模型本地推理全流程从0到1跑通图文交互下一篇DeepEval LLM 评估指南如何从 0 完成一次大模型评测创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表