ch1.
This commit is contained in:
parent
936be1ce44
commit
014d69bce5
|
@ -14,25 +14,22 @@
|
|||
|
||||
不过我们能够隐约意识到编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 **编译器** 为主的开发环境;运行应用程序执行码所依赖的是以 **操作系统** 为主的执行环境。
|
||||
|
||||
本章主要是设计和实现建立在裸机上的执行环境,从中对应用程序和它所依赖的执行环境有一个全面和深入的理解。
|
||||
本章我们将从操作系统最简单但也是最重要的println入手,要求大家实现一个裸机上的println以及带色彩的LOG,如info和warn,error等功能。因为大家是刚刚接触操作系统实验,本章的所有代码助教已经帮大家写好了,没有大家需要亲自编写代码的部分。但是它作为第一章又是最重要的一个章节:这一张之中,同学们要对整个C的OS实验框架有一个大致的掌握。对整个框架是如何编译的,之后需要写哪些内容以及如何测试有一个基本的认识。可以说,lab1打好基础会使得之后的实验难度大大降低。
|
||||
|
||||
本章我们的目标仍然只是输出 ``Hello, world!`` ,但这一次,我们将离开舒适区,基于一个几乎空无一物的平台从零开始搭建我们自己的高楼大厦,而不是仅仅通过一行语句就完成任务。所以,在接下来的内容中,我们将描述如何让 ``Hello, world!`` 应用程序逐步脱离对编译器、运行时和操作系统的现有复杂依赖,最终以最小的依赖需求能在裸机上运行。这时,我们也可把这个能在裸机上运行的 ``Hello, world!`` 应用程序称为一种支持输出字符串的非常初级的寒武纪“三叶虫”操作系统,它其实就是一个给应用提供各种服务(比如输出字符串)的库,方便了单一应用程序在裸机上的开发与运行。输出字符串功能好比是三叶虫的眼睛,有了它,我们就有了最基本的调试功能,即通过在代码中的不同位置插入特定内容的输出语句来实现对程序运行的调试。
|
||||
系统调用
|
||||
---------------------------
|
||||
|
||||
.. note::
|
||||
在实验开始之前,大家要熟悉一下系统调用(syscall)的概念。相信大家在汇编的课程中一定接触过这个名词。我们os课程中的syscall的意义也是一样的,它是操作系统提供给软件的一系列接口,使得软件能够使用系统的功能。syscall本质上属于一种异常/中断,它在riscv的汇编指令中以ecall的形式出现。
|
||||
|
||||
在操作系统发展历史上,在1956年就诞生了操作系统GM-NAA I/O,并且被实际投入使用,它的一个主要任务就是"自动加载运行一个接一个的程序"。
|
||||
本章的println所需要的在console中打印字符,也需要调用到syscall。syscall的种类有很多,操作系统通过区分syscall的id来判断是哪一个syscall。
|
||||
|
||||
实践体验
|
||||
---------------------------
|
||||
|
||||
本章设计实现了一个支持显示字符串应用的简单操作系统--“三叶虫”操作系统。
|
||||
|
||||
获取本章代码:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
|
||||
$ cd rCore-Tutorial-v3
|
||||
$ git checkout ch1
|
||||
|
||||
在 qemu 模拟器上运行本章代码,看看一个小应用程序是如何在QEMU模拟的计算机上运行的:
|
||||
|
@ -87,50 +84,3 @@
|
|||
RustSBI是啥?
|
||||
|
||||
戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。
|
||||
|
||||
本章代码树
|
||||
------------------------------------------------
|
||||
|
||||
.. code-block::
|
||||
|
||||
./os/src
|
||||
Rust 4 Files 118 Lines
|
||||
Assembly 1 Files 11 Lines
|
||||
|
||||
├── bootloader(内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
|
||||
│ ├── rustsbi-k210.bin(可运行在 k210 真实硬件平台上的预编译二进制版本)
|
||||
│ └── rustsbi-qemu.bin(可运行在 qemu 虚拟机上的预编译二进制版本)
|
||||
├── LICENSE
|
||||
├── os(我们的内核实现放在 os 目录下)
|
||||
│ ├── Cargo.toml(内核实现的一些配置文件)
|
||||
│ ├── Makefile
|
||||
│ └── src(所有内核的源代码放在 os/src 目录下)
|
||||
│ ├── console.rs(将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
|
||||
│ ├── entry.asm(设置内核执行环境的的一段汇编代码)
|
||||
│ ├── lang_items.rs(需要我们提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
|
||||
│ ├── linker-k210.ld(控制内核内存布局的链接脚本以使内核运行在 k210 真实硬件平台上)
|
||||
│ ├── linker-qemu.ld(控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
|
||||
│ ├── main.rs(内核主函数)
|
||||
│ └── sbi.rs(调用底层 SBI 实现提供的 SBI 接口)
|
||||
├── README.md
|
||||
├── rust-toolchain(控制整个项目的工具链版本)
|
||||
└── tools(自动下载的将内核烧写到 k210 开发板上的工具)
|
||||
├── kflash.py
|
||||
├── LICENSE
|
||||
├── package.json
|
||||
├── README.rst
|
||||
└── setup.py
|
||||
|
||||
|
||||
本章代码导读
|
||||
-----------------------------------------------------
|
||||
|
||||
|
||||
操作系统虽然是软件,但它不是常规的应用软件,需要运行在没有操作系统的裸机环境中。如果采用通常编程方法和编译手段,无法开发出操作系统。其中一个重要的原因编译器编译出的应用软件在缺省情况下是要链接标准库(Rust编译器和C编译器都是这样的),而标准库是依赖于操作系统(如Linux、Windows等)的。所以,本章主要是让读者能够脱离常规应用软件开发的思路,理解如何开发没有操作系统支持的操作系统内核。
|
||||
|
||||
为了做到这一步,首先需要写出不需要标准库的软件并通过编译。为此,先把一般应用所需要的标准库的组件给去掉,这会导致编译失败。然后在逐步添加不需要操作系统的极少的运行时支持代码,让编译器能够正常编译出不需要标准库的正常程序。但此时的程序没有显示输出,更没有输入等,但可以正常通过编译,这样就为进一步扩展程序内容打下了一个 **可正常编译OS** 的前期基础。具体可看 :ref:`移除标准库依赖 <term-remove-std>` 一节的内容。
|
||||
|
||||
由于操作系统代码无法象应用软件那样,可以有方便的调试(Debug)功能。这是因为应用之所以能够被调试,也是由于操作系统提供了方便的调试相关的系统调用。而我们不得不再次认识到,需要运行在没有操作系统的裸机环境中,当然没法采用依赖操作系统的传统调试方法了。所以,我们只能采用 ``print`` 这种原始且有效的调试方法。这样,第二步就是让脱离了标准库的软件有输出,这样,我们就能看到程序的运行情况了。为了简单起见,我们可以先在用户态尝试构建没有标准库的支持显示输出的最小运行时执行环境,比较特别的地方在于如何写内嵌汇编完成简单的系统调用。具体可看 :ref:`构建用户态执行环境 <term-print-userminienv>` 一节的内容。
|
||||
|
||||
|
||||
接下来就是尝试构建可在裸机上支持显示的最小运行时执行环境。相对于用户态执行环境,读者需要能够做更多的事情,比如如何关机,如何配置软件运行所在的物理内存空间,特别是栈空间,如何清除 ``bss`` 段,如何通过 ``RustSBI`` 的 ``SBI_CONSOLE_PUTCHAR`` 接口简洁地实现的信息输出。这里比较特别的地方是需要了解 ``linker.ld`` 文件中对OS的代码和数据所在地址空间布局的描述,以及基于RISC-V 64的汇编代码 ``entry.asm`` 如何进行栈的设置和初始化,以及如何跳转到Rust语言编写 ``rust_main`` 主函数中,并开始内核最小运行时执行环境的运行。具体可看 :ref:`构建裸机执行环境 <term-print-kernelminienv>` 一节的内容。
|
|
@ -1,4 +1,4 @@
|
|||
应用程序执行环境与平台支持
|
||||
代码框架简述
|
||||
================================================
|
||||
|
||||
.. toctree::
|
||||
|
@ -9,226 +9,85 @@
|
|||
本节导读
|
||||
-------------------------------
|
||||
|
||||
本节介绍了如何设计实现一个提供显示字符服务的用户态执行环境和裸机执行环境,以支持一个应用程序显示字符串。显示字符服务的裸机执行环境和用户态执行环境向下直接或间接与硬件关联,向上可通过函数库给应用提供 **显示字符** 的服务。这也说明了不管执行环境是简单还是复杂,设计实现上是否容易,它都体现了不同操作系统的共性特征--给应用需求提供服务。在某种程度上看,执行环境的软件主体就可称为是一种操作系统。
|
||||
本节会介绍代码的整体框架。我们默认大家都熟练掌握了C语言,不会从头讲C语言的基本语法qaq。
|
||||
整个项目目前的代码树如下:
|
||||
|
||||
执行应用程序
|
||||
|
||||
os文件夹
|
||||
-------------------------------
|
||||
|
||||
我们先在Linux上开发并运行一个简单的“Hello, world”应用程序,看看一个简单应用程序从开发到执行的全过程。作为一切的开始,让我们使用 Cargo 工具来创建一个 Rust 项目。它看上去没有任何特别之处:
|
||||
os文件夹下存放了所有我们构建操作系统的源代码。也是本次实验中最最重要的一部分。在开始实验之前,大家一定要清楚我们这是自己设计的os,是无法使用C提供的官方标准库的,也就是说,就算是最简单的printf之类的函数都无法使用。但是别担心,作为一个轻量级的os,我们也用不到那么多函数。需要的函数助教们在框架之中已经为大家编写好了~~
|
||||
|
||||
.. code-block:: console
|
||||
我们的os是一个由makefile来构建的C项目。下面介绍框架之中一些重要文件的作用,以及整个项目是如何链接及编译的。
|
||||
|
||||
$ cargo new os --bin
|
||||
- kernel.ld
|
||||
kernel.ld是我们用于链接项目的脚本。链接脚本决定了 elf 程序的内存空间布局(严格的讲是虚存映射,注意程序中的各种绝对地址就在链接的时候确定),由于刚进入 S 态的时候我们尚未激活虚存机制,我们必须把 os 置于物理内存的 0x80200000 处(这个地址的来由请参考rustsbi)::
|
||||
BASE_ADDRESS = 0x80200000;
|
||||
SECTIONS
|
||||
{
|
||||
. = BASE_ADDRESS;
|
||||
skernel = .;
|
||||
|
||||
我们加上了 ``--bin`` 选项来告诉 Cargo 我们创建一个可执行项目而不是库项目。此时,项目的文件结构如下:
|
||||
stext = .;
|
||||
.text : {
|
||||
*(.text.entry) # 第一行代码
|
||||
*(.text .text.*)
|
||||
}
|
||||
|
||||
.. code-block:: console
|
||||
...
|
||||
}
|
||||
SECTIONS之中是从BASE_ADDRESS开始的各段。对程序内存布局还不太熟悉的同学可以翻看后面内存布局的章节。以text段为例,它是由不同文件的text组成。我们没有规定这些 text 段的具体顺序,但是我们规定了一个特殊的 text 段:.text.entry 段,该 text 段是 BASE_ADDRESS 后的第一个段,该段的第一行代码就在 0x80200000 处。这个特殊的段不是编译生成的,它在 entry.S 中人为设定。
|
||||
|
||||
$ tree os
|
||||
os
|
||||
├── Cargo.toml
|
||||
└── src
|
||||
└── main.rs
|
||||
- entry.S
|
||||
::
|
||||
# entry.S
|
||||
.section .text.entry
|
||||
.globl _entry
|
||||
_entry:
|
||||
la sp, boot_stack
|
||||
call main
|
||||
|
||||
1 directory, 2 files
|
||||
.section .bss.stack
|
||||
.globl boot_stack
|
||||
boot_stack:
|
||||
.space 4096 * 16
|
||||
.globl boot_stack_top
|
||||
boot_stack_top:
|
||||
|
||||
其中 ``Cargo.toml`` 中保存着项目的配置,包括作者的信息、联系方式以及库依赖等等。显而易见源代码保存在 ``src`` 目录下,目前为止只有 ``main.rs`` 一个文件,让我们看一下里面的内容:
|
||||
.text.entry 段中只有一个函数 _entry,它干的事情也十分简单,设置好 os 运行的堆栈(la sp, boot_stack语句。bootloader 并没有好心的设置好这些),然后调用 main 函数。main 函数位于 main.c 中,从此开始我们就基本进入了 C 的世界。
|
||||
|
||||
.. code-block:: rust
|
||||
- main.c
|
||||
它是os的入口函数。在其中我们会完成一系列的初始化并开始运行os。
|
||||
作为第一章,它在初始化完毕之后实际上起到了一个测试的作用。如果你的main.c能够完成一系列打印并且最后成功退出(Shutdown),那么祝贺你,你完成了os的第一步。
|
||||
.. code-block:: c
|
||||
:linenos:
|
||||
:caption: 最简单的 Rust 应用
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
void main() {
|
||||
clean_bss();
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
进入 os 项目根目录下,利用 Cargo 工具即可一条命令实现构建并运行项目:
|
||||
实际上,main.c之中众多的extern声明的内存段是在ld文件之中定义的。
|
||||
等待lab1重做。
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo run
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 1.15s
|
||||
Running `target/debug/os`
|
||||
Hello, world!
|
||||
|
||||
如我们预想的一样,我们在屏幕上看到了一行 ``Hello, world!`` 。但是,需要注意到我们所享受到的编程和执行程序的方便性并不是理所当然的,背后有着从硬件到软件的多种机制的支持。特别是对于应用程序的运行,是需要有一个强大的执行环境来帮助。接下来,我们就要看看有操作系统加持的强大的执行环境。
|
||||
|
||||
应用程序执行环境
|
||||
bootloader文件夹
|
||||
-------------------------------
|
||||
|
||||
如下图所示,现在通用操作系统(如 Linux 等)上的应用程序运行需要下面一套多层次的执行环境栈的支持:
|
||||
这个文件夹是用来存放bootloader的bin文件的,这一章以及之后都无需我们做任何修改。
|
||||
|
||||
.. _app-software-stack:
|
||||
硬件加电之后是处于M态,而rustsbi帮助我们完成了M态的初始化,到os运行的S态的迁移,以及PC移动至我们os开始执行的位置。同时,它也帮助我们完成了异常和中断的委托。会帮助S态的os完成其发出的ecall请求等等。具体的细节就是大家这一章的练习之一。
|
||||
|
||||
.. figure:: app-software-stack.png
|
||||
:align: center
|
||||
|
||||
应用程序执行环境栈:图中的白色块自上而下(越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行)表示各级执行环境,黑色块则表示相邻两层执行环境之间的接口。
|
||||
|
||||
.. _term-execution-environment:
|
||||
|
||||
我们的应用位于最上层,它可以通过调用编程语言提供的标准库或者其他三方库对外提供的功能强大的函数接口,使得仅需少量的源代码就能完成复杂的功能。但是这些库的功能不仅限于此,事实上它们属于应用程序的 **执行环境** (Execution Environment),在我们通常不会注意到的地方,它们还会在执行应用之前完成一些初始化工作,并在应用程序执行的时候对它进行监控。我们在打印 ``Hello, world!`` 时使用的 ``println!`` 宏正是由 Rust 标准库 std 和 GNU Libc 库等提供的。
|
||||
|
||||
.. _term-system-call:
|
||||
|
||||
从内核/操作系统的角度看来,它上面的一切都属于用户态,而它自身属于内核态。无论用户态应用如何编写,是手写汇编代码,还是基于某种编程语言利用其标准库或三方库,某些功能总要直接或间接的通过内核/操作系统提供的 **系统调用** (System Call) 来实现。因此系统调用充当了用户和内核之间的边界。内核作为用户态的执行环境,它不仅要提供系统调用接口,还需要对用户态应用的执行进行监控和管理。
|
||||
|
||||
.. note::
|
||||
|
||||
**Hello, world! 用到了哪些系统调用?**
|
||||
|
||||
从之前的 ``cargo run`` 的输出可以看出之前构建的可执行文件是在 target/debug 目录下的 os 。在 Ubuntu 系统上,可以通过 ``strace`` 工具来运行一个程序并输出程序运行过程当中向内核请求的所有的系统调用及其返回值。我们只需输入 ``strace target/debug/os`` 即可看到一长串的各种系统调用。
|
||||
|
||||
其中,容易看出与 ``Hello, world!`` 应用实际执行相关的只有两个系统调用:
|
||||
|
||||
.. code-block::
|
||||
|
||||
[输出字符串]
|
||||
write(1, "Hello, world!\n", 14) = 14
|
||||
[程序退出执行]
|
||||
exit_group(0)
|
||||
|
||||
其参数的具体含义我们暂且不在这里进行解释。
|
||||
|
||||
其余的系统调用基本上分别用于函数库和内核两层执行环境的初始化工作和对于上层的运行期监控和管理。之后,随着应用场景的复杂化,我们需要更强的抽象能力,也会实现这里面的一些系统调用。
|
||||
|
||||
.. _term-isa:
|
||||
|
||||
从硬件的角度来看,它上面的一切都属于软件。硬件可以分为三种: 处理器 (Processor) ——它更常见的名字是中央处理单元 (CPU, Central Processing Unit),内存 (Memory) 还有 I/O 设备。其中处理器无疑是其中最复杂同时也最关键的一个。它与软件约定一套 **指令集体系结构** (ISA, Instruction Set Architecture),使得软件可以通过 ISA 中提供的汇编指令来访问各种硬件资源。软件当然也需要知道处理器会如何执行这些指令:最简单的话就是一条一条执行位于内存中的指令。当然,实际的情况远比这个要复杂得多,为了适应现代应用程序的场景,处理器还需要提供很多额外的机制,而不仅仅是让数据在 CPU 寄存器、内存和 I/O 设备三者之间流动。
|
||||
|
||||
.. _term-abstraction:
|
||||
|
||||
.. note::
|
||||
|
||||
**多层执行环境都是必需的吗?**
|
||||
|
||||
除了最上层的应用程序和最下层的硬件平台必须存在之外,作为中间层的函数库和操作系统内核并不是必须存在的:
|
||||
它们都是对下层资源进行了 **抽象** (Abstraction),并为上层提供了一套执行环境(也可理解为一些服务功能)。抽象的优点在于它让上层以较小的代价获得所需的功能,并同时可以提供一些保护。但抽象同时也是一种限制,会丧失一些应有的灵活性。比如,当你在考虑在项目中应该使用哪个函数库的时候,就常常需要这方面的权衡:过多的抽象和过少的抽象自然都是不合适的。理解应用的需求也很重要。一个能合理满足应用需求的操作系统设计是操作系统设计者需要深入考虑的问题。这也是一种权衡,过多的服务功能和过少的服务功能自然都是不合适的。
|
||||
|
||||
实际上,我们通过应用程序的特征和需求来判断操作系统需要什么程度的抽象和功能。
|
||||
|
||||
- 如果函数库和内核都不存在,那么我们就是在手写汇编代码,这种方式具有最高的灵活性,抽象能力则最低,基本等同于硬件。我们通常用这种方式来实现一些架构相关且仅通过编程语言无法描述的小模块或者代码片段。
|
||||
- 如果仅存在函数库而不存在内核,意味着我们不需要内核提供的抽象。在嵌入式场景就常常会出现这种情况。嵌入式设备虽然也包含 CPU、内存和 I/O 设备,但是它上面通常只会同时运行一个或几个功能非常简单的小应用程序,其定位就是那种功能单一的场景,比如人脸识别打卡系统等。我们常用的操作系统如 Windows/Linux/macOS 等的抽象都支持同时运行很多应用程序,在嵌入式场景是过抽象或功能太多,用力过猛的。因此,常见的解决方案是仅使用函数库构建单独的应用程序或是用专为应用场景特别裁减过的轻量级内核管理少数应用程序。
|
||||
|
||||
.. note::
|
||||
|
||||
**“用力过猛”的现代操作系统**
|
||||
|
||||
对于如下更简单的小应用程序,我们可以看到“用力过猛”的现代操作系统提供的执行环境支持:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
//ch1/donothing.rs
|
||||
fn main() {
|
||||
//do nothing
|
||||
}
|
||||
|
||||
它只是想显示一下几乎感知不到的存在感。在编译后再运行,可以看到的情况是:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ rustc donothing.rs
|
||||
$ ./donothing
|
||||
$ (无输出)
|
||||
$ strace ./donothing
|
||||
(多达 93 行的输出,表明 donothing 向 Linux 操作系统内核发出了93次各种各样的系统调用)
|
||||
execve("./donothing", ["./donothing"], 0x7ffe02c9ca10 /* 67 vars */) = 0
|
||||
brk(NULL) = 0x563ba0532000
|
||||
arch_prctl(0x3001 /* ARCH_??? */, 0x7fff2da54360) = -1 EINVAL (无效的参数)
|
||||
......
|
||||
|
||||
|
||||
平台与目标三元组
|
||||
user文件夹(本章还莫得,需移动至lab2)
|
||||
---------------------------------------
|
||||
|
||||
.. _term-platform:
|
||||
大家马上就会发现我们并没有这样一个文件夹啊?实际上user文件夹是我们用于存放测例的文件夹。大家可以通过clone如下仓库的得到:
|
||||
|
||||
对于一份用某种编程语言实现的应用程序源代码而言,编译器在将其通过编译、链接得到可执行文件的时候需要知道程序要在哪个 **平台** (Platform) 上运行。这里 **平台** 主要是指CPU类型、操作系统类型和标准运行时库的组合。从上面给出的 :ref:`应用程序执行环境栈 <app-software-stack>` 可以看出:
|
||||
.. code-block:: bash
|
||||
|
||||
- 如果用户态基于的内核不同,会导致系统调用接口不同或者语义不一致;
|
||||
- 如果底层硬件不同,对于硬件资源的访问方式会有差异。特别是 ISA 不同的话,对上提供的指令集和寄存器都不同。
|
||||
git clone https://github.com/DeathWish5/riscvos-c-tests.git
|
||||
|
||||
它们都会导致最终生成的可执行文件有很大不同。需要指出的是,某些编译器支持同一份源代码无需修改就可编译到多个不同的目标平台并在上面运行。这种情况下,源代码是 **跨平台** 的。而另一些编译器则已经预设好了一个固定的目标平台。
|
||||
之后直接将user文件夹复制到我们的项目之中就可以了。
|
||||
|
||||
.. _term-target-triplet:
|
||||
我们的测例是通过cmake来编译的。具体的指令可以参见其中的readme。在使用测例的时候要注意,由于我们使用的是自己的os系统,因此所有常见的C库,比如stdlib.h,stdio.h等等都不能使用C官方的版本。这里在user的include和lib之中我们提供了搭配本次实验的对应库。大家可以看到,所有测例代码调用的函数都是使用的这里的代码。而这些库会依赖我们编写的os提供的系统调用(syscall)来运行。
|
||||
|
||||
我们可以通过 **目标三元组** (Target Triplet) 来描述一个目标平台。它一般包括 CPU 架构、CPU 厂商、操作系统和运行时库,它们确实都会控制可执行文件的生成。比如,我们可以尝试看一下之前的 ``Hello, world!`` 的目标平台是什么。这可以通过打印编译器 rustc 的默认配置信息:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ rustc --version --verbose
|
||||
rustc 1.51.0-nightly (d1aed50ab 2021-01-26)
|
||||
binary: rustc
|
||||
commit-hash: d1aed50ab81df3140977c610c5a7d00f36dc519f
|
||||
commit-date: 2021-01-26
|
||||
host: x86_64-unknown-linux-gnu
|
||||
release: 1.51.0-nightly
|
||||
LLVM version: 11.0.1
|
||||
|
||||
从其中的 host 一项可以看出默认的目标平台是 ``x86_64-unknown-linux-gnu``,其中 CPU 架构是 x86_64,CPU 厂商是 unknown,操作系统是 linux,运行时库是gnu libc(封装了Linux系统调用,并提供POSIX接口为主的函数库)。这种无论编译器还是其生成的可执行文件都在我们当前所处的平台运行是一种最简单也最普遍的情况。但是很快我们就将遇到另外一种情况。
|
||||
|
||||
讲了这么多,终于该介绍我们的主线任务了。我们希望能够在另一个硬件平台上运行 ``Hello, world!``,而与之前的默认平台不同的地方在于,我们将 CPU 架构从 x86_64 换成 RISC-V。
|
||||
|
||||
.. note::
|
||||
|
||||
**为何基于 RISC-V 架构而非 x86 系列架构?**
|
||||
|
||||
x86 架构为了在升级换代的同时保持对基于旧版架构应用程序/内核的兼容性,存在大量的历史包袱,也就是一些对于目前的应用场景没有任何意义,但又必须花大量时间正确设置才能正常使用 CPU 的奇怪设定。为了建立并维护架构的应用生态,这确实是必不可少的,但站在教学的角度几乎完全是在浪费时间。而新生的 RISC-V 架构十分简洁,架构文档需要阅读的核心部分不足百页,且这些功能已经足以用来构造一个具有相当抽象能力的内核了。
|
||||
|
||||
可以看一下目前 Rust 编译器支持哪些基于 RISC-V 的平台:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ rustc --print target-list | grep riscv
|
||||
riscv32gc-unknown-linux-gnu
|
||||
riscv32i-unknown-none-elf
|
||||
riscv32imac-unknown-none-elf
|
||||
riscv32imc-unknown-none-elf
|
||||
riscv64gc-unknown-linux-gnu
|
||||
riscv64gc-unknown-none-elf
|
||||
riscv64imac-unknown-none-elf
|
||||
|
||||
这里我们选择的是 ``riscv64gc-unknown-none-elf``,目标三元组中的 CPU 架构是 riscv64gc,厂商是 unknown,操作系统是 none,elf表示没有标准的运行时库(表明没有任何系统调用的封装支持),但可以生成ELF格式的执行程序。这里我们之所以不选择有
|
||||
linux-gnu 系统调用支持的版本 ``riscv64gc-unknown-linux-gnu``,是因为我们只是想跑一个 ``Hello, world!``,没有必要使用现在通用操作系统所提供的那么高级的抽象和多余的操作系统服务。而且我们很清楚后续我们要开发的是一个操作系统内核,它必须直面底层物理硬件(bare-metal)来提供更大的操作系统服务功能,已有操作系统(如Linux)提供的系统调用服务对这个内核而言是多余的。
|
||||
|
||||
.. note::
|
||||
|
||||
**RISC-V 指令集拓展**
|
||||
|
||||
由于基于 RISC-V 架构的处理器可能用于嵌入式场景或是通用计算场景,因此指令集规范将指令集划分为最基本的 RV32/64I 以及若干标准指令集拓展。每款处理器只需按照其实际应用场景按需实现指令集拓展即可。
|
||||
|
||||
- RV32/64I:每款处理器都必须实现的基本整数指令集。在 RV32I 中,每个通用寄存器的位宽为 32 位;在 RV64I 中则为 64 位。它可以用来模拟绝大多数标准指令集拓展中的指令,除了比较特殊的 A 拓展,因为它需要特别的硬件支持。
|
||||
- M 拓展:提供整数乘除法相关指令。
|
||||
- A 拓展:提供原子指令和一些相关的内存同步机制,这个后面会展开。
|
||||
- F/D 拓展:提供单/双精度浮点数运算支持。
|
||||
- C 拓展:提供压缩指令拓展。
|
||||
|
||||
G 拓展是基本整数指令集 I 再加上标准指令集拓展 MAFD 的总称,因此 riscv64gc 也就等同于 riscv64imafdc。我们剩下的内容都基于该处理器架构完成。除此之外 RISC-V 架构还有很多标准指令集拓展,有一些还在持续更新中尚未稳定,有兴趣的读者可以浏览最新版的 RISC-V 指令集规范。
|
||||
|
||||
Rust 标准库与核心库
|
||||
----------------------------------
|
||||
|
||||
我们尝试一下将当前的 ``Hello, world!`` 程序的目标平台换成 riscv64gc-unknown-none-elf 看看会发生什么事情:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo run --target riscv64gc-unknown-none-elf
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
error[E0463]: can't find crate for `std`
|
||||
|
|
||||
= note: the `riscv64gc-unknown-none-elf` target may not be installed
|
||||
|
||||
.. _term-bare-metal:
|
||||
|
||||
在之前的开发环境配置中,我们已经在 rustup 工具链中安装了这个目标平台支持,因此并不是该目标平台未安装的问题。这个问题只是单纯的表示在这个目标平台上找不到Rust 标准库 std。我们之前曾经提到过,编程语言的标准库或三方库的某些功能会直接或间接的用到操作系统提供的系统调用。但目前我们所选的目标平台不存在任何操作系统支持,于是 Rust 并没有为这个目标平台支持完整的标准库 std。类似这样的平台通常被我们称为 **裸机平台** (bare-metal)。
|
||||
|
||||
.. note::
|
||||
|
||||
**Rust语言标准库**
|
||||
|
||||
Rust 语言标准库是让 Rust 语言开发的软件具备可移植性的基础,类似于 C 语言的 LibC 标准库。它是一组最小的、经过实战检验的共享抽象,适用于更广泛的 Rust 生态系统开发。它提供了核心类型,如 Vec 和 Option、类库定义的语言原语操作、标准宏、I/O 和多线程等。默认情况下,所有 Rust crate 都可以使用 std 来支持 Rust 应用程序的开发。但 Rust 语言标准库的一个限制是,它需要有操作系统的支持。所以,如果你要实现的软件是运行在裸机上的操作系统,就不能直接用 Rust 语言标准库了。
|
||||
|
||||
幸运的是,Rust 有一个对 std 裁剪过后的核心库 core,这个库是不需要任何操作系统支持的,相对的它的功能也比较受限,但是也包含了 Rust 语言相当一部分的核心机制,可以满足我们的大部分需求。Rust 语言是一种面向系统(包括操作系统)开发的语言,所以在 Rust 语言生态中,有很多三方库也不依赖标准库 std 而仅仅依赖核心库 core。对它们的使用可以很大程度上减轻我们的编程负担。它们是我们能够在裸机平台挣扎求生的最主要倚仗,也是大部分运行在没有操作系统支持的 Rust 嵌入式软件的必备。
|
||||
|
||||
于是,我们知道在裸机平台上我们要将对于标准库 std 的引用换成核心库 core。但是做起来其实还要有一些琐碎的事情需要解决。
|
||||
user的库是如何调用到os的系统调用的呢?实际上转lab2.
|
||||
|
|
|
@ -1,177 +1,91 @@
|
|||
.. _term-remove-std:
|
||||
|
||||
移除标准库依赖
|
||||
makefile和qemu
|
||||
==========================
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
:maxdepth: 5
|
||||
|
||||
|
||||
|
||||
|
||||
本节导读
|
||||
-------------------------------
|
||||
|
||||
为了很好地理解一个简单应用所需的服务如何体现,本节将尝试开始构造一个小的执行环境,可建立在 Linux 之上,也可直接建立在裸机之上,我们称为“三叶虫”操作系统。作为第一步,本节将尝试移除之前的 ``Hello world!`` 程序对于 Rust std 标准库的依赖,使得它能够编译到裸机平台 RV64GC 或 Linux-RV64 上。
|
||||
为了帮助大家进一步理解我们的项目的链接和编译的过程,这里简要介绍一下我们makefile的工作机制。由于涉及到CI的判定,因此大家最好不要自己修改makefile。
|
||||
|
||||
移除 println! 宏
|
||||
makefile内部
|
||||
----------------------------------
|
||||
|
||||
我们首先在 ``os`` 目录下新建 ``.cargo`` 目录,并在这个目录下创建 ``config`` 文件,并在里面输入如下内容:
|
||||
指定编译使用的工具
|
||||
|
||||
.. code-block:: toml
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
# os/.cargo/config
|
||||
[build]
|
||||
target = "riscv64gc-unknown-none-elf"
|
||||
::
|
||||
TOOLPREFIX = riscv64-unknown-elf-
|
||||
CC = $(TOOLPREFIX)gcc
|
||||
AS = $(TOOLPREFIX)gas
|
||||
LD = $(TOOLPREFIX)ld
|
||||
OBJCOPY = $(TOOLPREFIX)objcopy
|
||||
OBJDUMP = $(TOOLPREFIX)objdump
|
||||
GDB = $(TOOLPREFIX)gdb
|
||||
|
||||
.. _term-cross-compile:
|
||||
这里makefile调用了大家设定好的PATH之中的riscv64工具链。如果没有设置好,那么之后的编译就会因为找不到这些文件而出错。
|
||||
|
||||
这会对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu。事实上,这是一种编译器运行所在的平台与编译器生成可执行文件的目标平台不同(分别是后者和前者)的情况。这是一种 **交叉编译** (Cross Compile)。
|
||||
添加编译flag
|
||||
|
||||
..
|
||||
chyyuu:解释一下交叉编译???
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
当然,这只是使得我们之后在 ``cargo build`` 的时候不必再加上 ``--target`` 参数的一个小 trick。如果我们现在 ``cargo build`` ,还是会和上一小节一样出现找不到标准库 std 的错误。于是我们开始着手移除标准库,当然,这会产生一些副作用。
|
||||
::
|
||||
CFLAGS = -Wall -Werror -O -fno-omit-frame-pointer -ggdb
|
||||
CFLAGS += -MD
|
||||
CFLAGS += -mcmodel=medany
|
||||
CFLAGS += -ffreestanding -fno-common -nostdlib -mno-relax
|
||||
CFLAGS += -I.
|
||||
CFLAGS += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
|
||||
|
||||
我们在 ``main.rs`` 的开头加上一行 ``#![no_std]`` 来告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core。编译器报出如下错误:
|
||||
这里添加了编译的flag。比较需要注意的是我们设置了警告也会报错,因此大家写代码的时候最好避免warning的出现。
|
||||
|
||||
.. error::
|
||||
编译出目标文件以及qemu
|
||||
|
||||
.. code-block:: console
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
$ cargo build
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
error: cannot find macro `println` in this scope
|
||||
--> src/main.rs:4:5
|
||||
|
|
||||
4 | println!("Hello, world!");
|
||||
| ^^^^^^^
|
||||
::
|
||||
kernel: $(OBJS) kernel.ld
|
||||
$(LD) $(LDFLAGS) -T kernel.ld -o kernel $(OBJS)
|
||||
|
||||
我们之前提到过, println! 宏是由标准库 std 提供的,且会使用到一个名为 write 的系统调用。现在我们的代码功能还不足以自己实现一个 println! 宏。由于使用了系统调用也不能在核心库 core 中找到它,所以我们目前先通过将它注释掉来绕过它。
|
||||
clean:
|
||||
rm -f *.o *.d kernel
|
||||
|
||||
提供语义项 panic_handler
|
||||
----------------------------------------------------
|
||||
上面两个是编译的选项。可以使用make + name 来调用它们。比较常用的就是make clean,可以清除所有生成出来的 .o以及.d中间文件和生成的kernel。
|
||||
::
|
||||
QEMU = qemu-system-riscv64
|
||||
QEMUOPTS = \
|
||||
-nographic \
|
||||
-smp $(CPUS) \
|
||||
-machine virt \
|
||||
-bios $(BOOTLOADER) \
|
||||
-kernel kernel
|
||||
|
||||
.. error::
|
||||
run: kernel
|
||||
$(QEMU) $(QEMUOPTS)
|
||||
|
||||
.. code-block:: console
|
||||
这个就是最关键的地方:make run。我们查看这条指令的结构,它首先执行上面kernel所需要的链接以及编译操作得到一个二进制的kernel。之后执行按照QEMUOPTS变量指定的参数启动qemu。那么QEMUOPTS之中的flag有什么意义呢?
|
||||
- nographic: 无图形界面
|
||||
- smp 1: 单核
|
||||
- machine virt: 模拟硬件 RISC-V VirtIO Board
|
||||
- bios ...: 使用制定 bios,这里指向的是我们提供的rustsbi的bin文件。
|
||||
- device loader ...: 增加 loader 类型的 device, 这里其实就是把我们的 os 文件放到指定位置。
|
||||
因此qemu会按照上述的参数启动,使用我们的rustsbi来进行一系列初始化,并将程序计数器移动至0x80200000并开始执行我们的OS。我们之后所有执行测试都是使用的make run指令。
|
||||
|
||||
$ cargo build
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
error: `#[panic_handler]` function required, but not found
|
||||
下面是使用gdb需要的指令,本章也会使用到这些指令。
|
||||
::
|
||||
# QEMU's gdb stub command line changed in 0.11
|
||||
QEMUGDB = $(shell if $(QEMU) -help | grep -q '^-gdb'; \
|
||||
then echo "-gdb tcp::1234"; \
|
||||
else echo "-s -p 1234"; fi)
|
||||
|
||||
在使用 Rust 编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误导致程序无法继续向下运行的时候手动或自动调用 panic! 宏来并打印出错的位置让我们能够意识到它的存在,并进行一些后续处理。panic! 宏最典型的应用场景包括断言宏 assert! 失败或者对 ``Option::None/Result::Err`` 进行 ``unwrap`` 操作。
|
||||
debug: kernel .gdbinit
|
||||
$(QEMU) $(QEMUOPTS) -S $(QEMUGDB) &
|
||||
sleep 1
|
||||
$(GDB)
|
||||
|
||||
在标准库 std 中提供了 panic 的处理函数 ``#[panic_handler]``,其大致功能是打印出错位置和原因并杀死当前应用。可惜的是在核心库 core 中并没有提供,因此我们需要自己实现 panic 处理函数。
|
||||
|
||||
.. note::
|
||||
|
||||
**Rust 语法卡片:语义项 lang_items**
|
||||
|
||||
Rust 编译器内部的某些功能的实现并不是硬编码在语言内部的,而是以一种可插入的形式在库中提供。库只需要通过某种方式告诉编译器它的某个方法实现了编译器内部的哪些功能,编译器就会采用库提供的方法来实现它内部对应的功能。通常只需要在库的方法前面加上一个标记即可。
|
||||
|
||||
我们开一个新的子模块 ``lang_items.rs`` 保存这些语义项,在里面提供 panic 处理函数的实现并通过标记通知编译器采用我们的实现:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/lang_items.rs
|
||||
use core::panic::PanicInfo;
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(_info: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
|
||||
注意,panic 处理函数的函数签名需要一个 ``PanicInfo`` 的不可变借用作为输入参数,它在核心库中得以保留,这也是我们第一次与核心库打交道。之后我们会从 ``PanicInfo`` 解析出错位置并打印出来,然后杀死应用程序。但目前我们什么都不做只是在原地 ``loop`` 。
|
||||
|
||||
移除 main 函数
|
||||
-----------------------------
|
||||
|
||||
.. error::
|
||||
|
||||
.. code-block::
|
||||
|
||||
$ cargo build
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
error: requires `start` lang_item
|
||||
|
||||
编译器提醒我们缺少一个名为 ``start`` 的语义项。我们回忆一下,之前提到语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的 ``main`` 函数)开始执行。事实上 ``start`` 语义项正代表着标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
|
||||
|
||||
最简单的解决方案就是压根不让编译器使用这项功能。我们在 ``main.rs`` 的开头加入设置 ``#![no_main]`` 告诉编译器我们没有一般意义上的 ``main`` 函数,并将原来的 ``main`` 函数删除。在失去了 ``main`` 函数的情况下,编译器也就不需要完成所谓的初始化工作了。
|
||||
|
||||
至此,我们成功移除了标准库的依赖并完成裸机平台上的构建。
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo build
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
|
||||
|
||||
目前的代码如下:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/main.rs
|
||||
#![no_std]
|
||||
#![no_main]
|
||||
|
||||
mod lang_items;
|
||||
|
||||
// os/src/lang_items.rs
|
||||
use core::panic::PanicInfo;
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(_info: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
|
||||
本小节我们固然脱离了标准库,通过了编译器的检验,但也是伤筋动骨,将原有的很多功能弱化甚至直接删除,看起来距离在 RV64GC 平台上打印 ``Hello world!`` 相去甚远了(我们甚至连 println! 和 ``main`` 函数都删除了)。不要着急,接下来我们会以自己的方式来重塑这些基本功能,并最终完成我们的目标。
|
||||
|
||||
|
||||
分析被移除标准库的程序
|
||||
-----------------------------
|
||||
|
||||
对于上面这个被移除标准库的应用程序,通过了编译器的检查和编译,形成了二进制代码。但这个二进制代码是怎样的,它能否被正常执行呢?我们可以通过一些工具来分析一下。
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
[文件格式]
|
||||
$ file target/riscv64gc-unknown-none-elf/debug/os
|
||||
target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, ......
|
||||
|
||||
[文件头信息]
|
||||
$ rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os
|
||||
File: target/riscv64gc-unknown-none-elf/debug/os
|
||||
Format: elf64-littleriscv
|
||||
Arch: riscv64
|
||||
AddressSize: 64bit
|
||||
......
|
||||
Type: Executable (0x2)
|
||||
Machine: EM_RISCV (0xF3)
|
||||
Version: 1
|
||||
Entry: 0x0
|
||||
......
|
||||
}
|
||||
|
||||
[反汇编导出汇编程序]
|
||||
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
|
||||
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
|
||||
|
||||
|
||||
|
||||
通过 ``file`` 工具对二进制程序 ``os`` 的分析可以看到它好像是一个合法的 RV64 执行程序,但通过 ``rust-readobj`` 工具进一步分析,发现它的入口地址 Entry 是 ``0`` ,这就比较奇怪了,地址从 0 执行,好像不对。再通过 ``rust-objdump`` 工具把它反汇编,可以看到没有生成汇编代码。所以,我们可以断定,这个二进制程序虽然合法,但它是一个空程序。这不是我们希望的,我们希望有具体内容的执行程序。为什么会这样呢?原因是我们缺少了编译器需要找到的入口函数 ``_start`` 。
|
||||
|
||||
在下面几节,我们将建立有支持显示字符串的最小执行环境。
|
||||
|
||||
.. note::
|
||||
|
||||
**在 x86_64 平台上移除标准库依赖**
|
||||
|
||||
有兴趣的同学可以将目标平台换回之前默认的 ``x86_64-unknown-linux-gnu`` 并重复本小节所做的事情,比较两个平台从 ISA 到操作系统
|
||||
的差异。可以参考 `BlogOS 的相关内容 <https://os.phil-opp.com/freestanding-rust-binary/>`_ 。
|
||||
|
||||
.. note::
|
||||
|
||||
本节内容部分参考自 `BlogOS 的相关章节 <https://os.phil-opp.com/freestanding-rust-binary/>`_ 。
|
||||
使用make debug来使用gdb调试qemu。程序自身执行的机制和直接make run一样。在解析bootloader的行为时可以使用gdb在其中添加断点来查看对应寄存器和内存的内容。gdb的具体使用方法和汇编课程上一致。不熟悉的同学可以在训练章节查看到可能用到的gdb指令的用法。
|
|
@ -1,299 +0,0 @@
|
|||
.. _term-print-userminienv:
|
||||
|
||||
构建用户态执行环境
|
||||
=================================
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
:maxdepth: 5
|
||||
|
||||
本节导读
|
||||
-------------------------------
|
||||
|
||||
本节开始我们将着手自己来实现之前被我们移除的 ``Hello, world!`` 程序中执行环境的功能。
|
||||
在这一小节,我们介绍如何进行 **执行环境初始化** 。
|
||||
|
||||
在这里,我们先设计实现一个最小执行环境以支持最简单的用户态 ``Hello, world!`` 程序,再改进这个最小执行环境,支持对裸机应用程序。这样设计实现的原因是,
|
||||
它能帮助我们理解这两个不同的执行环境在支持同样一个应用程序时的的相同和不同之处,这将加深对执行环境的理解,并对后续写自己的OS和运行在OS上的应用程序都有帮助。
|
||||
所以,本节将先建立一个用户态的最小执行环境,即 **恐龙虾** 操作系统。
|
||||
|
||||
用户态最小化执行环境
|
||||
----------------------------
|
||||
|
||||
在上一节,我们构造的二进制程序是一个空程序,其原因是 Rust 编译器找不到执行环境的入口函数,于是就没有生产后续的代码。所以,我们首先要把入口函数
|
||||
找到。通过查找资料,发现Rust编译器要找的入口函数是 ``_start()`` ,于是我们可以在 ``main.rs`` 中添加如下内容:
|
||||
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/main.rs
|
||||
#[no_mangle]
|
||||
extern "C" fn _start() {
|
||||
loop{};
|
||||
}
|
||||
|
||||
|
||||
对上述代码重新编译,再用分析工具分析,可以看到:
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo build
|
||||
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
|
||||
|
||||
[文件格式]
|
||||
$ file target/riscv64gc-unknown-none-elf/debug/os
|
||||
target/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, ......
|
||||
|
||||
[文件头信息]
|
||||
$ rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os
|
||||
File: target/riscv64gc-unknown-none-elf/debug/os
|
||||
Format: elf64-littleriscv
|
||||
Arch: riscv64
|
||||
AddressSize: 64bit
|
||||
......
|
||||
Type: Executable (0x2)
|
||||
Machine: EM_RISCV (0xF3)
|
||||
Version: 1
|
||||
Entry: 0x11120
|
||||
......
|
||||
}
|
||||
|
||||
[反汇编导出汇编程序]
|
||||
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
|
||||
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
|
||||
|
||||
Disassembly of section .text:
|
||||
|
||||
0000000000011120 <_start>:
|
||||
; loop {}
|
||||
11120: 09 a0 j 2 <_start+0x2>
|
||||
11122: 01 a0 j 0 <_start+0x2>
|
||||
|
||||
|
||||
通过 ``file`` 工具对二进制程序 ``os`` 的分析可以看到它依然是一个合法的 RV64 执行程序,但通过 ``rust-readobj`` 工具进一步分析,发现它的入口地址 Entry 是 ``0x11120`` ,这好像是一个合法的地址。再通过 ``rust-objdump`` 工具把它反汇编,可以看到生成汇编代码!
|
||||
|
||||
所以,我们可以断定,这个二进制程序虽然合法,但它是一个空程序。这不是我们希望的,我们希望有具体内容的执行程序。为什么会这样呢?
|
||||
|
||||
仔细读读这两条指令,发现就是一个死循环的汇编代码,且其第一条指令的地址与入口地址 Entry 的值一致。这已经是一个合理的程序了。如果我们用 ``qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os`` 执行这个程序,可以看到好像就是在执行死循环。
|
||||
|
||||
我们能让程序正常退出吗?我们把 ``_start()`` 函数中的循环语句注释掉,重新编译并分析,看到其汇编代码是:
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os
|
||||
|
||||
target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv
|
||||
|
||||
|
||||
Disassembly of section .text:
|
||||
|
||||
0000000000011120 <_start>:
|
||||
; }
|
||||
11120: 82 80 ret
|
||||
|
||||
看起来是有内容(具有 ``ret`` 函数返回汇编指令)且合法的执行程序。但如果我们执行它,就发现有问题了:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os
|
||||
段错误 (核心已转储)
|
||||
|
||||
*段错误 (核心已转储)* 是常见的一种应用程序出错,而我们这个非常简单的应用程序导致了 Linux 环境模拟程序 ``qemu-riscv64`` 崩溃了!为什么会这样?
|
||||
|
||||
.. _term-qemu-riscv64:
|
||||
|
||||
.. note::
|
||||
|
||||
QEMU有两种运行模式: ``User mode`` 模式,即用户态模拟,如 ``qemu-riscv64`` 程序,能够模拟不同处理器的用户态指令的执行,并可以直接解析ELF可执行文件,加载运行那些为不同处理器编译的用户级Linux应用程序(ELF可执行文件);在翻译并执行不同应用程序中的不同处理器的指令时,如果碰到是系统调用相关的汇编指令,它会把不同处理器(如RISC-V)的Linux系统调用转换为本机处理器(如x86-64)上的Linux系统调用,这样就可以让本机Linux完成系统调用,并返回结果(再转换成RISC-V能识别的数据)给这些应用。 ``System mode`` 模式,即系统态模式,如 ``qemu-system-riscv64`` 程序,能够模拟一个完整的基于不同CPU的硬件系统,包括处理器、内存及其他外部设备,支持运行完整的操作系统。
|
||||
|
||||
回顾一下最开始的输出 ``Hello, world!`` 的简单应用程序,其入口函数名字是 ``main`` ,编译时用的是标准库 std 。它可以正常执行。再仔细想想,当一个应用程序出错的时候,最上层为操作系统的执行环境会把它给杀死。但如果一个应用的入口函数正常返回,执行环境应该优雅地让它退出才对。没错!目前的执行环境还缺了一个退出机制。
|
||||
|
||||
先了解一下,操作系统会提供一个退出的系统调用服务接口,但应用程序调用这个接口,那这个程序就退出了。这里先给出代码:
|
||||
|
||||
.. _term-llvm-syscall:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/main.rs
|
||||
#![feature(llvm_asm)]
|
||||
|
||||
const SYSCALL_EXIT: usize = 93;
|
||||
|
||||
fn syscall(id: usize, args: [usize; 3]) -> isize {
|
||||
let mut ret: isize;
|
||||
unsafe {
|
||||
llvm_asm!("ecall"
|
||||
: "={x10}" (ret)
|
||||
: "{x10}" (args[0]), "{x11}" (args[1]), "{x12}" (args[2]), "{x17}" (id)
|
||||
: "memory"
|
||||
: "volatile"
|
||||
);
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn sys_exit(xstate: i32) -> isize {
|
||||
syscall(SYSCALL_EXIT, [xstate as usize, 0, 0])
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn _start() {
|
||||
sys_exit(9);
|
||||
}
|
||||
|
||||
``main.rs`` 增加的内容不多,但还是有点与一般的应用程序有所不同,因为它引入了汇编和系统调用。如果你看不懂上面内容的细节,没关系,在第二章的第二节 :doc:`/chapter2/2application` 会有详细的介绍。这里只需知道 ``_start`` 函数调用了一个 ``sys_exit`` 函数,来向操作系统发出一个退出服务的系统调用请求,并传递给OS的退出码为 ``9`` 。
|
||||
|
||||
我们编译执行以下修改后的程序:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo build --target riscv64gc-unknown-none-elf
|
||||
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 0.26s
|
||||
|
||||
[$?表示执行程序的退出码,它会被告知 OS]
|
||||
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $?
|
||||
9
|
||||
|
||||
可以看到,返回的结果确实是 ``9`` 。这样,我们在没有任何显示功能的情况下,勉强完成了一个简陋的用户态最小化执行环境。
|
||||
|
||||
上面实现的最小化执行环境貌似能够在 Linux 操作系统上支持只调用一个 ``SYSCALL_EXIT`` 系统调用服务的程序,但这也说明了
|
||||
在操作系统的支持下,实现一个基本的用户态执行环境还是比较容易的。其中的原因是,操作系统帮助用户态执行环境完成了程序加载、程序退出、资源分配、资源回收等各种琐事。如果没有操作系统,那么实现一个支持在裸机上运行应用程序的执行环境,就要考虑更多的事情了,或者干脆简化一切可以不必干的事情(比如对于单个应用,不需要调度功能等)。
|
||||
|
||||
在裸机上的执行环境,其实就是之前提到的“三叶虫”操作系统。
|
||||
|
||||
|
||||
有显示支持的用户态执行环境
|
||||
----------------------------
|
||||
|
||||
没有显示功能,终究觉得缺了点啥。在没有通常开发应用程序时常用的动态调试工具的情况下,其实能显示字符串,就已经能够满足绝大多数情况下的调试需求了。
|
||||
|
||||
Rust 的 core 库内建了以一系列帮助实现显示字符的基本 Trait 和数据结构,函数等,我们可以对其中的关键部分进行扩展,就可以实现定制的 ``println!`` 功能。
|
||||
|
||||
|
||||
实现输出字符串的相关函数
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
首先封装一下对 ``SYSCALL_WRITE`` 系统调用。这个是 Linux 操作系统内核提供的系统调用,其 ``ID`` 就是 ``SYSCALL_WRITE``。
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
const SYSCALL_WRITE: usize = 64;
|
||||
|
||||
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
|
||||
syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
|
||||
}
|
||||
|
||||
然后实现基于 ``Write`` Trait 的数据结构,并完成 ``Write`` Trait 所需要的 ``write_str`` 函数,并用 ``print`` 函数进行包装。
|
||||
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
struct Stdout;
|
||||
|
||||
impl Write for Stdout {
|
||||
fn write_str(&mut self, s: &str) -> fmt::Result {
|
||||
sys_write(1, s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print(args: fmt::Arguments) {
|
||||
Stdout.write_fmt(args).unwrap();
|
||||
}
|
||||
|
||||
最后,实现基于 ``print`` 函数,实现Rust语言 **格式化宏** ( `formatting macros <https://doc.rust-lang.org/std/fmt/#related-macros>`_ )。
|
||||
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! print {
|
||||
($fmt: literal $(, $($arg: tt)+)?) => {
|
||||
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! println {
|
||||
($fmt: literal $(, $($arg: tt)+)?) => {
|
||||
print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
|
||||
}
|
||||
}
|
||||
|
||||
上面的代码没有读懂?没关系,你只要了解到应用程序发出的宏调用 ``println!`` 就是通过上面的实现,一步一步地调用,最终通过操作系统提供的 ``SYSCALL_WRITE`` 系统调用服务,帮助我们完成了字符串显示输出。这就完成了有显示支持的用户态执行环境。
|
||||
|
||||
接下来,我们调整一下应用程序,让它发出显示字符串和退出的请求:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn _start() {
|
||||
println!("Hello, world!");
|
||||
sys_exit(9);
|
||||
}
|
||||
|
||||
整体工作完成!当然,我们实现的很简陋,用户态执行环境和应用程序都放在一个文件里面,以后会通过我们学习的软件工程的知识,进行软件重构,让代码更清晰和模块化。
|
||||
|
||||
现在,我们编译并执行一下,可以看到正确的字符串输出,且程序也能正确结束!
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo build --target riscv64gc-unknown-none-elf
|
||||
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 0.61s
|
||||
|
||||
$ qemu-riscv64 target/riscv64gc-unknown-none-elf/debug/os; echo $?
|
||||
Hello, world!
|
||||
9
|
||||
|
||||
|
||||
.. 下面出错的情况是会在采用 linker.ld,加入了 .cargo/config
|
||||
.. 的内容后会出错:
|
||||
.. .. [build]
|
||||
.. .. target = "riscv64gc-unknown-none-elf"
|
||||
.. .. [target.riscv64gc-unknown-none-elf]
|
||||
.. .. rustflags = [
|
||||
.. .. "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
|
||||
.. .. ]
|
||||
|
||||
.. 重新定义了栈和地址空间布局后才会出错
|
||||
|
||||
.. 段错误 (核心已转储)
|
||||
|
||||
.. 系统崩溃了!借助以往的操作系统内核编程经验和与下一节调试kernel的成果经验,我们直接定位为是 **栈** (Stack) 没有设置的问题。我们需要添加建立栈的代码逻辑。
|
||||
|
||||
.. .. code-block:: asm
|
||||
|
||||
.. # entry.asm
|
||||
|
||||
.. .section .text.entry
|
||||
.. .globl _start
|
||||
.. _start:
|
||||
.. la sp, boot_stack_top
|
||||
.. call rust_main
|
||||
|
||||
.. .section .bss.stack
|
||||
.. .globl boot_stack
|
||||
.. boot_stack:
|
||||
.. .space 4096 * 16
|
||||
.. .globl boot_stack_top
|
||||
.. boot_stack_top:
|
||||
|
||||
.. 然后把汇编代码嵌入到 ``main.rs`` 中,并进行微调。
|
||||
|
||||
.. .. code-block:: rust
|
||||
|
||||
.. #![feature(global_asm)]
|
||||
|
||||
.. global_asm!(include_str!("entry.asm"));
|
||||
|
||||
.. #[no_mangle]
|
||||
.. #[link_section=".text.entry"]
|
||||
.. extern "C" fn rust_main() {
|
||||
|
||||
.. 再次编译执行,可以看到正确的字符串输出,且程序也能正确结束!
|
|
@ -1,561 +0,0 @@
|
|||
.. _term-print-kernelminienv:
|
||||
|
||||
构建裸机运行时执行环境
|
||||
=================================
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
:maxdepth: 5
|
||||
|
||||
本节导读
|
||||
-------------------------------
|
||||
|
||||
本节开始我们将着手自己来实现裸机上的最小执行环境,即我们的“三叶虫”操作系统,并能在裸机上运行 ``Hello, world!`` 程序。
|
||||
有了上一节实现的用户态的最小执行环境,我们可以稍加改造,就可以完成裸机上的最小执行环境了。与上节不同,需要关注地方主要是:
|
||||
|
||||
- 物理内存的 DRAM 位置(放应用程序的地方)和应用程序的内存布局(如何在 DRAM 中放置应用程序的各个部分)
|
||||
- SBI 的字符输出接口(执行环境提供的输出字符服务,可以被应用程序使用)
|
||||
- 应用程序的初始化(起始的指令位置,对 ``栈 stack`` 和 ``bss`` 的初始化)
|
||||
|
||||
|
||||
了解硬件组成和裸机启动过程
|
||||
----------------------------
|
||||
|
||||
在这一小节,我们介绍如何进行 **执行环境初始化** 。我们在上一小节提到过,一个应用程序的运行离不开下面多层执行环境栈的支撑。
|
||||
以 ``Hello, world!`` 程序为例,在目前广泛使用的操作系统上,它就至少需要经历以下层层递进的初始化过程:
|
||||
|
||||
- 启动OS:硬件启动后,会有一段代码(一般统称为bootloader)对硬件进行初始化,让包括内核在内的系统软件得以运行;
|
||||
- OS准备好应用程序执行的环境:要运行该应用程序的时候,内核分配相应资源,将程序代码和数据载入内存,并赋予 CPU 使用权,由此应用程序可以运行;
|
||||
- 应用程序开始执行:程序员编写的代码是应用程序的一部分,它需要标准库/核心库进行一些初始化工作后才能运行。
|
||||
|
||||
不过我们的目标是实现在裸机上执行的应用。由于目标平台 ``riscv64gc-unknown-none-elf`` 没有任何操作系统支持,我们只能禁用标准库并移除默认的 main 函数
|
||||
入口。但是最终我们还是要将 main 函数恢复回来并且输出 ``Hello, world!`` 的。因此,我们需要知道具体需要做哪些初始化工作才能支持
|
||||
应用程序在裸机上的运行。
|
||||
|
||||
而这又需要明确三点:首先,应用程序的裸机硬件系统是啥样子的?其次,系统在做这些初始化工作之前处于什么状态;最后,在做完初始化工作也就是即将执行 main 函数之前又处于什么状态。比较二者
|
||||
即可得出答案。
|
||||
|
||||
硬件组成
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
我们采用的是QEMU软件 ``qemu-system-riscv64`` 来模拟一台RISC-V 64计算机,具体的硬件规格是:
|
||||
- 外设:16550A UART,virtio-net/block/console/gpu等和设备树
|
||||
- 硬件特权级:priv v1.10, user v2.2
|
||||
- 中断控制器:可参数化的CLINT(核心本地中断器)、可参数化的PLIC(平台级中断控制器)
|
||||
- 可参数化的RAM内存
|
||||
- 可配置的多核 RV64GC M/S/U mode CPU
|
||||
|
||||
这里列出的硬件功能很多还用不上,不过在后面的章节中会逐步用到上面的硬件功能,以支持更加强大的操作系统能力。
|
||||
|
||||
在QEMU模拟的硬件中,物理内存和外设都是通过对内存读写的方式来进行访问,下面列出了QEMU模拟的物理内存空间。
|
||||
|
||||
.. code-block:: c
|
||||
|
||||
// qemu/hw/riscv/virt.c
|
||||
static const struct MemmapEntry {
|
||||
hwaddr base;
|
||||
hwaddr size;
|
||||
} virt_memmap[] = {
|
||||
[VIRT_DEBUG] = { 0x0, 0x100 },
|
||||
[VIRT_MROM] = { 0x1000, 0xf000 },
|
||||
[VIRT_TEST] = { 0x100000, 0x1000 },
|
||||
[VIRT_RTC] = { 0x101000, 0x1000 },
|
||||
[VIRT_CLINT] = { 0x2000000, 0x10000 },
|
||||
[VIRT_PCIE_PIO] = { 0x3000000, 0x10000 },
|
||||
[VIRT_PLIC] = { 0xc000000, VIRT_PLIC_SIZE(VIRT_CPUS_MAX * 2) },
|
||||
[VIRT_UART0] = { 0x10000000, 0x100 },
|
||||
[VIRT_VIRTIO] = { 0x10001000, 0x1000 },
|
||||
[VIRT_FLASH] = { 0x20000000, 0x4000000 },
|
||||
[VIRT_PCIE_ECAM] = { 0x30000000, 0x10000000 },
|
||||
[VIRT_PCIE_MMIO] = { 0x40000000, 0x40000000 },
|
||||
[VIRT_DRAM] = { 0x80000000, 0x0 },
|
||||
};
|
||||
|
||||
|
||||
到现在为止,其中比较重要的两个是:
|
||||
- VIRT_DRAM:DRAM的内存起始地址是 ``0x80000000`` ,缺省大小为128MB。在本书中一般限制为8MB。
|
||||
- VIRT_UART0:串口相关的寄存器起始地址是 ``0x10000000`` ,范围是 ``0x100`` ,我们通过访问这段特殊的区域来实现字符输入输出的管理与控制。
|
||||
|
||||
.. _term-bootloader:
|
||||
|
||||
|
||||
裸机启动过程
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. note::
|
||||
|
||||
**QEMU 模拟 CPU 加电的执行过程**
|
||||
|
||||
CPU加电后的执行细节与具体硬件相关,我们这里以QEMU模拟器为具体例子简单介绍一下。
|
||||
|
||||
这需要从 CPU 加电后如何初始化,如何执行第一条指令开始讲起。对于我们采用的QEMU模拟器而言,它模拟了一台标准的RISC-V64计算机。我们启动QEMU时,可设置一些参数,在RISC-V64计算机启动执行前,先在其模拟的内存中放置好BootLoader程序和操作系统的二进制代码。这可以通过查看 ``os/Makefile`` 文件中包含 ``qemu-system-riscv64`` 的相关内容来了解。
|
||||
|
||||
- ``-bios $(BOOTLOADER)`` 这个参数意味着硬件内存中的固定位置 ``0x80000000`` 处放置了一个BootLoader程序--RustSBI(戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。)。
|
||||
- ``-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)`` 这个参数表示硬件内存中的特定位置 ``$(KERNEL_ENTRY_PA)`` 放置了操作系统的二进制代码 ``$(KERNEL_BIN)`` 。 ``$(KERNEL_ENTRY_PA)`` 的值是 ``0x80200000`` 。
|
||||
|
||||
当我们执行包含上次参数的qemu-system-riscv64软件,就意味给这台虚拟的RISC-V64计算机加电了。此时,CPU的其它通用寄存器清零,
|
||||
而PC寄存器会指向 ``0x1000`` 的位置。
|
||||
这个 ``0x1000`` 位置上是CPU加电后执行的第一条指令(固化在硬件中的一小段引导代码),它会很快跳转到 ``0x80000000`` 处,
|
||||
即RustSBI的第一条指令。RustSBI完成基本的硬件初始化后,
|
||||
会跳转操作系统的二进制代码 ``$(KERNEL_BIN)`` 所在内存位置 ``0x80200000`` ,执行操作系统的第一条指令。
|
||||
这时我们的编写的操作系统才开始正式工作。
|
||||
|
||||
为啥在 ``0x80000000`` 放置 ``Bootloader`` ?因为这是QEMU的硬件模拟代码中设定好的 ``Bootloader`` 的起始地址。
|
||||
|
||||
为啥在 ``0x80200000`` 放置 ``os`` ?因为这是 ``Bootloader--RustSBI`` 的代码中设定好的 ``os`` 的起始地址。
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
**操作系统与SBI之间是啥关系?**
|
||||
|
||||
SBI是RISC-V的一种底层规范,操作系统内核与实现SBI规范的RustSBI的关系有点象应用与操作系统内核的关系,后者向前者提供一定的服务。只是SBI提供的服务很少,
|
||||
能帮助操作系统内核完成的功能有限,但这些功能很底层,很重要,比如关机,显示字符串等。通过操作系统内核也能直接实现,但比较繁琐,如果RustSBI提供了服务,
|
||||
那么操作系统内核直接调用就好了。
|
||||
|
||||
|
||||
.. warning::
|
||||
|
||||
**FIXME: 提供一下分析展示**
|
||||
|
||||
实现关机功能
|
||||
----------------------------
|
||||
|
||||
如果在裸机上的应用程序执行完毕并通知操作系统后,那么“三叶虫”操作系统就没事干了,实现正常关机是一个合理的选择。所以我们要让“三叶虫”操作系统能够正常关机,这是需要调用SBI提供的关机功能 ``SBI_SHUTDOWN`` ,这与上一节的 ``SYSCALL_EXIT`` 类似,
|
||||
只是在具体参数上有所不同。在上一节完成的没有显示功能的用户态最小化执行环境基础上,修改后的代码如下:
|
||||
|
||||
.. _term-llvm-sbicall:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// bootloader/rustsbi-qemu.bin 直接添加的SBI规范实现的二进制代码,给操作系统提供基本支持服务
|
||||
|
||||
// os/src/sbi.rs
|
||||
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
|
||||
let mut ret;
|
||||
unsafe {
|
||||
llvm_asm!("ecall"
|
||||
: "={x10}" (ret)
|
||||
: "{x10}" (arg0), "{x11}" (arg1), "{x12}" (arg2), "{x17}" (which)
|
||||
...
|
||||
|
||||
// os/src/main.rs
|
||||
const SBI_SHUTDOWN: usize = 8;
|
||||
|
||||
pub fn shutdown() -> ! {
|
||||
sbi_call(SBI_SHUTDOWN, 0, 0, 0);
|
||||
panic!("It should shutdown!");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn _start() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
也许有同学比较迷惑,应用程序访问操作系统提供的系统调用的指令是 ``ecall`` ,操作系统访问
|
||||
RustSBI提供的SBI服务的SBI调用的指令也是 ``ecall`` 。
|
||||
这其实是没有问题的,虽然指令一样,但它们所在的特权级和特权级转换是不一样的。简单地说,应用程序位于最弱的用户特权级(User Mode),操作系统位于
|
||||
很强大的内核特权级(Supervisor Mode),RustSBI位于完全掌控机器的机器特权级(Machine Mode),通过 ``ecall`` 指令,可以完成从弱的特权级
|
||||
到强的特权级的转换。具体细节,可以看下一章的进一步描述。在这里,只要知道如果“三叶虫”操作系统正确地向RustSBI发出了停机的SBI服务请求,
|
||||
那么RustSBI能够通知QEMU模拟的RISC-V计算机停机(即 ``qemu-system-riscv64`` 软件能正常退出)就行了。
|
||||
|
||||
下面是编译执行,结果如下:
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
# 编译生成ELF格式的执行文件
|
||||
$ cargo build --release
|
||||
Compiling os v0.1.0 (/media/chyyuu/ca8c7ba6-51b7-41fc-8430-e29e31e5328f/thecode/rust/os_kernel_lab/os)
|
||||
Finished release [optimized] target(s) in 0.15s
|
||||
# 把ELF执行文件转成bianary文件
|
||||
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os --strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin
|
||||
|
||||
#加载运行
|
||||
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
|
||||
# 无法退出,风扇狂转,感觉碰到死循环
|
||||
|
||||
这样的结果是我们不期望的。问题在哪?仔细查看和思考,操作系统的入口地址不对!对 ``os`` ELF执行程序,通过rust-readobj分析,看到的入口地址不是
|
||||
RustSBIS约定的 ``0x80200000`` 。我们需要修改 ``os`` ELF执行程序的内存布局。
|
||||
|
||||
|
||||
设置正确的程序内存布局
|
||||
----------------------------
|
||||
|
||||
.. _term-linker-script:
|
||||
|
||||
我们可以通过 **链接脚本** (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。
|
||||
我们修改 Cargo 的配置文件来使用我们自己的链接脚本 ``os/src/linker.ld`` 而非使用默认的内存布局:
|
||||
|
||||
.. code-block::
|
||||
:linenos:
|
||||
:emphasize-lines: 5,6,7,8
|
||||
|
||||
// os/.cargo/config
|
||||
[build]
|
||||
target = "riscv64gc-unknown-none-elf"
|
||||
|
||||
[target.riscv64gc-unknown-none-elf]
|
||||
rustflags = [
|
||||
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
|
||||
]
|
||||
|
||||
具体的链接脚本 ``os/src/linker.ld`` 如下:
|
||||
|
||||
.. code-block::
|
||||
:linenos:
|
||||
|
||||
OUTPUT_ARCH(riscv)
|
||||
ENTRY(_start)
|
||||
BASE_ADDRESS = 0x80200000;
|
||||
|
||||
SECTIONS
|
||||
{
|
||||
. = BASE_ADDRESS;
|
||||
skernel = .;
|
||||
|
||||
stext = .;
|
||||
.text : {
|
||||
*(.text.entry)
|
||||
*(.text .text.*)
|
||||
}
|
||||
|
||||
. = ALIGN(4K);
|
||||
etext = .;
|
||||
srodata = .;
|
||||
.rodata : {
|
||||
*(.rodata .rodata.*)
|
||||
}
|
||||
|
||||
. = ALIGN(4K);
|
||||
erodata = .;
|
||||
sdata = .;
|
||||
.data : {
|
||||
*(.data .data.*)
|
||||
}
|
||||
|
||||
. = ALIGN(4K);
|
||||
edata = .;
|
||||
.bss : {
|
||||
*(.bss.stack)
|
||||
sbss = .;
|
||||
*(.bss .bss.*)
|
||||
}
|
||||
|
||||
. = ALIGN(4K);
|
||||
ebss = .;
|
||||
ekernel = .;
|
||||
|
||||
/DISCARD/ : {
|
||||
*(.eh_frame)
|
||||
}
|
||||
}
|
||||
|
||||
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 ``_start``;
|
||||
第 3 行定义了一个常量 ``BASE_ADDRESS`` 为 ``0x80200000`` ,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址;
|
||||
|
||||
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 ``.`` 表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件
|
||||
中收集来的段。我们可以对 ``.`` 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 ``.`` 从而记录这一时刻的位置。我们还能够
|
||||
看到这样的格式:
|
||||
|
||||
.. code-block::
|
||||
|
||||
.rodata : {
|
||||
*(.rodata)
|
||||
}
|
||||
|
||||
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为
|
||||
``<ObjectFile>(SectionName)``,表示目标文件 ``ObjectFile`` 的名为 ``SectionName`` 的段需要被放进去。我们也可以
|
||||
使用通配符来书写 ``<ObjectFile>`` 和 ``<SectionName>`` 分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件
|
||||
中各个常见的段 ``.text, .rodata .data, .bss`` 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,
|
||||
且每个段都有两个全局符号给出了它的开始和结束地址(比如 ``.text`` 段的开始和结束地址分别是 ``stext`` 和 ``etext`` )。
|
||||
|
||||
|
||||
|
||||
|
||||
为了说明当前实现的正确性,我们需要讨论这样一个问题:
|
||||
|
||||
1. 如何做到执行环境的初始化代码被放在内存上以 ``0x80200000`` 开头的区域上?
|
||||
|
||||
在链接脚本第 7 行,我们将当前地址设置为 ``BASE_ADDRESS`` 也即 ``0x80200000`` ,然后从这里开始往高地址放置各个段。第一个被放置的
|
||||
是 ``.text`` ,而里面第一个被放置的又是来自 ``entry.asm`` 中的段 ``.text.entry``,这个段恰恰是含有两条指令的执行环境初始化代码,
|
||||
它在所有段中最早被放置在我们期望的 ``0x80200000`` 处。
|
||||
|
||||
|
||||
这样一来,我们就将运行时重建完毕了。在 ``os`` 目录下 ``cargo build --release`` 或者直接 ``make build`` 就能够看到
|
||||
最终生成的可执行文件 ``target/riscv64gc-unknown-none-elf/release/os`` 。
|
||||
通过分析,我们看到 ``0x80200000`` 处的代码是我们预期的 ``_start()`` 函数的内容。我们采用刚才的编译运行方式进行试验,发现还是同样的错误结果。
|
||||
问题出在哪里?这时需要用上 ``debug`` 大法了。
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
# 在一个终端执行如下命令:
|
||||
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 -S -s
|
||||
|
||||
# 在另外一个终端执行如下命令:
|
||||
$ rust-gdb target/riscv64gc-unknown-none-elf/release/os
|
||||
(gdb) target remote :1234
|
||||
(gdb) break *0x80200000
|
||||
(gdb) x /16i 0x80200000
|
||||
(gdb) si
|
||||
|
||||
结果发现刚执行一条指令,整个系统就飞了( ``pc`` 寄存器等已经变成为 ``0`` 了)。再一看, ``sp`` 寄存器是一个非常大的值 ``0xffffff...`` 。这就很清楚是
|
||||
**栈 stack** 出现了问题。我们没有设置好 **栈 stack** ! 好吧,我们需要考虑如何合理设置 **栈 stack** 。
|
||||
|
||||
|
||||
正确配置栈空间布局
|
||||
----------------------------
|
||||
|
||||
为了说明如何实现正确的栈,我们需要讨论这样一个问题:应用函数调用所需的栈放在哪里?
|
||||
|
||||
需要有一段代码来分配并栈空间,并把 ``sp`` 寄存器指向栈空间的起始位置(注意:栈空间是从上向下 ``push`` 数据的)。
|
||||
所以,我们要写一小段汇编代码 ``entry.asm`` 来帮助建立好栈空间。
|
||||
从链接脚本第 32 行开始,我们可以看出 ``entry.asm`` 中分配的栈空间对应的段 ``.bss.stack`` 被放入到可执行文件中的
|
||||
``.bss`` 段中的低地址中。在后面虽然有一个通配符 ``.bss.*`` ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去。
|
||||
这里需要注意的是地址区间 :math:`[\text{sbss},\text{ebss})` 并不包括栈空间,其原因后面再进行说明。
|
||||
|
||||
我们自己编写运行时初始化的代码:
|
||||
|
||||
.. code-block:: asm
|
||||
:linenos:
|
||||
|
||||
# os/src/entry.asm
|
||||
.section .text.entry
|
||||
.globl _start
|
||||
_start:
|
||||
la sp, boot_stack_top
|
||||
call rust_main
|
||||
|
||||
.section .bss.stack
|
||||
.globl boot_stack
|
||||
boot_stack:
|
||||
.space 4096 * 16
|
||||
.globl boot_stack_top
|
||||
boot_stack_top:
|
||||
|
||||
在这段汇编代码中,我们从第 8 行开始预留了一块大小为 4096 * 16 字节也就是 :math:`64\text{KiB}` 的空间用作接下来要运行的程序的栈空间,
|
||||
这块栈空间的栈顶地址被全局符号 ``boot_stack_top`` 标识,栈底则被全局符号 ``boot_stack`` 标识。同时,这块栈空间单独作为一个名为
|
||||
``.bss.stack`` 的段,之后我们会通过链接脚本来安排它的位置。
|
||||
|
||||
从第 2 行开始,我们通过汇编代码实现执行环境的初始化,它其实只有两条指令:第一条指令将 sp 设置为我们预留的栈空间的栈顶位置,于是之后在函数
|
||||
调用的时候,栈就可以从这里开始向低地址增长了。简单起见,我们目前暂时不考虑 sp 越过了栈底 ``boot_stack`` ,也就是栈溢出的情形,虽然这有
|
||||
可能导致严重的错误。第二条指令则是通过伪指令 ``call`` 函数调用 ``rust_main`` ,这里的 ``rust_main`` 是一个我们稍后自己编写的应用
|
||||
入口。因此初始化任务非常简单:正如上面所说的一样,只需要设置栈指针 sp,随后跳转到应用入口即可。这两条指令单独作为一个名为
|
||||
``.text.entry`` 的段,且全局符号 ``_start`` 给出了段内第一条指令的地址。
|
||||
|
||||
接着,我们在 ``main.rs`` 中嵌入这些汇编代码并声明应用入口 ``rust_main`` :
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
:emphasize-lines: 4,8,10,11,12,13
|
||||
|
||||
// os/src/main.rs
|
||||
#![no_std]
|
||||
#![no_main]
|
||||
#![feature(global_asm)]
|
||||
|
||||
mod lang_items;
|
||||
|
||||
global_asm!(include_str!("entry.asm"));
|
||||
|
||||
#[no_mangle]
|
||||
pub fn rust_main() -> ! {
|
||||
loop {}
|
||||
}
|
||||
|
||||
背景高亮指出了 ``main.rs`` 中新增的代码。
|
||||
|
||||
第 4 行中,我们手动设置 ``global_asm`` 特性来支持在 Rust 代码中嵌入全局汇编代码。第 8 行,我们首先通过
|
||||
``include_str!`` 宏将同目录下的汇编代码 ``entry.asm`` 转化为字符串并通过 ``global_asm!`` 宏嵌入到代码中。
|
||||
|
||||
从第 10 行开始,
|
||||
我们声明了应用的入口点 ``rust_main`` ,这里需要注意的是需要通过宏将 ``rust_main`` 标记为 ``#[no_mangle]`` 以避免编译器对它的
|
||||
名字进行混淆,不然的话在链接的时候, ``entry.asm`` 将找不到 ``main.rs`` 提供的外部符号 ``rust_main`` 从而导致链接失败。
|
||||
|
||||
|
||||
这样一来,我们就将“三叶虫”操作系统编写完毕了。再次使用上节中的编译,生成和运行操作,我们看到QEMU模拟的RISC-V 64计算机 **优雅** 地退出了!
|
||||
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ qemu-system-riscv64 \
|
||||
> -machine virt \
|
||||
> -nographic \
|
||||
> -bios ../bootloader/rustsbi-qemu.bin \
|
||||
> -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
|
||||
[rustsbi] Version 0.1.0
|
||||
.______ __ __ _______.___________. _______..______ __
|
||||
| _ \ | | | | / | | / || _ \ | |
|
||||
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
|
||||
| / | | | | \ \ | | \ \ | _ < | |
|
||||
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
|
||||
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
|
||||
|
||||
[rustsbi] Platform: QEMU
|
||||
[rustsbi] misa: RV64ACDFIMSU
|
||||
[rustsbi] mideleg: 0x222
|
||||
[rustsbi] medeleg: 0xb1ab
|
||||
[rustsbi] Kernel entry: 0x80200000
|
||||
# “优雅”地退出了。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
清空 .bss 段
|
||||
----------------------------------
|
||||
|
||||
与内存相关的部分太容易出错了。所以,我们再仔细检查代码后,发现在嵌入式系统中常见的 **清零 .bss段** 的工作并没有完成。
|
||||
|
||||
由于一般应用程序的 ``.bss`` 段在程序正式开始运行之前会被执环境(系统库或操作系统内核)固定初始化为零,因此在 ELF 文件中,为了节省磁盘空间,只会记录 ``.bss`` 段的位置,且应用程序的假定在它执行前,其 ``.bss段`` 的数据内容都已是 ``全0`` 。
|
||||
如果这块区域不是全零,且执行环境也没提前清零,那么会与应用的假定矛盾,导致程序出错。对于在裸机上执行的应用程序,其执行环境(就是QEMU模拟硬件+“三叶虫”操作系统内核)将可执行文件加载到内存的时候,并负责将 ``.bss`` 所分配到的内存区域全部清零。
|
||||
|
||||
落实到我们正在实现的“三叶虫”操作系统内核,我们需要提供清零的 ``clear_bss()`` 函数。此函数属于执行环境,并在执行环境调用
|
||||
应用程序的 ``rust_main`` 主函数前,把 ``.bss`` 段的全局数据清零。
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// os/src/main.rs
|
||||
fn clear_bss() {
|
||||
extern "C" {
|
||||
fn sbss();
|
||||
fn ebss();
|
||||
}
|
||||
(sbss as usize..ebss as usize).for_each(|a| {
|
||||
unsafe { (a as *mut u8).write_volatile(0) }
|
||||
});
|
||||
}
|
||||
|
||||
在程序内自己进行清零的时候,我们就不用去解析 ELF(此时也没有 ELF 可供解析)了,而是通过链接脚本 ``linker.ld`` 中给出的全局符号
|
||||
``sbss`` 和 ``ebss`` 来确定 ``.bss`` 段的位置。
|
||||
|
||||
|
||||
|
||||
我们可以松一口气了。接下来,我们要让“三叶虫”操作系统要实现“Hello, world”输出!
|
||||
|
||||
|
||||
添加裸机打印相关函数
|
||||
----------------------------------
|
||||
|
||||
|
||||
|
||||
与上一节为输出字符实现的代码片段相比,裸机应用的执行环境支持字符输出的代码改动会很小。
|
||||
下面的代码基于上节有打印能力的执行环境的基础上做的变动。
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
const SBI_CONSOLE_PUTCHAR: usize = 1;
|
||||
|
||||
pub fn console_putchar(c: usize) {
|
||||
syscall(SBI_CONSOLE_PUTCHAR, [c, 0, 0]);
|
||||
}
|
||||
|
||||
impl Write for Stdout {
|
||||
fn write_str(&mut self, s: &str) -> fmt::Result {
|
||||
//sys_write(STDOUT, s.as_bytes());
|
||||
for c in s.chars() {
|
||||
console_putchar(c as usize);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
可以看到主要就只是把之前的操作系统系统调用改为了SBI调用。然后我们再编译运行试试,
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo build
|
||||
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/debug/os --strip-all -O binary target/riscv64gc-unknown-none-elf/debug/os.bin
|
||||
$ qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/debug/os.bin,addr=0x80200000
|
||||
|
||||
[rustsbi] Version 0.1.0
|
||||
.______ __ __ _______.___________. _______..______ __
|
||||
| _ \ | | | | / | | / || _ \ | |
|
||||
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
|
||||
| / | | | | \ \ | | \ \ | _ < | |
|
||||
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
|
||||
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
|
||||
|
||||
[rustsbi] Platform: QEMU
|
||||
[rustsbi] misa: RV64ACDFIMSU
|
||||
[rustsbi] mideleg: 0x222
|
||||
[rustsbi] medeleg: 0xb1ab
|
||||
[rustsbi] Kernel entry: 0x80200000
|
||||
Hello, world!
|
||||
|
||||
可以看到,在裸机上输出了 ``Hello, world!`` ,而且qemu正常退出,表示RISC-V计算机也正常关机了。
|
||||
|
||||
|
||||
接着我们可提高“三叶虫”操作系统处理异常的能力,即给异常处理函数 ``panic`` 增加显示字符串能力。主要修改内容如下:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/main.rs
|
||||
#![feature(panic_info_message)]
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(info: &PanicInfo) -> ! {
|
||||
if let Some(location) = info.location() {
|
||||
println!("Panicked at {}:{} {}", location.file(), location.line(), info.message().unwrap());
|
||||
} else {
|
||||
println!("Panicked: {}", info.message().unwrap());
|
||||
}
|
||||
shutdown()
|
||||
}
|
||||
|
||||
我们尝试从传入的 ``PanicInfo`` 中解析 panic 发生的文件和行数。如果解析成功的话,就和 panic 的报错信息一起打印出来。我们需要在
|
||||
``main.rs`` 开头加上 ``#![feature(panic_info_message)]`` 才能通过 ``PanicInfo::message`` 获取报错信息。
|
||||
|
||||
但我们在 ``main.rs`` 的 ``rust_main`` 函数中调用 ``panic!("It should shutdown!");`` 宏时,整个模拟执行的结果是:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ cargo build --release
|
||||
$ rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/os \
|
||||
--strip-all -O binary target/riscv64gc-unknown-none-elf/release/os.bin
|
||||
$ qemu-system-riscv64 \
|
||||
-machine virt \
|
||||
-nographic \
|
||||
-bios ../bootloader/rustsbi-qemu.bin \
|
||||
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
|
||||
|
||||
[rustsbi] Version 0.1.0
|
||||
.______ __ __ _______.___________. _______..______ __
|
||||
| _ \ | | | | / | | / || _ \ | |
|
||||
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
|
||||
| / | | | | \ \ | | \ \ | _ < | |
|
||||
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
|
||||
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
|
||||
|
||||
[rustsbi] Platform: QEMU
|
||||
[rustsbi] misa: RV64ACDFIMSU
|
||||
[rustsbi] mideleg: 0x222
|
||||
[rustsbi] medeleg: 0xb1ab
|
||||
[rustsbi] Kernel entry: 0x80200000
|
||||
Hello, world!
|
||||
Panicked at src/main.rs:95 It should shutdown!
|
||||
|
||||
可以看到产生panic的地点在 ``main.rs`` 的第95行,与源码中的实际位置一致!到这里,我们基本上算是完成了第一章的实验内容,
|
||||
实现了支持应用程序在裸机上显示字符串的“三叶虫”操作系统。但也能看出,这个操作系统很脆弱,只能支持一个简单的易用,在本质上
|
||||
是一个提供方便服务接口的库。“三叶虫”操作系统还需进化,提升能力。
|
||||
在下一章,我们将进入“敏迷龙”操作系统的设计与实现。
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
**Rust 小知识:错误处理**
|
||||
|
||||
Rust 中常利用 ``Option<T>`` 和 ``Result<T, E>`` 进行方便的错误处理。它们都属于枚举结构:
|
||||
|
||||
- ``Option<T>`` 既可以有值 ``Option::Some<T>`` ,也有可能没有值 ``Option::None``;
|
||||
- ``Result<T, E>`` 既可以保存某个操作的返回值 ``Result::Ok<T>`` ,也可以表明操作过程中出现了错误 ``Result::Err<E>`` 。
|
||||
|
||||
我们可以使用 ``Option/Result`` 来保存一个不能确定存在/不存在或是成功/失败的值。之后可以通过匹配 ``if let`` 或是在能够确定
|
||||
的场合直接通过 ``unwrap`` 将里面的值取出。详细的内容可以参考 Rust 官方文档。
|
|
@ -9,9 +9,7 @@
|
|||
0intro
|
||||
1app-ee-platform
|
||||
2remove-std
|
||||
3-1-mini-rt-usrland
|
||||
3-2-mini-rt-baremetal
|
||||
4understand-prog
|
||||
5exercise
|
||||
3understand-prog
|
||||
4exercise
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue