目录
最小内核Feb10,2018
翻译内容:这是对原文章AMinimalRustKernel的社区英文翻译。它可能不完整,过时或则包含错误。可以在这个Issue上评论和提问!
翻译者:@luojia65,@Rustin-Liu和@liuyuran.Withcontributionsfrom@JiangengDong.
在这篇文章中,我们将基于x86构架(thex86architecture),使用Rust语言,编撰一个最小化的64位内核。我们将从上一章中建立的独立式可执行程序开始,建立自己的内核;它将向显示器复印字符串,并能被打包为一个才能引导启动的c盘映像(diskimage)。
此博客在GitHub上公开开发.倘若您有任何问题或疑惑,请在此处打开一个issue。您也可以在发表评论.这篇文章的完整源代码可以在[post-02]postbranch分支中找到。
目录引导启动
当我们启动笔记本时,显卡ROM显存储的固件(firmware)将会运行:它将负责笔记本的加电自检(power-onselftest),可用显存(availableRAM)的测量,以及CPU和其它硬件的预加载。这以后,它将找寻一个可引导的储存介质(bootabledisk),并开始引导启动其中的内核(kernel)。
x86构架支持两种固件标准:BIOS(BasicInput/OutputSystem)和UEFI(UnifiedExtensibleFirmwareInterface)。其中,BIOS标准变得陈旧而过时,但实现简单,并为1980年代后的所有x86设备所支持;相反地,UEFI更现代化,功能也更全面,但开发和建立更复杂(起码从我的角度看是这么)。
在这篇文章中,我们暂时只提供BIOS固件的引导启动方法,并且UEFI支持也早已在计划中了。假如你希望帮助我们推动它,请查阅这份Githubissue。
BIOS启动
几乎所有的x86硬件系统都支持BIOS启动,这也包含新型的、基于UEFI、用模拟BIOS(emulatedBIOS)的形式向后兼容的硬件系统。这可以说是一件好事情,由于无论是上世纪还是现今的硬件系统,你都只需编撰同样的引导启动逻辑;但这些兼容性有时也是BIOS引导启动最大的缺点,由于这意味着在系统启动前,你的CPU必须先步入一个16位系统兼容的实模式(realmode),这样1980年代古老的引导固件才才能继续使用。
让我们从头开始,理解一遍BIOS启动的过程。
当笔记本启动时,显卡上特殊的闪存中储存的BIOS固件将被加载。BIOS固件将会加电自检、初始化硬件,之后它将找寻一个可引导的储存介质。假如找到了,那笔记本的控制权将被转交给引导程序(bootloader):一段储存在储存介质的开头的、512字节宽度的程序片断。大多数的引导程序厚度都小于512字节——所以一般情况下,引导程序都被切分为一段优先启动、长度不超过512字节、存储在介质开头的第一阶段引导程序(firststagebootloader),和一段随即由其加载的、长度可能较长、存储在其它位置的第二阶段引导程序(secondstagebootloader)。
引导程序必须决定内核的位置,并将内核加载到显存。引导程序还须要将CPU从16位的实模式,先切换到32位的保护模式(protectedmode),最终切换到64位的长模式(longmode):此时,所有的64位寄存器和整个主显存(mainmemory)能够被访问。引导程序的第三个作用,是从BIOS查询特定的信息,并将其传递到内核;如查询和传递显存映射表(memorymap)。
编撰一个引导程序并不是一个简单的任务,由于这须要使用汇编语言,但是必须经过许多意图并不显著的步骤——比如,把一些魔术数字(magicnumber)写入某个寄存器。因而,我们不会讲解怎样编撰自己的引导程序,而是推荐bootimage工具——它还能手动而且便捷地为你的内核打算一个引导程序。
Multiboot标准
每位操作系统都实现自己的引导程序,而这只对单个操作系统有效。为了防止这样的窘境,1995年,自由软件基金会(FreeSoftwareFoundation)施行了一个开源的引导程序标准——Multiboot。这个标准定义了引导程序和操作系统间的统一插口,所以任何适配Multiboot的引导程序,都能拿来加载任何同样适配了Multiboot的操作系统。GNUGRUB是一个可供参考的Multiboot实现,它也是最热门的Linux系统引导程序之一。
要编撰一款适配Multiboot的内核,我们只须要在内核文件开头,插入被叫做Multiboot头()的数据片断。这让GRUB很容易引导任何操作系统,并且,GRUB和Multiboot标准也有一些可预知的问题:
它们只支持32位的保护模式。这意味着,在引导以后,你仍然须要配置你的CPUlinux最小系统构建,让它切换到64位的长模式;它们被设计为精简引导程序,而不是精简内核。举个反例,内核须要以调整过的默认页宽度()被链接,否则GRUB将难以找到内核的Multiboot头。另一个反例是引导信息(),这个包含着大量与构架有关的数据,会在引导启动时,被直接传到操作系统,而不会经过一层清晰的具象;GRUB和Multiboot标准并没有被详尽地解释,阅读相关文档须要一定经验;为了创建一个才能被引导的c盘映像,我们在开发时必须安装GRUB:这加强了基于Windows或macOS开发内核的难度。
出于这种考虑,我们决定不使用GRUB或则Multiboot标准。但是,Multiboot支持功能也在bootimage工具的开发计划之中,所以从原理上讲,假如选用bootimage工具,在未来使用GRUB引导你的系统内核是可能的。假如你对编撰一个支持Mutiboot标准的内核有兴趣,可以查阅初版文档。
UEFI
(截止此时,我们并未提供UEFI相关教程,但我们确实有此意向。假如你乐意提供一些帮助,请在Githubissue告知我们,不胜谢谢。)
最小内核
如今我们早已明白笔记本是怎样启动的,那也是时侯编撰我们自己的内核了。我们的小目标是,创建一个内核的c盘映像puppy linux,它还能在启动时,向屏幕输出一行“HelloWorld!”;我们的工作将基于上一章建立的独立式可执行程序。
假如读者还有印象的话,在上一章,我们使用cargo建立了一个独立的二补码程序;但这个程序仍然基于特定的操作系统平台:因平台而异,我们须要定义不同名称的函数linux命令手册,且使用不同的编译指令。这是由于在默认情况下,cargo会为特定的寄主系统(hostsystem)建立源码,例如为你正在运行的系统建立源码。这并不是我们想要的,由于我们的内核不应当基于另一个操作系统——我们想要编撰的,就是这个操作系统。准确地说,我们想要的是,编译为一个特定的目标系统(targetsystem)。
安装NightlyRust
Rust语言有三个发行频道(releasechannel),分别是stable、beta和nightly。《Rust程序设计语言》中对这三个频道的区别解释得很详尽,可以抵达这儿看一看。为了搭建一个操作系统,我们须要一些只有nightly会提供的实验性功能,所以我们须要安装一个nightly版本的Rust。
要管理安装好的Rust,我强烈建议使用rustup:它容许你同时安装nightly、beta和stable版本的编译器,并且让更新Rust显得容易。你可以输入rustupoverrideaddnightly来选择在当前目录使用nightly版本的Rust。或则,你也可以在项目根目录添加一个名称为rust-toolchain、内容为nightly的文件。要检测你是否早已安装了一个nightly,你可以运行rustc--version:返回的版本号末尾应当包含-nightly。
Nightly版本的编译器容许我们在源码的开头插入特点标签(featureflag),来自由选择并使用大量实验性的功能。举个反例,要使用实验性的内联汇编(asm!宏),我们可以在main.rs的底部添加#![feature(asm)]。要注意的是,这样的实验性功能不稳定(unstable),意味着未来的Rust版本可能会更改或移除那些功能linux最小系统构建,而不会有预先的警告过渡。因而我们只有在绝对必要的时侯,才应当使用这种特点。
目标配置清单
通过--target参数,cargo支持不同的目标系统。这个目标系统可以使用一个目标三元组()来描述,它描述了CPU构架、平台供应者、操作系统和应用程序二补码插口(ApplicationBinaryInterface,ABI)。比方说,目标三元组x86_64-unknown-linux-gnu描述一个基于x86_64构架CPU的、没有明晰的平台供应者的linux系统,它遵守GNU风格的ABI。Rust支持许多不同的目标三元组,包括安卓系统对应的arm-linux-androideabi和WebAssembly使用的wasm32-unknown-unknown。
为了编撰我们的目标系统,但是鉴于我们须要做一些特殊的配置(例如没有依赖的底层操作系统),早已支持的目标三元组都不能满足我们的要求。辛运的是,只需使用一个JSON文件,Rust便准许我们定义自己的目标系统;这个文件常被叫做目标配置清单(targetspecification)。例如,一个描述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)。大多数的配置项都是LLVM需求的,它们将配置为特定平台生成的代码。打个比方,data-layout配置项定义了不同的整数、浮点数、指针类型的宽度;另外,还有一些Rust用作条件编译的配置项,如target-pointer-width。还有一些类型的配置项,定义了这个包该怎么被编译,比如,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
}
须要注意的是,由于我们要在裸机(baremetal)上运行内核,我们早已更改了llvm-target的内容,并将os配置项的值改为none。
我们还须要添加下边与编译相关的配置项:
"linker-flavor": "ld.lld",
"linker": "rust-lld",
在这儿,我们不使用平台默认提供的链接器,由于它可能不支持Linux目标系统。为了链接我们的内核,我们使用跨平台的LLD链接器(LLDlinker),它是和Rust一起打包发布的。
"panic-strategy": "abort",
这个配置项的意思是,我们的编译目标不支持panic时的栈展开(stackunwinding),所以我们选择直接在panic时终止(abortonpanic)。这和在Cargo.toml文件中添加panic=”abort”选项的作用是相同的,所以我们可以不在这儿的配置清单中填写这一项。
"disable-redzone": true,
我们正在编撰一个内核,所以我们迟早要处理中断。要安全地实现这一点,我们必须禁用一个与红区(redzone)有关的栈表针优化:由于此时,这个优化可能会造成栈被破坏。假如须要更详尽的资料,请查阅我们的一篇关于禁用红区的短文。
"features": "-mmx,-sse,+soft-float",
features配置项被拿来启用或禁用某个目标CPU特点(CPUfeature)。通过在它们后面添加-号,我们将mmx和sse特点禁用;添加前缀+号,我们启用了soft-float特点。
mmx和sse特点决定了是否支持单指令多数据流(SingleInstructionMultipleData,SIMD)相关指令,这种指令经常能明显地增强程序层面的性能。但是,在内核中使用庞大的SIMD寄存器,可能会导致较大的性能影响:由于每次程序中断时,内核不得不存储整个庞大的SIMD寄存器以备恢复——这意味着,对每位硬件中断或系统调用,完整的SIMD状态必须存到寻址中。因为SIMD状态可能相当大(512~1600个字节),而中断可能经常发生,这种额外的储存与恢复操作可能明显地影响效率。为解决这个问题,我们对内核禁用SIMD(但这不意味着禁用内核之上的应用程序的SIMD支持)。
禁用SIMD形成的一个问题是,x86_64构架的浮点数表针运算默认依赖于SIMD寄存器。我们的解决方式是,启用soft-float特点,它将使用基于整数的软件功能,模拟浮点数表针运算。
为了让读者的印象更清晰,我们撰写了一篇关于禁用SIMD的短文。
如今,我们将各个配置项整合在一起。我们的目标配置清单应当长这样:
{
"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系统的编撰风格(这可能是LLVM的默认风格)。这意味着,我们须要把前一篇文章中编撰的入口点重命名为_start:
// 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` 的函数,所以这个函数就是入口点
// 默认命名为 `_start`
loop {}
}
注意的是,无论你开发使用的是哪类操作系统,你都须要将入口点命名为_start。前一篇文章中编撰的Windows系统和macOS对应的入口点不应当被保留。
通过把JSON文件名传入--target选项,我们如今可以开始编译我们的内核。让我们试试看:
> cargo build --target x86_64-blog_os.json
error[E0463]: can't find crate for `core`
毫不意外的编译失败了,错误信息告诉我们编译器没有找到core这个crate,它包含了Rust语言中的部份基础类型,如Result、Option、迭代器等等,但是它就会隐式链接到no_std特点上面。
一般状况下,corecrate以预编译库(precompiledlibrary)的方式与Rust编译器一齐发布——这时,corecrate只对支持的寄主系统有效,而对我们自定义的目标系统无效。假如我们想为其它系统编译代码,我们须要为这种系统重新编译整个corecrate。
build-std选项
此时就到了cargo中登场的时刻,该特点容许你根据自己的须要重编译core等标准crate,而不须要使用Rust安装程序外置的预编译版本。并且该特点是全新的功能,到目前为止仍未完全完成,所以它被标记为“unstable”且仅被准许在环境下调用。
要启用该特点,你须要创建一个cargo配置文件,即.cargo/config.toml,并写入以下句子:
# in .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
该配置会告知cargo须要重新编译core和compiler_builtins这两个crate,其中compiler_builtins是core的必要依赖。另外重编译须要提供源码,我们可以使用rustupcomponentaddrust-src命令来下载它们。