🌲长青 #OS #Rust

这里是 rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档的阅读笔记。解释一下文章标题: Bare Metai (裸机平台),在裸机平台上的软件没有传统操作系统支持。我们的目标是在这样一个裸机平台上编写一个应用程序,功能是打印出 HelloWorld!

应用程序执行环境与平台支持

编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 编译器 为主的 开发环境 ;运行应用程序执行码所依赖的是以 操作系统 为主的 执行环境 。

本章主要是讲解如何设计和实现建立在裸机上的执行环境,并让应用程序能够在这样的执行环境中运行。从而让同学能够对应用程序和它所依赖的执行环境有一个全面和深入的理解。

本章设计实现了一个支持显示字符串应用的简单操作系统–“三叶虫”操作系统 – LibOS,它的形态就是一个函数库,给应用程序提供了显示字符串的函数。

库操作系统(Library OS,LibOS)

LibOS 以函数库的形式存在,为应用程序提供操作系统的基本功能。它最早来源于 MIT PDOS 研究小组在1996年左右的 Exokernel(外核)操作系统结构研究。Frans Kaashoek 教授的博士生 Dawson Engler 提出了一种与以往操作系统架构大相径庭的 Exokernel(外核)架构设计 1 ,即把传统的单体内核分为两部分,一部分以库操作系统的形式(即 LibOS)与应用程序紧耦合以实现传统的操作系统抽象,并进行面向应用的裁剪与优化;另外一部分(即外核)仅专注在最基本的安全复用物理硬件的机制上,来给 LibOS 提供基本的硬件访问服务。这样的设计思路可以针对应用程序的特征定制 LibOS ,达到高性能的目标。

这种操作系统架构的设计思路比较超前,对原型系统的测试显示了很好的性能提升。但最终没有被工业界采用,其中一个重要的原因是针对特定应用定制一个LibOS的工作量大,难以重复使用。人力成本因素导致了它不太被工业界认可。

应用程序执行环境

计算机科学中遇到的所有问题都可通过增加一层抽象来解决。

All problems in computer science can be solved by another level of indirection。

– 计算机科学家 David Wheeler

现代编译器工具集(以 C 或 Rust 编译器为例)的主要工作流程如下:

  1. 源代码(source code) –> 预处理器(preprocessor) –> 宏展开的源代码
  2. 宏展开的源代码 –> 编译器(compiler) –> 汇编程序
  3. 汇编程序 –> 汇编器(assembler)–> 目标代码(object code)
  4. 目标代码 –> 链接器(linker) –> 可执行文件(executables)

目标平台与目标三元组

为什么需要知道目标平台?

编译器在将源代码通过编译、链接得到可执行文件的时候需要知道程序要在哪个 平台 (Platform) 上运行才能最后生成可执行文件。这里平台主要是指 CPU 类型、操作系统类型和标准运行时库的组合。

我们选择 riscv64gc-unknown-none-elf 目标平台。

Rust 标准库与核心库

编程语言的标准库比如Rust 语言标准库–std 或三方库的某些功能会直接或间接的用到操作系统提供的系统调用。但目前我们所选的目标平台不存在任何操作系统支持。

幸运的是,Rust 有一个对 Rust 语言标准库–std 裁剪过后的 Rust 语言核心库 core。core库是不需要任何操作系统支持的

移除标准库依赖

不是说修改编译器编译选项就可以完成针对不同执行环境的编译的,我们需要修改我们的代码改造哪些需要用到之前依赖的执行环境,当然在你没有改造完成之前你也不会编译成功的😂。

提供 panic_handler 功能应对致命错误

在使用 Rust 编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误(panic),导致程序无法继续向下运行。这时手动或自动调用 panic! 宏来打印出错的位置,让软件能够意识到它的存在,并进行一些后续处理。 panic! 宏最典型的应用场景包括断言宏 assert! 失败或者对 Option::None/Result::Err 进行 unwrap 操作。所以Rust编译器在编译程序时,从安全性考虑,需要有 panic! 宏的具体实现。

更底层的核心库 core 中只有一个 panic! 宏的空壳,并没有提供 panic! 宏的精简实现。因此我们需要自己先实现一个简陋的 panic 处理函数,这样才能让“三叶虫”操作系统 – LibOS的编译通过。

移除 main 函数

语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 main 函数)开始执行。事实上 start 语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。

最简单的解决方案就是压根不让编译器使用这项功能。我们在 main.rs 的开头加入设置 #![no_main] 告诉编译器我们没有一般意义上的 main 函数,并将原来的 main 函数删除。在失去了 main 函数的情况下,编译器也就不需要完成所谓的初始化工作了。

介绍执行环境 Qemu——内核执行的基础

接下来两节我们将进行构建“三叶虫”操作系统的第二步,即将我们的内核正确加载到 Qemu 模拟器上,使得 Qemu 模拟器可以成功执行内核的第一条指令!

想要加载内核的话,我们首先需要了解执行平台的相关资料,把我们的内核放到哪里才能被正确执行?怎么把 qemu 的控制权转移到我们的内核。

Qemu 的工作模式

在 Qemu 模拟的 virt 硬件平台上,物理内存的起始物理地址为 0x80000000 ,物理内存的默认大小为 128MiB。在本书中,我们只会用到最低的 8MiB 物理内存,对应的物理地址区间为 [0x80000000,0x80800000) 。

Qemu 模拟的启动流程则可以分为三个阶段:第一个阶段由固化在 Qemu 内的一小段汇编程序负责;第二个阶段由 bootloader 负责;第三个阶段则由内核镜像负责。

程序内存布局 Memory Layout

在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可读写的内存空间。

编译流程

从源代码得到可执行文件的编译流程可被细化为多个阶段(虽然输入一条命令便可将它们全部完成):

  1. 编译器 (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;
  2. 汇编器 (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File);
  3. 链接器 (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。

汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。在此期间链接器主要完成两件事情:

在 Qemu 上执行内核的第一条指令

我们先编写好内核第一条指令并嵌入到内核项目中。

编写第一条指令

# os/src/entry.asm
     .section .text.entry
     .globl _start
 _start: 
     li x1, 100

调整内核布局与 Qemu 正确对接

内核第一条指令(实践篇) - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档

由于链接器默认的内存布局并不能符合我们的要求,为了实现与 Qemu 正确对接,我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合 Qemu 的预期,即内核第一条指令的地址应该位于 0x80200000 。我们修改 Cargo 的配置文件来使用我们自己的链接脚本 os/src/linker.ld 而非使用默认的内存布局。

手动加载内核可执行文件

上面通过链接得到的文件完全符合我们对于内存布局的要求,但是我们不能将其直接提交给 Qemu ,因为它除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。

我们需要丢弃内核可执行文件中的元数据得到内核镜像。

rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin

为内核提供函数调用——将控制权交给Rust

上一节我们成功在 Qemu 上执行了内核的第一条指令,它是我们在 entry.asm 中手写汇编代码得到的。然而,我们无论如何也不想仅靠手写汇编代码的方式编写我们的内核,绝大部分功能我们都想使用 Rust 语言来实现。

不过为了将控制权转交给我们使用 Rust 语言编写的内核入口函数,我们确实需要手写若干行汇编代码进行一定的初始化工作。这些汇编代码放在 entry.asm 中,并在控制权被转交给内核相关函数前最先被执行,但它们的功能会更加复杂。首先需要设置栈空间,来在内核内使能函数调用,随后直接调用使用 Rust 编写的内核入口函数,从而控制权便被移交给 Rust 代码。

函数调用与栈

从汇编指令的级别看待一段程序的执行,假如 CPU 依次执行的指令的物理地址序列为 {an},那么这个序列会符合怎样的模式呢?

编译器是独立编译每个函数的,因此一个函数并不能知道它所调用的子函数修改了哪些寄存器。而站在一个函数的视角,在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。

我们将由于函数调用,在控制流转移前后需要保持不变的寄存器集合称之为 函数调用上下文 (Function Call Context) 。

由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,就需要物理内存的帮助。在调用子函数之前,我们需要在物理内存中的一个区域栈Stack 保存 (Save) 函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并 恢复 (Restore) 函数调用上下文中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:

RISC-V 架构上的 C 语言调用规范

|寄存器组|保存者|功能|
|---|---|---|
|a0~a7( x10~x17 )|调用者保存|用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。|
|t0~t6 ( x5~x7,x28~x31 )|调用者保存|作为临时寄存器使用,在被调函数中可以随意使用无需保存。|
|s0~s11 ( x8~x9,x18~x27 )|被调用者保存|作为临时寄存器使用,被调函数保存后才能在被调函数中使用。|

分配并使用启动栈

# os/src/entry.asm
     .section .text.entry
     .globl _start
_start:
     la sp, boot_stack_top
     call rust_main

     .section .bss.stack
     .globl boot_stack_lower_bound
boot_stack_lower_bound:
    .space 4096 * 16
    .globl boot_stack_top
boot_stack_top:

栈是从高地址向低地址增长。因此,最开始的时候栈为空,栈顶和栈底位于相同的位置,我们用更高地址的符号 boot_stack_top 来标识栈顶的位置。同时,我们用更低地址的符号 boot_stack_lower_bound 来标识栈能够增长到的下限位置,它们都被设置为全局符号供其他目标文件使用。如下图所示:

内核调用 RustSBI 服务

之前我们对 RustSBI 的了解仅限于它会在计算机启动时进行它所负责的环境初始化工作,并将计算机控制权移交给内核。但实际上作为内核的执行环境,它还有另一项职责:即在内核运行时响应内核的请求为内核提供服务。当内核发出请求时,计算机会转由 RustSBI 控制来响应内核的请求,待请求处理完毕后,计算机控制权会被交还给内核。

总结