提交 346e9ead 编写于 作者: deathwish5's avatar deathwish5

merge main

......@@ -19,6 +19,9 @@ help:
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
view:
make html && firefox build/html/index.html
deploy:
@make clean
@make html
......
.wy-nav-content {
max-width: 1200px !important;
}
附录 A:Rust 快速入门
附录 A:Rust 系统编程入门
=============================
.. toctree::
......@@ -6,39 +6,40 @@
:maxdepth: 4
.. note::
.. .. note::
**Rust 语法卡片:外部符号引用**
.. **Rust 语法卡片:外部符号引用**
extern "C" 可以引用一个外部的 C 函数接口(这意味着调用它的时候要遵从目标平台的 C 语言调用规范)。但我们这里只是引用位置标志
并将其转成 usize 获取它的地址。由此可以知道 ``.bss`` 段两端的地址。
.. extern "C" 可以引用一个外部的 C 函数接口(这意味着调用它的时候要遵从目标平台的 C 语言调用规范)。但我们这里只是引用位置标志
.. 并将其转成 usize 获取它的地址。由此可以知道 ``.bss`` 段两端的地址。
**Rust 语法卡片:迭代器与闭包**
.. **Rust 语法卡片:迭代器与闭包**
代码第 7 行用到了 Rust 的迭代器与闭包的语法,它们在很多情况下能够提高开发效率。如读者感兴趣的话也可以将其改写为等价的 for
循环实现。
.. 代码第 7 行用到了 Rust 的迭代器与闭包的语法,它们在很多情况下能够提高开发效率。如读者感兴趣的话也可以将其改写为等价的 for
.. 循环实现。
.. _term-raw-pointer:
.. _term-dereference:
.. warning::
.. .. _term-raw-pointer:
.. .. _term-dereference:
.. .. warning::
**Rust 语法卡片:Unsafe**
.. **Rust 语法卡片:Unsafe**
代码第 8 行,我们将 ``.bss`` 段内的一个地址转化为一个 **裸指针** (Raw Pointer),并将它指向的值修改为 0。这在 C 语言中是
一种司空见惯的操作,但在 Rust 中我们需要将他包裹在 unsafe 块中。这是因为,Rust 认为对于裸指针的 **解引用** (Dereference)
是一种 unsafe 行为。
.. 代码第 8 行,我们将 ``.bss`` 段内的一个地址转化为一个 **裸指针** (Raw Pointer),并将它指向的值修改为 0。这在 C 语言中是
.. 一种司空见惯的操作,但在 Rust 中我们需要将他包裹在 unsafe 块中。这是因为,Rust 认为对于裸指针的 **解引用** (Dereference)
.. 是一种 unsafe 行为。
相比 C 语言,Rust 进行了更多的语义约束来保证安全性(内存安全/类型安全/并发安全),这在编译期和运行期都有所体现。但在某些时候,
尤其是与底层硬件打交道的时候,在 Rust 的语义约束之内没法满足我们的需求,这个时候我们就需要将超出了 Rust 语义约束的行为包裹
在 unsafe 块中,告知编译器不需要对它进行完整的约束检查,而是由程序员自己负责保证它的安全性。当代码不能正常运行的时候,我们往往也是
最先去检查 unsafe 块中的代码,因为它没有受到编译器的保护,出错的概率更大。
C 语言中的指针相当于 Rust 中的裸指针,它无所不能但又太过于灵活,程序员对其不谨慎的使用常常会引起很多内存不安全问题,最常见的如
悬垂指针和多次回收的问题,Rust 编译器没法确认程序员对它的使用是否安全,因此将其划到 unsafe Rust 的领域。在 safe Rust 中,我们
有引用 ``&/&mut`` 以及各种功能各异的智能指针 ``Box<T>/RefCell<T>/Rc<T>`` 可以使用,只要按照 Rust 的规则来使用它们便可借助
编译器在编译期就解决很多潜在的内存不安全问题。
.. 相比 C 语言,Rust 进行了更多的语义约束来保证安全性(内存安全/类型安全/并发安全),这在编译期和运行期都有所体现。但在某些时候,
.. 尤其是与底层硬件打交道的时候,在 Rust 的语义约束之内没法满足我们的需求,这个时候我们就需要将超出了 Rust 语义约束的行为包裹
.. 在 unsafe 块中,告知编译器不需要对它进行完整的约束检查,而是由程序员自己负责保证它的安全性。当代码不能正常运行的时候,我们往往也是
.. 最先去检查 unsafe 块中的代码,因为它没有受到编译器的保护,出错的概率更大。
.. C 语言中的指针相当于 Rust 中的裸指针,它无所不能但又太过于灵活,程序员对其不谨慎的使用常常会引起很多内存不安全问题,最常见的如
.. 悬垂指针和多次回收的问题,Rust 编译器没法确认程序员对它的使用是否安全,因此将其划到 unsafe Rust 的领域。在 safe Rust 中,我们
.. 有引用 ``&/&mut`` 以及各种功能各异的智能指针 ``Box<T>/RefCell<T>/Rc<T>`` 可以使用,只要按照 Rust 的规则来使用它们便可借助
.. 编译器在编译期就解决很多潜在的内存不安全问题。
- `OS Tutorial Summer of Code 2020:Rust系统编程入门指导 <https://github.com/rcore-os/rCore/wiki/os-tutorial-summer-of-code#step-0-%E8%87%AA%E5%AD%A6rust%E7%BC%96%E7%A8%8B%E5%A4%A7%E7%BA%A67%E5%A4%A9>`_
- `Stanford 新开的一门很值得学习的 Rust 入门课程 <https://reberhardt.com/cs110l/spring-2020/>`_
- `一份简单的 Rust 入门介绍 <https://zhuanlan.zhihu.com/p/298648575>`_
- `《RustOS Guide》中的 Rust 介绍部分 <https://simonkorl.gitbook.io/r-z-rustos-guide/dai-ma-zhi-qian/ex1>`_
\ No newline at end of file
- `《RustOS Guide》中的 Rust 介绍部分 <https://simonkorl.gitbook.io/r-z-rustos-guide/dai-ma-zhi-qian/ex1>`_
- `一份简单的Rust宏编程新手指南 <http://blog.hubwiz.com/2020/01/30/rust-macro/>`_
\ No newline at end of file
......@@ -14,7 +14,7 @@
.. note::
在本书中,下面的抽象表示将以有形的数据结构和实际的执行行在后续各章实现的操作系统内核中进行展示
在本书中,下面的抽象表示不会仅仅就是一个文字的描述,还会在后续章节对具体操作系统设计与运行的讲述中,以具体化的静态数据结构,动态执行对物理/虚拟资源的变化来展示。从而让读者能够建立操作系统抽象概念与操作系统具体实验之间的内在联系
执行环境
----------------------------------------
......@@ -32,7 +32,11 @@
提供丰富的功能和资源。在第三个阶段,应用程序的执行环境就变成了 **函数库->操作系统->计算机硬件** 。
在后面又出现了基于 Java 语言的应用程序,在函数库和操作系统之间,多了一层 Java 虚拟机,此时 Java 应用
程序的执行环境就变成了 **函数库-> Java 虚拟机->操作系统->计算机硬件** 。在云计算时代,在传统操作系统与
计算机硬件之间多了一层 Hypervisor/VMM ,此时应用程序的执行环境变成了 **函数库 ->Java 虚拟机->操作系统->Hypervisor/VMM->计算机硬件** 。另外,执行环境的不同层次通过 API 或 ABI 进行交互,而且彼此之间也有一定的交集,并不一定具有严格区分的界面。
计算机硬件之间多了一层 Hypervisor/VMM ,此时应用程序的执行环境变成了 **函数库 ->Java 虚拟机->操作系统->Hypervisor/VMM->计算机硬件** 。
.. _term-ee-switch:
另外,CPU在执行过程中,可以在不同层次的执行环境之间可以切换,这称为 **执行环境切换** 。这主要是通过特定的 API 或 ABI 来完成的,这样不同执行环境的软件就能实现数据交换与互操作,而且还保证了彼此之间有清晰的隔离。
.. image:: complex-EE.png
:align: center
......@@ -43,23 +47,59 @@
基于上面的介绍,我们可以给应用程序的执行环境一个基本的定义:执行环境是一个概念,一种机制,用来完成应用程序在运行时的数据与资源管理、应用程序的生存期等方面的处理,它定义了应用程序有权访问的其他数据或资源,并决定了应用程序的行为限制范围。
.. _term-ccf:
普通控制流
----------------------
回顾一下编译原理课上的知识,程序的控制流(Flow of Control or Control Flow) 是指以一个程序的指令、语句或基本块为单位的执行序列。再回顾一下计算机组成原理课上的知识,处理器的控制流 是指处理器中程序计数器的控制转移序列。最简单的一种控制流(没有异常或中断产生的前提下)是一个“平滑的”序列,其中每个要执行的指令地址在内存中都是相邻的。如果站在程序员的角度来看控制流,会发现控制流是程序员编写的程序的执行序列,这些序列程序员预设好的。程序运行时能以多种简单的控制流(顺序、分支、循环结构和多层嵌套函数调用)组合的方式,来一行一行的执行源代码(以编程语言级的视角),也是一条一条的执行汇编指令(以汇编语言级的视角)。对于上述的不同描述,我们可以统称其为普通控制流(CCF,Common Control Flow)。在应用程序是视角下,它只能接触到它所在的执行环境,不会跳到其他执行环境,所以应用程序执行基本上是以普通控制流的形式完成整个运行的过程。
.. _term-ecf:
异常控制流
--------------------------------------
首先,处理器的控制流 (Flow of Control or Control Flow) 是指处理器中程序计数器的控制转移序列。最简单的一种控制流是一个“平滑的”序列,其中每个要执行的指令地址在内存中都是相邻的。如果前一条指令和后一条指令位于两个完全不同的位置,即不同的 **执行环境** ,比如,前一条指令还在应用程序的代码段中,后一条指令就跑到操作系统的代码段中去了,这就是一种控制流的“突变”,即控制流脱离了其所在的 **执行环境** 。
应用程序在执行过程中,如果出现也外设中断或CPU异常,处理器执行的前一条指令和后一条指令会位于两个完全不同的位置,即不同的 **执行环境** ,比如,前一条指令还在应用程序的代码段中,后一条指令就跑到操作系统的代码段中去了,这就是一种控制流的“突变”,即控制流脱离了其所在的 **执行环境** ,并产生 :ref:`执行环境的切换 <term-ee-switch>`。
应用程序 **感知** 不到这种异常的控制流情况,这主要是由于操作系统把这种情况 **透明** 地进行了 **执行环境** 的切换和对各种异常情况的处理,让应用程序从始至终地 **认为** 没有这些异常控制流的产生。
简单地说,异常控制流 (ECF, Exceptional Control Flow) 是处理器在执行过程中的突变,其主要作用是通过硬件和操作系统的协同工作来响应处理器状态中的特殊变化。比如当应用程序正在执行时,产生了时钟外设中断,导致操作系统打断当前应用程序的执行,转而进入 **操作系统** 所在的执行环境去处理时钟外设中断。处理完毕后,再回到应用程序的 **执行环境** 中被打断的地方继续执行。
简单地说,异常控制流 (ECF, Exceptional Control Flow) 是处理器在执行过程中的突变,即通过硬件和操作系统的协同工作来响应处理器状态中的特殊变化。比如当应用程序正在执行时,产生了时钟外设中断,导致操作系统打断当前应用程序的执行,转而进入 **操作系统** 所在的执行环境去处理时钟外设中断。处理完毕后,再回到应用程序的 **执行环境** 中被打断的地方继续执行。
上下文(context)
.. _term-context:
.. _term-ees:
上下文(context)或执行环境的状态
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
站在硬件的角度来看普通控制流或异常控制流的具体执行过程,我们会发现从控制流起始的某条指令开始记录,指令可访问的所有物理资源,包括自带的所有通用寄存器、特权级相关特殊寄存器、以及指令访问的内存等,会随着指令的执行而逐渐发生变化。
这里我们把控制流在执行完某指令时的物理资源内容,即确保下一时刻能继续 **正确** 执行控制流指令的物理资源内容称为控制流的 ``上下文 (context)`` ,也可称为控制流所在执行环境的状态。
如果出现了处理器在执行过程中的突变(即异常控制流)或转移(如多层函数调用),需要由维持执行环境的软硬件协同起来,保存发生突变或转移前的当前的执行环境的状态(比如突变或函数调用前一刻的指令寄存器,栈寄存器和其他一些通用寄存器等的内容),并在完成突变处理或被调用函数后,恢复突变或转移前的当前的执行执行环境的状态。这是由于完成与突变相关的执行会破坏突变前的执行环境状态(比如上述各种寄存器的内容),导致如果不保存状态,就无法恢复到突变前执行环境,继续正常的控制流的执行。
这里需要理解控制流的 ``上下文 (context)`` 对控制流的 **正确** 执行的影响。如果在某时刻,由于某种有意或无意的原因,控制流的 ``上下文 (context)`` 发生了不是由于控制流本身的指令产生的变化,并使得控制流执行接下来的指令序列时出现了偏差,并最终导致执行过程或执行结果不符合预期,即没有正确执行。 所以,我们这里说的控制流的 ``上下文 (context)`` 是指仅会影响控制流正确执行的有限的物理资源内容。
这里我们把控制流在某一时刻的执行环境的状态(即确保下一时刻能继续正确执行控制流指令所需的硬件(主要是各种寄存器)内容称为 ``上下文 (context)`` 。对于异常控制流的 ``上下文 (context)`` 保存与恢复,主要是通过CPU和操作系统(手动编写在 ``stack`` 上保存与恢复寄存器的指令)来协同完成;对于函数转移控制流的 ``上下文 (context)`` 保存与恢复,主要是通过编译器(自动生成在 ``stack`` 上保存与恢复寄存器的指令)来帮助完成的。
如果一个控制流是属于某个函数,那么这个控制流的上下文简称为函数调用上下文。如果一个控制流是属于某个应用程序的,那么这个控制流的上下文简称为应用程序上下文。如果把某 :ref:`进程 <term-process>` 看做是运行的应用程序,那么这个属于某个应用程序的控制流可简称为某进程上下文。如果一个控制流是属于操作系统,那么这个控制流的上下文简称为操作系统上下文。如果一个控制流是属于操作系统中处理中断/异常/陷入的那段代码,那么这个控制流的上下文简称为中断/异常/陷入的上下文。
那么随着CPU的执行,各种前缀的上下文(执行环境的状态)会在不断的变化。
如果出现了处理器在执行过程中的突变(即异常控制流)或转移(如多层函数调用),需要由维持执行环境的软硬件协同起来,保存发生突变或转移前的当前的执行环境的状态(比如突变或函数调用前一刻的指令寄存器,栈寄存器和其他一些通用寄存器等的内容),并在完成突变处理或被调用函数后,恢复突变或转移前的当前的执行执行环境的状态。这是由于完成与突变相关的执行会破坏突变前的执行环境状态(比如上述各种寄存器的内容),导致如果不保存状态,就无法恢复到突变前执行环境,继续正常的普通控制流的执行。
对于异常控制流的 ``上下文 (context)`` 保存与恢复,主要是通过CPU和操作系统(手动编写在 ``stack`` 上保存与恢复寄存器的指令)来协同完成;对于函数转移控制流的 ``上下文 (context)`` 保存与恢复,主要是通过编译器(自动生成在 ``stack`` 上保存与恢复寄存器的指令)来帮助完成的。
在操作系统中,需要处理三类异常控制流:外设中断 (Device Interrupt) 、陷入 (Trap) 和异常 (Exception,也称Fault Interrupt)。
.. _term-execution-flow:
执行流或执行历史
------------------------
无论是操作系统还是应用程序,它在某一段时间上的执行过程会让处理器执行一系列程序的指令,并对计算机的物理资源的内容(即上下文)进行了改变。如果结合上面的抽象概念更加细致地表述一下,可以认为在它从开始到结束的整个执行过程中,截取其中一个时间段,在这个时间段中,它所执行的指令流形成了这个时间段的控制流,而控制流中的每条执行的指令和它执行后的``上下文 (context)``,形成由二元组<指令,上下文>(<intr,context>)构成的有序序列,我们用**执行流** (Execution Flow)或 **执行历史** (Execution History) 来表示这个二元组有序序列。它完整描述了操作系统或应用程序在一段时间内执行的指令流以及计算机物理资源的变化过程。
中断(interrupt)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
......@@ -90,6 +130,8 @@
在后面的叙述中,如果没有特别指出,我们将用简称中断、陷入、异常来区分这三种异常控制流。
.. _term_process:
进程
----------------------------------
......
......@@ -53,17 +53,20 @@ Ubuntu18.04 镜像,它是一个 ``vmdk`` 格式的虚拟磁盘文件,只需
已经创建好用户 oslab ,密码为一个空格。它已经安装了中文输入法和 Markdown 编辑器 Typora 还有作为 Rust 集成开发环境的
Visual Studio Code,能够更容易完成实验并撰写实验报告。
.. _link-docker-env:
.. note::
**Docker 开发环境**
感谢 dinghao188 配置好的 Docker 开发环境:
.. code-block::
docker pull dinghao188/rcore-tutorial
感谢 dinghao188 和张汉东老师帮忙配置好的 Docker 开发环境,进入 Docker 开发环境之后不需要任何软件工具链的安装和配置,可以直接将 tutorial 运行起来,目前应该仅支持将 tutorial 运行在 Qemu 模拟器上。
使用方法如下(以 Ubuntu18.04 为例):
1. 通过 ``su`` 切换到管理员账户 ``root`` ;
2. 在 ``rCore-Tutorial-v3`` 根目录下 ``make docker`` 进入到 Docker 环境;
3. 进入 Docker 之后,会发现当前处于根目录 ``/`` ,我们通过 ``cd mnt`` 将当前工作路径切换到 ``/mnt`` 目录;
4. 通过 ``ls`` 可以发现 ``/mnt`` 目录下的内容和 ``rCore-Tutorial-v3`` 目录下的内容完全相同,接下来就可以在这个环境下运行 tutorial 了。例如 ``cd os && make run`` 。
你也可以在 Windows10 或 macOS 原生系统或者其他 Linux 发行版上进行实验,基本上不会出现太大的问题。不过由于
......@@ -126,6 +129,18 @@ Rust 开发环境配置
rustc 1.46.0-nightly (7750c3d46 2020-06-26)
.. warning::
目前用于操作系统实验开发的rustc编译器的版本不局限在1.46.0这样的数字上,你可以选择更新的rustc编译器。但注意只能用rustc的nightly版本。
可通过如下命令安装rustc的nightly版本,并把该版本设置为rustc的缺省版本。
.. code-block:: bash
rustup install nightly
rustup default nightly
我们最好把软件包管理器 cargo 所用的软件包镜像地址 crates.io 也换成中国科学技术大学的镜像服务器来加速三方库的下载。
我们打开(如果没有就新建) ``~/.cargo/config`` 文件,并把内容修改为:
......@@ -156,8 +171,8 @@ Rust 开发环境配置
rustup component add llvm-tools-preview
rustup component add rust-src
.. note::
.. warning::
如果你换了另外一个rustc编译器(必须是nightly版的),需要重新安装上述rustc所需软件包。
rCore-Tutorial 仓库中的 ``Makefile`` 包含了这些工具的安装,如果你使用 ``make run`` 也可以不手动安装。
至于 Rust 开发环境,推荐 JetBrains Clion + Rust插件 或者 Visual Studio Code 搭配 rust-analyzer 和 RISC-V Support 插件。
......@@ -225,7 +240,7 @@ Qemu 模拟器安装
qemu-system-riscv64 --version
qemu-riscv64 --version
其他工具安装
K210 真机串口通信
------------------------------
为了能在 K210 真机上运行 Tutorial,我们还需要安装基于 Python 的串口通信库和简易的串口终端。
......@@ -235,7 +250,17 @@ Qemu 模拟器安装
pip3 install pyserial
sudo apt install python-serial
下面这些工具链并不一定会被用到,可以等到真正依赖的时候再来安装。
GDB 调试支持
------------------------------
在 ``os`` 目录下 ``make debug`` 可以调试我们的内核,这需要安装终端复用工具 ``tmux`` ,还需要基于 riscv64 平台的 gdb 调试器 ``riscv64-unknown-elf-gdb`` 。该调试器包含在 riscv64 gcc 工具链中,工具链的预编译版本可以在如下链接处下载:
- `Ubuntu 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-linux-ubuntu14.tar.gz>`_
- `macOS 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-apple-darwin.tar.gz>`_
- `Windows 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-w64-mingw32.zip>`_
- `CentOS 平台 <https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.3.0-2020.04.1-x86_64-linux-centos6.tar.gz>`_
解压后在 ``bin`` 目录下即可找到 ``riscv64-unknown-elf-gdb`` 以及另外一些常用工具 ``objcopy/objdump/readelf`` 等。
运行 rCore-Tutorial-v3
------------------------------------------------------------
......
......@@ -101,14 +101,18 @@
*段错误 (核心已转储)* 是常见的一种应用程序出错,而我们这个非常简单的应用程序导致了 Linux 环境模拟程序 ``qemu-riscv64`` 崩溃了!为什么会这样?
.. _term-qemu-riscv64:
.. note::
QEMU有两种运行模式: ``User mode`` 模式,即用户态模拟,如 ``qemu-riscv64`` 程序,能够模拟不同处理器的用户态指令的执行,并加载运行那些为不同处理器编译的用户级Linux应用程序;通过把这些应用发出的不同CPU(如RISC-V)的Linux系统调用转换为本机(如x86-64)上的Linux系统调用,让本机Linux完成系统调用,并返回结果(再转换成RISC-V能识别的数据)给这些应用。 ``System mode`` 模式,即系统态模式,如 ``qemu-system-riscv64`` 程序,能够模拟一个完整的基于不同CPU的硬件系统,包括处理器、内存及其他外部设备,支持运行完整的操作系统。
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
......
......@@ -121,10 +121,21 @@
如果在裸机上的应用程序执行完毕并通知操作系统后,那么“三叶虫”操作系统就没事干了,实现正常关机是一个合理的选择。所以我们要让“三叶虫”操作系统能够正常关机,这是需要调用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;
......@@ -139,6 +150,8 @@
}
也许有同学比较迷惑,应用程序访问操作系统提供的系统调用的指令是 ``ecall`` ,操作系统访问
RustSBI提供的SBI服务的SBI调用的指令也是 ``ecall`` 。
这其实是没有问题的,虽然指令一样,但它们所在的特权级和特权级转换是不一样的。简单地说,应用程序位于最弱的用户特权级(User Mode),操作系统位于
......
......@@ -114,7 +114,7 @@ ra 保存的物理地址,由于 x0 是一个恒为 0 的寄存器,在 rd 中
变得多么复杂。如果我们试图在一个函数 :math:`f` 中调用一个子函数,在跳转到子函数 :math:`g` 的同时,ra 会被覆盖成这条跳转指令的
下一条的地址,而 ra 之前所保存的函数 :math:`f` 的返回地址将会 `永久丢失` 。
.. _term-context:
.. _term-function-context:
.. _term-activation-record:
因此,若想正确实现嵌套函数调用的控制流,我们必须通过某种方式保证:在一个函数调用子函数的前后,ra 寄存器的值不能发生变化。但实际上,
......
......@@ -143,4 +143,4 @@ tips
- 简单总结本次实验你编程的内容。(控制在5行以内,不要贴代码)
- 由于彩色输出不好自动测试,请附正确运行后的截图。
- 完成问答问题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有那些需要改进的地方,欢迎畅所欲言。
\ No newline at end of file
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。
\ No newline at end of file
......@@ -62,7 +62,7 @@ RISC-V 架构中一共定义了 4 种特权级:
- 11
- 机器模式 (M, Machine)
其中,级别的数值越,特权级越高,掌控硬件的能力越强。从表中可以看出, M 模式处在最高的特权级,而 U 模式处于最低的特权级。
其中,级别的数值越,特权级越高,掌控硬件的能力越强。从表中可以看出, M 模式处在最高的特权级,而 U 模式处于最低的特权级。
之前我们给出过支持应用程序运行的一套 :ref:`执行环境栈 <app-software-stack>` ,现在我们站在特权级架构的角度去重新看待它:
......@@ -233,4 +233,4 @@ RISC-V的特权指令
就是所谓的 Trap 机制。
RISC-V 架构规范分为两部分: `RISC-V 无特权级规范 <https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf>`_
和 `RISC-V 特权级规范 <https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMFDQC-and-Priv-v1.11/riscv-privileged-20190608.pdf>`_ 。
RISC-V 无特权级规范中给出的指令和寄存器无论在 CPU 处于哪个特权级下都可以使用。
\ No newline at end of file
RISC-V 无特权级规范中给出的指令和寄存器无论在 CPU 处于哪个特权级下都可以使用。
......@@ -185,8 +185,7 @@ Rust 中的 ``llvm_asm!`` 宏的完整格式如下:
第 10 行用于告知编译器将我们在程序中给出的嵌入汇编代码保持原样放到最终构建的可执行文件中。如果不这样做的话,编译器可能会把它和其他代码
一视同仁并放在一起进行一些我们期望之外的优化。为了保证语义的正确性,一些比较关键的汇编代码需要加上该选项。
第一章中的输出到屏幕的操作也同样是使用内联汇编调用 SEE 提供的 SBI 接口来实现的。有兴趣的读者可以回顾第一章的 ``console.rs`` 和
``sbi.rs`` 。
上面这一段汇编代码的含义和内容与第一章中的 :ref:`第一章中U-Mode应用程序中的系统调用汇编代码 <term-llvm-syscall>` 的是一致的。与 :ref:`第一章中的RustSBI输出到屏幕的SBI调用汇编代码 <term-llvm-sbicall>` 涉及的汇编指令一样,但传递参数的寄存器的含义是不同的。有兴趣的读者可以回顾第一章的 ``console.rs`` 和 ``sbi.rs`` 。
.. note::
......@@ -264,7 +263,7 @@ Rust 中的 ``llvm_asm!`` 宏的完整格式如下:
实现操作系统前执行应用程序
-----------------------------------
我们还没有实现操作系统,能提前执行或测试应用程序吗?可以! 这是因为我们除了一个能模拟一台RISC-V 64 计算机的全系统模拟器 ``qemu-system-riscv64`` 外,还有一个直接支持运行RISC-V64 用户程序的半系统模拟器 ``qemu-riscv64`` 。 ``qemu-riscv64`` 模拟器的特点是,它可以直接解析ELF 可执行文件,并加载执行RISC-V64 用户程序(ELF 可执行文件)中的指令,如果碰到系统调用,就把系统调用转换成本机(如x86-64)的系统调用格式,再发给本机的Linux内核来完成系统调用, 再把结果转换成RISC-V64的格式,这样RISC-V64 用户程序就可以得到结果,并继续执行。整个过程就好像这个RISC-V64 用户程序直接运行在一台安装了RV64 Linux的RV64的计算机上
我们还没有实现操作系统,能提前执行或测试应用程序吗?可以! 这是因为我们除了一个能模拟一台RISC-V 64 计算机的全系统模拟器 ``qemu-system-riscv64`` 外,还有一个 :ref:`直接支持运行RISC-V64 用户程序的半系统模拟器qemu-riscv64 <term-qemu-riscv64>`
.. note::
......@@ -305,7 +304,7 @@ Rust 中的 ``llvm_asm!`` 宏的完整格式如下:
.. code-block:: rust
:linenos:
// usr/src/bin/03priv_intr.rs
// usr/src/bin/04priv_intr.rs
...
println!("Hello, world!");
let mut sstatus = sstatus::read();
......
......@@ -73,10 +73,11 @@
这个文件是在 ``cargo build`` 的时候,由脚本 ``os/build.rs`` 控制生成的。有兴趣的读者可以参考其代码。
能够找到并加载应用程序二进制码
找到并加载应用程序二进制码
-----------------------------------------------
能够找到并加载应用程序二进制码的应用管理器 ``AppManager`` 是“邓式鱼”操作系统的核心组件。我们在 ``os`` 的 ``batch`` 子模块中实现一个应用管理器,它的主要功能是:
- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。
......@@ -117,7 +118,7 @@
所谓的内部可变性就是指在我们只能拿到 ``AppManager`` 的不可变借用,意味着同样也只能
拿到 ``AppManagerInner`` 的不可变借用的情况下依然可以修改 ``AppManagerInner`` 里面的字段。
使用 ``RefCell::borrow/RefCell::borrow_mut`` 分别可以拿到 ``RefCell`` 里面内容的不可变借用/可变借用,
``RefCell`` 内部会运行时维护当前已有的借用状态并进行借用检查。于是 ``RefCell::borrow_mut`` 就是我们实现内部可变性的关键。
``RefCell`` 会在运行时维护当前它管理的对象的已有借用状态,并在访问对象时进行借用检查。于是 ``RefCell::borrow_mut`` 就是我们实现内部可变性的关键。
我们这样初始化 ``AppManager`` 的全局实例:
......@@ -143,6 +144,8 @@
};
}
初始化的逻辑很简单,就是找到 ``link_app.S`` 中提供的符号 ``_num_app`` ,并从这里开始解析出应用数量以及各个应用的开头地址。注意其中对于切片类型的使用能够很大程度上简化编程。
这里我们使用了外部库 ``lazy_static`` 提供的 ``lazy_static!`` 宏。要引入这个外部库,我们需要加入依赖:
.. code-block:: toml
......@@ -154,15 +157,11 @@
``lazy_static!`` 宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置一个初始值,但是有些全局变量依赖于运行期间
才能得到的数据作为初始值。这导致这些全局变量需要在运行时发生变化,也即重新设置初始值之后才能使用。如果我们手动实现的话有诸多不便之处,
比如又需要把这种全局变量声明为 ``static mut`` 并衍生出很多 unsafe 。这种情况下我们可以使用 ``lazy_static!`` 宏来帮助我们解决
这个问题。这里我们借助 ``lazy_static!`` 声明了一个名为 ``APP_MANAGER`` 的 ``AppManager`` 全局实例,且只有在它第一次被使用到
的时候才会实际进行初始化工作。
初始化的逻辑很简单,就是找到 ``link_app.S`` 中提供的符号 ``_num_app`` ,并从这里开始解析出应用数量以及各个应用的开头地址。注意其中
对于切片类型的使用能够很大程度上简化编程。
比如需要把这种全局变量声明为 ``static mut`` 并衍生出很多 unsafe code。这种情况下我们可以使用 ``lazy_static!`` 宏来帮助我们解决
这个问题。这里我们借助 ``lazy_static!`` 声明了一个 ``AppManager`` 结构的名为 ``APP_MANAGER`` 的全局实例,且只有在它第一次被使用到
的时候才会进行实际的初始化工作。
因此,借助 Rust 核心库提供的 ``RefCell`` 和外部库 ``lazy_static!``,我们就能在避免 ``static mut`` 声明的情况下以更加 Rust 的
方式使用全局变量。
因此,借助 Rust 核心库提供的 ``RefCell`` 和外部库 ``lazy_static!``,我们就能在避免 ``static mut`` 声明的情况下以更加优雅的Rust风格使用全局变量。
``AppManagerInner`` 的方法中, ``print_app_info/get_current_app/move_to_next_app`` 都相当简单直接,需要说明的是 ``load_app``:
......@@ -192,9 +191,9 @@
}
这个方法负责将参数 ``app_id`` 对应的应用程序的二进制镜像加载到物理内存以 ``0x80040000`` 开头的位置,这个位置是批处理操作系统和应用程序
之间约定的常数,回忆上一小节中,我们也调整应用程序的内存布局以同一个地址开头。第 8 行开始,我们首先将一块内存清空,然后找到待加载应用
二进制镜像的位置,并将它复制到正确的位置。它本质上是数据从一块内存复制到另一块内存,从批处理操作系统的角度来看是将它数据段的一部分复制到了它
程序之外未知的地方。
之间约定的常数地址,回忆上一小节中,我们也调整应用程序的内存布局以同一个地址开头。第 8 行开始,我们首先将一块内存清空,然后找到待加载应用
二进制镜像的位置,并将它复制到正确的位置。它本质上是数据从一块内存复制到另一块内存,从批处理操作系统的角度来看是将它数据段的一部分复制到了它
程序之外未知的地方。在这一点上也体现了冯诺依曼计算机的 ``代码即数据`` 的特征。
.. _term-dcache:
.. _term-icache:
......
此差异已折叠。
......@@ -70,7 +70,7 @@ lab2 中,我们实现了第一个系统调用 ``sys_write``,这使得我们
2. L46-L51: 这几行汇编代码特殊处理了哪些寄存器?这些寄存器的的值对于进入用户态有何意义?请分别解释。
.. code-block:: asm
.. code-block:: riscv
ld t0, 32*8(sp)
ld t1, 33*8(sp)
......@@ -81,7 +81,7 @@ lab2 中,我们实现了第一个系统调用 ``sys_write``,这使得我们
3. L53-L59: 为何跳过了 ``x2`` 和 ``x4``?
.. code-block::
.. code-block:: riscv
ld x1, 1*8(sp)
ld x3, 3*8(sp)
......@@ -93,7 +93,7 @@ lab2 中,我们实现了第一个系统调用 ``sys_write``,这使得我们
4. L63: 该指令之后,``sp`` 和 ``sscratch`` 中的值分别有什么意义?
.. code-block:: asm
.. code-block:: riscv
csrrw sp, sscratch, sp
......@@ -101,13 +101,13 @@ lab2 中,我们实现了第一个系统调用 ``sys_write``,这使得我们
5. L13: 该指令之后,``sp`` 和 ``sscratch`` 中的值分别有什么意义?
.. code-block:: asm
.. code-block:: riscv
csrrw sp, sscratch, sp
6. 从 U 态进入 S 态是哪一条指令发生的?
3. 描述程序陷入内核的两大原因是中断和异常,请问 riscv64 支持些中断/异常?如何判断进入内核是由于中断还是异常?描述陷入内核时的几个重要寄存器及其值。
3. 描述程序陷入内核的两大原因是中断和异常,请问 riscv64 支持些中断/异常?如何判断进入内核是由于中断还是异常?描述陷入内核时的几个重要寄存器及其值。
4. 对于任何中断, ``__alltraps`` 中都需要保存所有寄存器吗?你有没有想到一些加速 ``__alltraps`` 的方法?简单描述你的想法。
......@@ -116,4 +116,4 @@ lab2 中,我们实现了第一个系统调用 ``sys_write``,这使得我们
- 简单总结与上次实验相比本次实验你增加的东西(控制在5行以内,不要贴代码)。
- 完成问答问题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有那些需要改进的地方,欢迎畅所欲言。
\ No newline at end of file
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。
\ No newline at end of file
......@@ -17,30 +17,34 @@
上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换;
另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控应用的执行,一旦应用越过了
硬件所设置的界限,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中
另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控运行在用户态特权级的应用的执行,一旦应用越过了
硬件所设置特权级界限或主动申请获得操作系统的服务,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中
的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用。这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到
相同的一块内存区域。
而计算机在发展,内存容量在逐渐增大,处理器的速度也在增加,IO方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善,处理器的空闲程度加大。于是科学家就开始考虑在内存中尽量同时驻留多个应用,这样处理器的利用率就会提高。但只有一个程序执行完毕后,才能执行另外一个程序。这种运行方式称为 **多道程序** 。
而计算机硬件在快速发展,内存容量在逐渐增大,处理器的速度也在增加,外设IO性能方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善,但处理器的空闲程度加大了。于是科学家就开始考虑在内存中尽量同时驻留多个应用,这样处理器的利用率就会提高。但只有一个程序执行完毕后或主动放弃执行,处理器才能执行另外一个程序。这种运行方式称为 **多道程序** 。
**协作式操作系统**
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
当处理器进一步发展后,它与IO的速度差距也进一步拉大。这时计算机科学家发现,在 **多道程序** 运行方式下,一个程序如果不让出处理器,其他程序是无法执行的。如果一个应用由于IO操作让处理器空闲下来或让处理器忙等,那其他需要处理器资源进行计算的应用还是没法使用空闲的处理器资源。于是就想到,让应用在执行IO操作时,可以主动释放处理器,让其他应用继续执行。当然执行 **放弃处理器** 的操作算是一种处理器资源的直接管理,应该有操作系统来具体完成。这旸的操作系统就是支持 **多道程序** 协作式操作系统。
早期的计算机系统大部分是单处理器计算机系统。当处理器进一步发展后,它与IO的速度差距也进一步拉大。这时计算机科学家发现,在 **多道程序** 运行方式下,一个程序如果不让出处理器,其他程序是无法执行的。如果一个应用由于IO操作让处理器空闲下来或让处理器忙等,那其他需要处理器资源进行计算的应用还是没法使用空闲的处理器资源。于是就想到,让应用在执行IO操作时,可以主动 **释放处理器** ,让其他应用继续执行。当然执行 **放弃处理器** 的操作算是一种对处理器资源的直接管理,所以应用程序可以发出这样的系统调用,让操作系统来具体完成。这样的操作系统就是支持 **多道程序** 协作式操作系统。
**抢占式操作系统**
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
但计算机科学家很快发现,编写应用程序的科学家(简称应用程序员)来自不同的领域,他们不一定有友好互助的意识,也不了解其他程序的执行情况,很难(也没必要)有提高整个系统利用率上的大局观。这导致应用程序员在编写程序时,无法有效做到在合适位置放置 **放弃处理器的系统调用请求** 的编程代码。在他们的脑海里,整个计算机就应该是为他们自己的应用准备的,不用考虑其他程序的运行
计算机科学家很快发现,编写应用程序的科学家(简称应用程序员)来自不同的领域,他们不一定有友好互助的意识,也不了解其他程序的执行情况,很难(也没必要)有提高整个系统利用率上的大局观。在他们的脑海里,整个计算机就应该是为他们自己的应用准备的,不用考虑其他程序的运行。这导致应用程序员在编写程序时,无法做到在程序的合适位置放置 **放弃处理器的系统调用请求** ,这样系统的整体利用率还是无法提高
所以,站在系统的层面,还是需要有一种办法能强制打断应用程序的执行,来提高整个系统的效率,让在整个系统中执行的多个程序之间占用计算机资源的情况相对公平一些。于是就想到了,通过时钟中断来强制打断一个程序的执行,这样一个程序只能运行一段时间(可以简称为一个时间片, Time Slice)就一定会让出处理器,且操作系统可以通过一个程序占用处理器的执行时间来评估程序对资源的消耗
所以,站在系统的层面,还是需要有一种办法能强制打断应用程序的执行,来提高整个系统的效率,让在整个系统中执行的多个程序之间占用计算机资源的情况相对公平一些。根据计算机系统的硬件设计,为提高I/O效率,外设可以通过硬件中断机制来与处理机进行I/O交互操作。这种硬件中断机制·可随时打断应用程序的执行,并让操作系统来完成对外设的I/O响应
我们可以把一个程序的在一个时间片上的执行过程称为一个 **任务** (Task),让操作系统对程序的执行时间进行管理,通过平衡各个程序在整个时间段上的任务数,就达到一定程度的系统公平。这样的操作系统就是支持 **分时多任务** 的抢占式操作系统
而操作系统可进一步利用某种以固定时长为时间间隔的外设中断(比如时钟中断)来强制打断一个程序的执行,这样一个程序只能运行一段时间(可以简称为一个时间片, Time Slice)就一定会让出处理器,且操作系统可以在处理外设的I/O响应后,让不同应用程序分时占用处理器执行,并可通过程序占用处理器的总执行时间来评估运行的程序对处理器资源的消耗
.. _term-task:
本章所介绍的多道程序和分时多任务系统都有一些共同的特点:在内存中同一时间可以驻留多个应用。所有的应用都是在系统启动的时候分别加载到内存的不同区域中。由于目前计算机系统中只有一个处理器,则同一时间最多只有一个应用在执行,剩下的应用则处于就绪状态,需要内核将处理器分配给它们才能开始执行。
我们可以把一个程序在一个时间片上占用处理器执行的过程称为一个 **任务** (Task),让操作系统对不同程序的 **任务** 进行管理。通过平衡各个程序在整个时间段上的任务数,就达到一定程度的系统公平和高效的系统效率。在一个包含多个时间片的时间段上,会有属于不同程序的多个任务在轮流占用处理器执行,这样的操作系统就是支持 **分时多任务** 的抢占式操作系统。
本章所介绍的多道程序和分时多任务系统都有一些共同的特点:在内存中同一时间可以驻留多个应用。所有的应用都是在系统启动的时候分别加载到内存的不同区域中。由于目前计算机系统中只有一个处理器,则同一时间最多只有一个应用在执行,剩下的应用则处于就绪状态,需要内核将处理器分配给它们才能开始执行。一旦应用开始执行,它就处于运行状态了。
本章主要是设计和实现建立支持 **多道程序** 的二叠纪“锯齿螈”初级操作系统、支持多道程序的三叠纪“始初龙”协作式操作系统和支持 **分时多任务** 的三叠纪“腔骨龙”抢占式操作系统,从而对可支持运行一批应用程序的多种执行环境有一个全面和深入的理解,并可归纳抽象出 **任务** , **任务切换** 等操作系统的概念。
......
......@@ -20,16 +20,16 @@
注意,我们需要调整每个应用被构建时候使用的链接脚本 ``linker.ld`` 中的起始地址 ``BASE_ADDRESS`` 为它实际
会被内核加载并运行的地址。也就是要做到:应用知道自己会被加载到某个地址运行,而内核也确实能做到将它加载到那个
地址。这算是应用和内核在某种意义上达成的一种协议。之所以要有这么苛刻的条件,是因为应用和内核的能力都很弱,用性很低。
事实上,目前我们的应用是绝对位置而并不是位置无关的,内核也没有提供相应的重定位机制。
地址。这算是应用和内核在某种意义上达成的一种协议。之所以要有这么苛刻的条件,是因为应用和内核的能力都很弱,用性很低。
事实上,目前应用程序的编址方式是基于绝对位置的而并没做到与位置无关,内核也没有提供相应的重定位机制。
.. note::
可以在 `这里 <https://nju-projectn.github.io/ics-pa-gitbook/ics2020/4.2.html>`_ 找到更多有关
对于编址方式,需要再回顾一下编译原理课讲解的后端代码生成技术,以及计算机组成原理课的指令寻址方式的内容。可以在 `这里 <https://nju-projectn.github.io/ics-pa-gitbook/ics2020/4.2.html>`_ 找到更多有关
位置无关和重定位的说明。
由于每个应用被加载到的位置都不同,也就导致它们 ``linker.ld`` 中的 ``BASE_ADDRESS`` 都是不同的。实际上,
我们写了一个脚本 ``build.py`` 而不是直接 ``cargo build`` 构建应用
由于每个应用被加载到的位置都不同,也就导致它们的链接脚本 ``linker.ld`` 中的 ``BASE_ADDRESS`` 都是不同的。实际上,
我们写了一个脚本 ``build.py`` 而不是直接用 ``cargo build`` 构建应用的链接脚本
.. code-block:: python
:linenos:
......@@ -73,7 +73,7 @@
多道程序加载
----------------------------
应用的加载方式也和上一章不同。上一章中讲解的加载方法是所有应用都共享同一个固定的加载物理地址,也是因为这个原因,内存中同时最多只能驻留一个应用,当它运行完毕或者出错退出的时候由 ``batch`` 子模块加载一个新的应用来替换掉它。本章中,所有的应用在内核初始化的时候就一并被加载到内存中。为了避免覆盖,它们自然需要被加载到不同的物理地址。这是通过
应用的加载方式也和上一章的有所不同。上一章中讲解的加载方法是让所有应用都共享同一个固定的加载物理地址。也是因为这个原因,内存中同时最多只能驻留一个应用,当它运行完毕或者出错退出的时候由操作系统的 ``batch`` 子模块加载一个新的应用来替换掉它。本章中,所有的应用在内核初始化的时候就一并被加载到内存中。为了避免覆盖,它们自然需要被加载到不同的物理地址。这是通过
调用 ``loader`` 子模块的 ``load_apps`` 函数实现的:
.. code-block:: rust
......@@ -135,7 +135,7 @@
执行应用程序
----------------------------
当多道程序的初始化放置工作完成,或者是某个应用程序运行结束或出错的时候,我们要调用 run_next_app 函数切换到下一个应用程序。此时 CPU 运行在 S 特权级的操作系统中,而操作系统希望能够切换到 U 特权级去运行应用程序。这一过程与上章的 :ref:`执行应用程序 <ch2-app-execution>` 一节的描述类似。相对不同的是,操作系统知道每个应用程序加载在内存中的位置,要设置应用程序返回的不同 Trap 上下文
当多道程序的初始化放置工作完成,或者是某个应用程序运行结束或出错的时候,我们要调用 run_next_app 函数切换到下一个应用程序。此时 CPU 运行在 S 特权级的操作系统中,而操作系统希望能够切换到 U 特权级去运行应用程序。这一过程与上章的 :ref:`执行应用程序 <ch2-app-execution>` 一节的描述类似。相对不同的是,操作系统知道每个应用程序预先加载在内存中的位置,这就需要设置应用程序返回的不同 Trap 上下文(Trap上下文中保存了 放置程序起始地址的``epc`` 寄存器内容)
- 跳转到应用程序(编号 :math:`i` )的入口点 :math:`\text{entry}_i`
- 将使用的栈切换到用户栈 :math:`\text{stack}_i`
......
......@@ -4,9 +4,9 @@
**本节导读**
--------------------------
在上一节实现的二叠纪“锯齿螈”操作系统还是比较原始,一个应用会独占 CPU 直到它出错或主动退出。操作系统还是以程序的一次执行过程(从开始到结束)作为处理器切换程序的时间段。为了提高效率,我们需要引入新的操作系统概念 **任务** 、**上下文** 。
在上一节实现的二叠纪“锯齿螈”操作系统还是比较原始,一个应用会独占 CPU 直到它出错或主动退出。操作系统还是以程序的一次执行过程(从开始到结束)作为处理器切换程序的时间段。为了提高效率,我们需要引入新的操作系统概念 **任务** 、 **任务切换** 、**任务上下文** 。
如果把应用程序执行的整个过程进行进一步分析,可以看到,如果程序访问 IO 或睡眠等待时,其实是不需要占用处理器的,于是,我们可以把应用程序的不同时间段的执行过程分为两类,占用处理器执行有效任务的计算阶段和不必占用处理器的等待阶段。这些按时间流连接在一起的不同类型的多个阶段形成了一个我们熟悉的“暂停-继续...”组合的执行流。从开始到结束的整个执行流就是应用程序的整个执行过程。
如果把应用程序执行的整个过程进行进一步分析,可以看到,如果程序访问 IO 或睡眠等待时,其实是不需要占用处理器的,于是我们可以把应用程序的不同时间段的执行过程分为两类,占用处理器执行有效任务的计算阶段和不必占用处理器的等待阶段。这些按时间流连接在一起的不同类型的多个阶段形成了一个我们熟悉的“暂停-继续...”组合的 :ref:`执行流或执行历史 <term-execution-flow>` 。从开始到结束的整个执行流就是应用程序的整个执行过程。
本节的重点是操作系统的核心机制—— **任务切换** 。 任务切换支持的场景是:一个应用在运行途中便会主动交出 CPU 的使用权,此时它只能暂停执行,等到内核重新给它分配处理器资源之后才能恢复并继续执行。
......@@ -19,11 +19,19 @@
如果操作系统能够在某个应用程序处于等待阶段的时候,把处理器转给另外一个处于计算阶段的应用程序,那么只要转换的开销不大,那么处理器的执行效率就会大大提高。当然,这需要应用程序在运行途中能
主动交出 CPU 的使用权,此时它处于等待阶段,等到操作系统让它再次执行后,那它就可以继续执行了。
.. _term-task:
.. _term-task-switch:
到这里,我们就把应用程序的一个计算阶段的执行过程(也是一段执行流)称为一个 **任务** ,所有的任务都完成后,应用程序也就完成了。从一个程序的任务切换到另外一个程序的任务称为 **任务切换** 。为了确保切换后的任务能够正确继续执行,操作系统需要支持让任务的执行“暂停”和“继续”。
.. _term-task-context:
我们又看到了熟悉的“暂停-继续”组合。一旦一条执行流需要支持“暂停-继续”,就需要提供一种执行流切换的机制,而且需要保证执行流被切换出去之前和
切换回来之后,它的状态,也就是在执行过程中同步变化的资源(如寄存器、栈等)需要保持不变,或者变化在它的预期之内。而不是所有的资源都需要被
保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些资源被称为任务上下文 (Task Context) 。
保存,事实上只有那些对于执行流接下来的进行仍然有用,且在它被切换出去的时候有被覆盖的风险的那些资源才有被保存的价值。这些物理资源被称为 **任务上下文 (Task Context)** 。
这里,大家开始在具体的操作系统中接触到了一些抽象的概念,其实这些概念都是具体代码的结构和代码动态执行过程的文字表述而已。
不同类型的上下文与切换
---------------------------------
......@@ -32,11 +40,11 @@
共同遵循某些约定来合作完成这一过程。在前两章,我们已经看到了两种上下文保存/恢复的实例。让我们再来回顾一下它们:
- 第一章《RV64 裸机应用》中,我们介绍了 :ref:`函数调用与栈 <function-call-and-stack>` 。当时提到过,为了支持嵌套函数调用,不仅需要
硬件平台提供特殊的跳转指令,还需要保存和恢复 **函数调用上下文** 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内
,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个过程的“切换”,
硬件平台提供特殊的跳转指令,还需要保存和恢复 :ref:`函数调用上下文 <term-function-context>` 。注意在 *我们* 的定义中,函数调用包含在普通控制流(与异常控制流相对)之内
,且始终用一个固定的栈来保存执行的历史记录,因此函数调用并不涉及执行流的切换。但是我们依然可以将其看成调用者和被调用者两个执行过程的“切换”,
二者的协作体现在它们都遵循调用规范,分别保存一部分通用寄存器,这样的好处是编译器能够有足够的信息来尽可能减少需要保存的寄存器的数目。
虽然当时用了很大的篇幅来说明,但其实整个过程都是编译器负责完成的,我们只需设置好栈就行了。
- 第二章《批处理系统》中第一次涉及到了某种异常(Trap)控制流,即两条执行流的切换,需要保存和恢复 **系统调用(Trap)上下文**。当时,为了让内核能够 *完全掌控* 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件
- 第二章《批处理系统》中第一次涉及到了某种异常(Trap)控制流,即两条执行流的切换,需要保存和恢复 :ref:`系统调用(Trap)上下文 <term-trap-context>` 。当时,为了让内核能够 *完全掌控* 应用的执行,且不会被应用破坏整个系统,我们必须利用硬件
提供的特权级机制,让应用和内核运行在不同的特权级。应用运行在 U 特权级,它所被允许的操作进一步受限,处处被内核监督管理;而内核运行在 S
特权级,有能力处理应用执行过程中提出的请求或遇到的状况。
......@@ -49,13 +57,13 @@
任务切换的设计与实现
---------------------------------
本节的任务切换是第二章的 Trap 之后的另一种异常控制流,都描述两条执行流之间的切换,如果将它和 Trap 切换进行比较
本节的任务切换的执行过程是第二章的 Trap 之后的另一种异常控制流,都是描述两条执行流之间的切换,如果将它和 Trap 切换进行比较,会有如下异同
- 与 Trap 切换不同,它不涉及特权级切换;
- 与 Trap 切换不同,它的一部分是由编译器帮忙完成的;
- 与 Trap 切换相同,它对应用是透明的。
事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 进行处理的时候,其 Trap 执行流可以调用一个特殊的
事实上,它是来自两个不同应用的 Trap 执行流之间的切换。当一个应用 Trap 到 S 模式的操作系统中进行进一步处理(即进入了操作系统的Trap执行流)的时候,其 Trap 执行流可以调用一个特殊的
``__switch`` 函数。这个函数表面上就是一个普通的函数调用:在 ``__switch`` 返回之后,将继续从调用该函数的位置继续向下执行。
但是其间却隐藏着复杂的执行流切换过程。具体来说,调用 ``__switch`` 之后直到它返回前的这段时间,原 Trap 执行流会先被暂停并被
切换出去, CPU 转而运行另一个应用的 Trap 执行流。之后在时机合适的时候,原 Trap 执行流才会从某一条 Trap 执行流(很有可能不是
......@@ -63,8 +71,6 @@
.. image:: task_context.png
.. _term-task-context:
当 Trap 执行流准备调用 ``__switch`` 函数并进入暂停状态的时候,让我们考察一下它内核栈上的情况。如上图所示,在准备调用
``__switch`` 函数之前,内核栈上从栈底到栈顶分别是保存了应用执行状态的 Trap 上下文以及内核在对 Trap 处理的过程中留下的
调用栈信息。由于之后还要恢复回来执行,我们必须保存 CPU 当前的某些寄存器,我们称它们为 **任务上下文** (Task Context)。
......@@ -78,7 +84,7 @@
TaskContext *task_cx_ptr = &task_cx;
由于我们要用 ``task_cx_ptr`` 这个变量来进行保存,自然也要对它进行修改。于是我们还需要指向它的指针 ``task_cx_ptr2`` :
由于我们要用 ``task_cx_ptr`` 这个变量来进行保存任务上下文的地址,自然也要对任务上下文的地址进行修改。于是我们还需要指向 ``task_cx_ptr`` 这个变量的指针 ``task_cx_ptr2`` :
.. code-block:: C
......@@ -104,7 +110,7 @@ Trap 执行流在调用 ``__switch`` 之前就需要明确知道即将切换到
- 阶段 [4]:CPU 从 B 的内核栈栈顶取出任务上下文并恢复寄存器状态,在这之后还要进行退栈操作。
- 阶段 [5]:对于 B 而言, ``__switch`` 函数返回,可以从调用 ``__switch`` 的位置继续向下执行。
从结果来看,我们看到 A 和 B 的状态发生了互换, A 在保存任务上下文之后进入暂停状态,而 B 则恢复过来在 CPU 上执行。
从结果来看,我们看到 A 执行流 和 B 执行流的状态发生了互换, A 在保存任务上下文之后进入暂停状态,而 B 则恢复了上下文并在 CPU 上执行。
下面我们给出 ``__switch`` 的实现:
......
......@@ -22,7 +22,8 @@ lab3中我们引入了任务调度的概念,可以在不同任务之间切换
可以证明,如果令 P.pass = BigStride / P.priority 其中 P.priority 表示进程的优先权(大于 1),而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。证明过程我们在这里略去,有兴趣的同学可以在网上查找相关资料。
其他实验细节:
- stride 调度要求进程优先级 >= 2,所以设定进程优先级 <= 1 会导致错误。
- stride 调度要求进程优先级 :math:`\geq 2`,所以设定进程优先级 :math:`\leq 1` 会导致错误。
- 进程初始 stride 设置为 0 即可。
- 进程初始优先级设置为 16。
......@@ -39,7 +40,7 @@ tips: 可以使用优先级队列比较方便的实现 stride 算法。如使用
需要说明的是 lab3 有3类测例,``ch3_0_*`` 用来检查基本 syscall 的实现,``ch3_1_*`` 基于 yield 来检测基本的调度,``ch3_2_*`` 基于时钟中断来测试 stride 调度算法实现的正确性。测试时可以分别测试 3 组测例,使得输出更加可控、更加清晰。
特别的,我们有一个死循环测例 ``ch3t_deadloop`` 用来保证大家真的实现了始终中断。这一章中我们人为限制一个程序执行的最大时间(必须很大),超过就杀死,这样,我们的程序更不容易被恶意程序伤害。这一规定可以在实验4开始删除,仅仅为通过 lab3 测例设置。
特别的,我们有一个死循环测例 ``ch3t_deadloop`` 用来保证大家真的实现了时钟中断。这一章中我们人为限制一个程序执行的最大时间(必须很大),超过就杀死,这样,我们的程序更不容易被恶意程序伤害。这一规定可以在实验4开始删除,仅仅为通过 lab3 测例设置。
challenge: 实现多核,可以并行调度。
......@@ -61,7 +62,7 @@ challenge: 实现多核,可以并行调度。
(1) 简要描述这一章的进程调度策略。何时进行进程切换?如何选择下一个运行的进程?如何处理新加入的进程?
(2) 在 C 版代码中,同样实现了类似 RR 的调度算法,但是由于没有 VecDeque 这样直接可用的数据结构(Rust很棒对不对),C 版代码的实现严格来讲存在一定问题。大致情况如下:C版代码使用一个进程池(也就是一个 struct proc 的数组)管理进程调度,当一个时间片用尽后,选择下一个进程逻辑在 `chapter3相关代码 <https://github.com/DeathWish5/ucore-Tutorial/blob/ch3/kernel/proc.c#L60-L74>`_ ,也就是当第 i 号进程结束后,会以 i -> max_num -> 0 -> i 的顺序便利进程池,直到找到下一个就绪进程。C 版代码新进程在调度池中的位置选择见 `chapter5相关代码 <https://github.com/DeathWish5/ucore-Tutorial/blob/ch5/kernel/proc.c#L90-L98>`_ ,也就是从头到尾遍历进程池,找到地一个空位。
(2) 在 C 版代码中,同样实现了类似 RR 的调度算法,但是由于没有 VecDeque 这样直接可用的数据结构(Rust很棒对不对),C 版代码的实现严格来讲存在一定问题。大致情况如下:C版代码使用一个进程池(也就是一个 struct proc 的数组)管理进程调度,当一个时间片用尽后,选择下一个进程逻辑在 `chapter3相关代码 <https://github.com/DeathWish5/ucore-Tutorial/blob/ch3/kernel/proc.c#L60-L74>`_ ,也就是当第 i 号进程结束后,会以 i -> max_num -> 0 -> i 的顺序遍历进程池,直到找到下一个就绪进程。C 版代码新进程在调度池中的位置选择见 `chapter5相关代码 <https://github.com/DeathWish5/ucore-Tutorial/blob/ch5/kernel/proc.c#L90-L98>`_ ,也就是从头到尾遍历进程池,找到第一个空位。
(2-1) 在目前这一章(chapter3)两种调度策略有实质不同吗?考虑在一个完整的 os 中,随时可能有新进程产生,这两种策略是否实质相同?
......@@ -141,4 +142,13 @@ challenge: 实现多核,可以并行调度。
- 简单总结与上次实验相比本次实验你增加的东西(控制在5行以内,不要贴代码)。
- 完成问答问题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有那些需要改进的地方,欢迎畅所欲言。
\ No newline at end of file
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。
参考信息
-------------------------------
如果有兴趣进一步了解stride调度相关内容,可以尝试看看:
- `作者 Carl A. Waldspurger 写这个调度算法的原论文 <https://people.cs.umass.edu/~mcorner/courses/691J/papers/PS/waldspurger_stride/waldspurger95stride.pdf>`_
- `作者 Carl A. Waldspurger 的博士生答辩slide <http://www.waldspurger.org/carl/papers/phd-mit-slides.pdf>`_
- `南开大学实验指导中对Stride算法的部分介绍 <https://nankai.gitbook.io/ucore-os-on-risc-v64/lab6/tiao-du-suan-fa-kuang-jia#stride-suan-fa>`_
- `NYU OS课关于Stride Scheduling的Slide <https://cs.nyu.edu/rgrimm/teaching/sp08-os/stride.pdf>`_
\ No newline at end of file
......@@ -39,7 +39,7 @@
-----------------------
本章的应用和上一章相同,只不过由于内核提供给应用的访存接口被替换,应用的构建方式发生了变化,这方面在下面会深入介绍。
因此应用运行起来的话效果是和上一章保持一致的。
因此应用运行起来的效果与上一章是一致的。
获取本章代码:
......
......@@ -46,8 +46,8 @@ Rust 中的动态内存分配
我们课上所介绍到的那些算法都可以随意使用。取决于应用的实际运行状况,每次分配的空间大小可能会有不同,因此也会产生外碎片。
如果在某次分配的时候发现堆空间不足,我们并不会像上一小节介绍的那样移动变量的存放位置让它们紧凑起来从而释放间隙用来分配
(事实上它很难做到这一点),
一般情况下应用会直接通过系统调用(如类 Unix 内核提供的 ``sbrk`` 调用)来向内核请求增加它地址空间内堆的大小,之后
就可以正常分配了。注意这一类系统调用也能缩减堆的大小。
一般情况下应用会直接通过系统调用(如类 Unix 内核提供的 ``sbrk`` 系统调用)来向内核请求增加它地址空间内堆的大小,之后
就可以正常分配了。当然,这一类系统调用也能缩减堆的大小。
鉴于动态分配是一项非常基础的功能,很多高级语言的标准库中都实现了它。以 C 语言为例,C 标准库中提供了如下两个动态分配
的接口函数:
......@@ -60,7 +60,7 @@ Rust 中的动态内存分配
其中,``malloc`` 的作用是从堆中分配一块大小为 ``size`` 字节的空间,并返回一个指向它的指针。而后续不用的时候,将这个
指针传给 ``free`` 即可在堆中回收这块空间。我们通过返回的指针变量来间接访问堆上的空间,而无法直接进行
访问。事实上,我们在程序中能够 *直接* 看到的变量都是被静态分配在栈或者全局数据段上的,它们大小在编译期已知,比如这里
一个指针类型的大小就等于平台的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的
一个指针类型的大小就可以等于计算机可寻址空间的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的
事情。
除了可以灵活利用内存之外,动态分配还允许我们以尽可能小的代价灵活调整变量的生命周期。一个局部变量被静态分配在它所在函数
......@@ -69,8 +69,7 @@ Rust 中的动态内存分配
一个变量的指针到 ``free`` 将它回收之前的这段时间,这个变量在堆上存在。由于需要跨越函数调用,我们需要作为堆上数据代表
的变量在函数间以参数或返回值的形式进行传递,而这些变量一般都很小(如一个指针),其拷贝开销可以忽略。
而动态内存分配的缺点在于:它背后运行着连续内存分配算法,它相比静态分配会带来一些额外的开销。如果动态分配非常频繁,
它甚至可能会成为应用的性能瓶颈。
而动态内存分配的缺点在于:它背后运行着连续内存分配算法,相比静态分配会带来一些额外的开销。如果动态分配非常频繁,可能会产生很多无法使用的空闲空间碎片,甚至可能会成为应用的性能瓶颈。
.. _rust-heap-data-structures:
......@@ -169,10 +168,10 @@ RAII 的含义是说,将一个使用前必须获取的资源的生命周期绑
上边介绍的那些与堆相关的智能指针或容器都可以在 Rust 自带的 ``alloc`` crate 中找到。当我们使用 Rust 标准库
``std`` 的时候可以不用关心这个 crate ,因为标准库内已经已经实现了一套堆管理算法,并将 ``alloc`` 的内容包含在
``std`` 名字空间之下让开发者可以直接使用。然而我们的内核是在禁用了标准库(即 ``no_std`` )的裸机平台,核心库
``core`` 也并没有动态内存分配的功能,这个时候就要考虑利用 ``alloc`` 了。
``core`` 也并没有动态内存分配的功能,这个时候就要考虑利用 ``alloc`` 了。
``alloc`` 需要我们提供给它一个全局的动态内存分配器,它会利用该分配器来管理堆空间,从而它提供的数据结构可以正常
工作。我们的动态内存分配器需要实现它提供的 ``GlobalAlloc`` Trait,这个 Trait 有两个必须实现的抽象接口:
``alloc`` 需要我们提供给它一个全局的动态内存分配器,它会利用该分配器来管理堆空间,从而它提供的数据结构可以正常
工作。具体而言,我们的动态内存分配器需要实现它提供的 ``GlobalAlloc`` Trait,这个 Trait 有两个必须实现的抽象接口:
.. code-block:: rust
......@@ -190,7 +189,7 @@ RAII 的含义是说,将一个使用前必须获取的资源的生命周期绑
**为何 C 语言 malloc 的时候不需要提供对齐需求?**
在 C 语言中,所有对齐要求的最大值是一个平台有关的很小的常数,消耗少量内存即可使得每一次分配都符合这个最大
在 C 语言中,所有对齐要求的最大值是一个平台有关的很小的常数(比如8 bytes),消耗少量内存即可使得每一次分配都符合这个最大
的对齐要求。因此也就不需要区分不同分配的对齐要求了。而在 Rust 中,某些分配的对齐要求可能很大,就只能采用更
加复杂的方法。
......@@ -203,7 +202,7 @@ RAII 的含义是说,将一个使用前必须获取的资源的生命周期绑
buddy_system_allocator = "0.6"
接着,需要引入 ``alloc`` 的依赖,由于它算是 Rust 内置的 crate ,我们并不是在 ``Cargo.toml`` 中进行引入,而是在
接着,需要引入 ``alloc`` 的依赖,由于它算是 Rust 内置的 crate ,我们并不是在 ``Cargo.toml`` 中进行引入,而是在
``main.rs`` 中声明即可:
.. code-block:: rust
......@@ -260,6 +259,8 @@ RAII 的含义是说,将一个使用前必须获取的资源的生命周期绑
最后,让我们尝试一下动态内存分配吧!
.. chyyuu 如何尝试???
.. code-block:: rust
:linenos:
......
......@@ -38,10 +38,10 @@
就可以同时把多个应用的数据驻留在内存中。在任务切换的时候只需完成任务上下文保存与恢复即可,这只是在内存的帮助下保存、
恢复少量通用寄存器,甚至无需访问外存,这从很大程度上降低了任务切换的开销。
在本章的引言中介绍过第三章的做法有哪些问题。对于应用来说,它需要自己决定会被加载到哪个物理地址运行,直接访问真实的
物理内存,这需要开发者对于硬件的特性和使用方法有更多了解,产生额外的学习成本,也会为应用的开发和调试带来不便。从
内核的角度来看,将直接访问物理内存的权力下放到应用会使得它难以对应用的访存行为进行有效管理,已有的特权级机制亦无法
阻止很多来自应用的恶意行为。
在本章的引言中介绍过第三章中操作系统的做法对应用程序开发带了一定的困难。从应用开发的角度看,需要应用程序决定自己会被加载到哪个物理地址运行,需要直接访问真实的
物理内存。这就要求应用开发者对于硬件的特性和使用方法有更多了解,产生额外的学习成本,也会为应用的开发和调试带来不便。从
内核的角度来看,将直接访问物理内存的权力下放到应用会使得它难以对应用程序的访存行为进行有效管理,已有的特权级机制亦无法
阻止很多来自应用程序的恶意行为。
加一层抽象加强内存管理
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
......@@ -57,18 +57,17 @@
.. _term-address-space:
.. _term-virtual-address:
最终,到目前为止仍被内核广泛使用的抽象被称为 **地址空间** (Address Space) 。某种程度上讲,可以将它看成一块
最终,到目前为止仍被操作系统内核广泛使用的抽象被称为 **地址空间** (Address Space) 。某种程度上讲,可以将它看成一块
巨大但并不一定真实存在的内存。在每个应用程序的视角里,操作系统分配给应用程序一个范围有限(但其实很大),独占的连续地址空间(其中有些地方被操作系统限制不能访问,如内核本身占用的虚地址空间等),因此应用程序可以在划分给它的地址空间中随意规划内存布局,它的
各个段也就可以分别放置在地址空间中它希望的位置(当然是操作系统允许应用访问的地址)。应用同样可以使用一个地址作为索引来读写自己地址空间的数据,就像用物理地址
作为索引来读写物理内存上的数据一样。这种地址被称为 **虚拟地址** (Virtual Address) 。
作为索引来读写物理内存上的数据一样。这种地址被称为 **虚拟地址** (Virtual Address) 。当然,操作系统要达到 **地址空间** 抽象的设计目标,需要有计算机硬件的支持,这就是计算机组成原理课上讲到的 ``MMU`` 和 ``TLB`` 等硬件机制。
从此,应用能够直接看到并访问的就只有地址空间,且它的任何一次访存使用的都是虚拟地址,无论取指令来执行还是读写
栈、堆或是全局数据段都是如此。事实上,特权级机制被拓展,使得应用不再具有通过物理地址直接访问物理内存的能力。
从此,应用能够直接看到并访问的内存就只有操作系统提供的地址空间,且它的任何一次访存使用的地址都是虚拟地址,无论取指令来执行还是读写
栈、堆或是全局数据段都是如此。事实上,特权级机制被拓展,使得应用不再具有通过物理地址直接访问物理内存的能力。应用所处的执行环境在安全方面被进一步强化,形成了用户态特权级和地址空间的二维安全措施。
由于每个应用独占一个地址空间,里面只含有自己的各个段,于是它可以随意规划
各个段的分布而无需考虑和其他应用冲突;同时,它完全无法窃取或者破坏其他应用的数据,毕竟那些段在其他应用的地址空间
内,鉴于应用只能通过虚拟地址读写它自己的地址空间,这是它没有能力去访问的。这样看来这个抽象有一定安全性,并为系统
提供了部分稳定性。
内,鉴于应用只能通过虚拟地址读写它自己的地址空间,这是它没有能力去访问的。这是 **地址空间** 抽象对应用程序执行的安全性和稳定性的一种保障。
.. image:: address-translation.png
......@@ -76,13 +75,13 @@
.. _term-address-translation:
我们知道应用的数据终归还是存在物理内存中的,那么虚拟地址如何形成地址空间,虚拟地址空间如何转换为物理内存呢?操作系统可以设计巧妙的数据结构来表示地址空间。但如果完全由操作系统来完成转换每次处理器地址访问所需的虚实地址转换,那开销就太大了。这就需要扩展硬件功能来加速地址转换过程(回忆 ** 计算机组成原理** 课上讲的 ``MMU`` )。
我们知道应用的数据终归还是存在物理内存中的,那么虚拟地址如何形成地址空间,虚拟地址空间如何转换为物理内存呢?操作系统可以设计巧妙的数据结构来表示地址空间。但如果完全由操作系统来完成转换每次处理器地址访问所需的虚实地址转换,那开销就太大了。这就需要扩展硬件功能来加速地址转换过程(回忆 ** 计算机组成原理** 课上讲的 ``MMU`` 和 ``TLB`` )。
增加硬件加速虚实地址转换
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
如上图所示,当应用取指或者执行
开始回顾一下 ** 计算机组成原理** 课。如上图所示,当应用取指或者执行
一条访存指令的时候,它都是在以虚拟地址为索引读写自己的地址空间。此时,CPU 中的 **内存管理单元**
(MMU, Memory Management Unit) 自动将这个虚拟地址进行 **地址转换** (Address Translation) 变为一个物理地址,
也就是物理内存上这个应用的数据真实被存放的位置。也就是说,在 MMU 的帮助下,应用对自己地址空间的读写才能被实际转化为
......@@ -96,9 +95,9 @@
回过头来,在介绍内核对于 CPU 资源的抽象——时分复用的时候,我们曾经提到它为应用制造了一种每个应用独占整个 CPU 的
幻象,而隐藏了多个应用分时共享 CPU 的实质。而地址空间也是如此,应用只需、也只能看到它独占整个地址空间的幻象,而
藏在背后的实质仍然是多个应用共享内存,它们的数据分别存放在内存的不同位置。
藏在背后的实质仍然是多个应用共享物理内存,它们的数据分别存放在内存的不同位置。
地址空间只是一层抽象接口,它有很多种具体的实现策略。对于不同的实现策略来说,内核如何规划应用数据放在物理内存的位置,
地址空间只是一层抽象接口,它有很多种具体的实现策略。对于不同的实现策略来说,操作系统内核如何规划应用数据放在物理内存的位置,
而 MMU 又如何进行地址转换也都是不同的。下面我们简要介绍几种曾经被使用的策略,并探讨它们的优劣。
分段内存管理
......@@ -151,14 +150,15 @@ MMU 需要用一对不同的 :math:`\text{base/bound}` 进行区分。这里由
尽管内碎片被消除了,但内存浪费问题并没有完全解决。这是因为每个段的大小都是不同的(它们可能来自不同的应用,功能
也不同),内核就需要使用更加通用、也更加复杂的连续内存分配算法来进行内存管理,而不能像之前的插槽那样以一个比特
为单位。顾名思义,连续内存分配算法就是每次需要分配一块连续内存来存放一个段的数据。
随着一段时间的分配和回收,物理内存还剩下一些可用的连续块,其中有一些只是很小的间隙,它们自己已经无法被
随着一段时间的分配和回收,物理内存还剩下一些相互不连续的较小的可用连续块,其中有一些只是两个已分配内存块之间的很小的间隙,它们自己可能由于空间较小,已经无法被
用于分配,被称为 **外碎片** (External Fragment) 。
如果这时再想分配一个比较大的块或者越过间隙增长一个段的大小,
就需要将这些外碎片“拼起来”。然而这是一件开销很大的事情,这需要移动一些已有的段在物理内存上的位置让它们
连续分布,从而它们中间的间隙就可以被释放出来用于分配。这个过程同样涉及到极大的内存读写开销。如果连续内存分配算法
如果这时再想分配一个比较大的块,
就需要将这些不连续的外碎片“拼起来”,形成一个大的连续块。然而这是一件开销很大的事情,涉及到极大的内存读写开销。具体而言,这需要移动和调整一些已分配内存块在物理内存上的位置,才能让那些小的外碎片能够合在一起,形成一个大的空闲块。如果连续内存分配算法
选取得当,可以尽可能减少这种操作。课上所讲到的那些算法,包括 first-fit/worst-fit/best-fit 或是 buddy
system,其具体表现取决于实际的应用需求,各有优劣。那么,分段内存管理带来的外碎片和连续内存分配算法比较复杂的
system,其具体表现取决于实际的应用需求,各有优劣。
那么,分段内存管理带来的外碎片和连续内存分配算法比较复杂的
问题可否被解决呢?
分页内存管理
......
......@@ -6,19 +6,25 @@
--------------------------
在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧,偏移量等),访问的空间范围等;以及如何用Rust语言来设计有类型的页表项。
在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧,偏移量等),访问的空间范围等;以及如何用Rust语言来设计有类型的页表项。
虚拟地址和物理地址
------------------------------------------------------
内存控制的CSR寄存器
^^^^^^^^^^^^^^^^^^^^
内存控制相关的CSR寄存器
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
默认情况下 MMU 未被使能,此时无论 CPU 位于哪个特权级,访存的地址都会作为一个物理地址交给对应的内存控制单元来直接
访问物理内存。我们可以通过修改 S 特权级的一个名为 ``satp`` 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存
地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址
被视为一个物理地址还是一个需要经历和 S/U 特权级相同的地址转换的虚拟地址则取决于配置,在这里我们并不深入。
地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。
.. note::
M 特权级的访存地址被视为一个物理地址还是一个需要经历和 S/U 特权级相同的地址转换的虚拟地址取决于硬件配置,在这里我们不会进一步探讨。
.. chyyuu M模式下,应该访问的是物理地址???
.. image:: satp.png
:name: satp-layout
......@@ -49,7 +55,7 @@
属于哪一个虚拟页面/物理页帧。
地址转换是以页为单位进行的,在地址转换的前后地址的页内偏移部分不变。可以认为 MMU 只是从虚拟地址中取出 27 位虚拟页号,
在页表中查到其对应的物理页号(如果存在的话),最后将得到的物理页号与虚拟地址的页内偏移依序拼接到一起就变成了物理地址。
在页表中查到其对应的物理页号(如果存在的话),最后将得到的44位的物理页号与虚拟地址的12位页内偏移依序拼接到一起就变成了56位的物理地址。
.. _high-and-low-256gib:
......@@ -57,14 +63,14 @@
**RV64 架构中虚拟地址为何只有 39 位?**
在 64 位架构上虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有 39 位是真正有意义的。
在 64 位架构上虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有 39 位是真正有意义的。
SV39 分页模式规定 64 位虚拟地址的 :math:`[63:39]` 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个
不合法的虚拟地址。通过这个检查之后 MMU 再取出 39 位尝试将其转化为一个 56 位的物理地址。
不合法的虚拟地址。通过这个检查之后 MMU 再取出 39 位尝试将其转化为一个 56 位的物理地址。
也就是说,所有 :math:`2^{64}` 个虚拟地址中,只有最低的 :math:`256\text{GiB}` (当第 38 位为 0 时)
以及最高的 :math:`256\text{GiB}` (当第 38 位为 1 时)是可能通过 MMU 检查的。当我们写软件代码的时候,一个
地址的位宽毋庸置疑就是 64 位,我们要清楚可用的只有最高和最低这两部分,尽管它们已经巨大的超乎想象了;而本节中
我们专注于介绍 MMU 的机制,强调 MMU 看到的真正用来地址转换的虚拟地址,这只有 39 位。
我们专注于介绍 MMU 的机制,强调 MMU 看到的真正用来地址转换的虚拟地址只有 39 位。
......@@ -315,7 +321,7 @@ usize 的一种简单包装。我们刻意将它们各自抽象出来而不是
有意义并能在页表中查到实际的物理页号的虚拟页号在 :math:`2^{27}` 中也只是很小的一部分。由此线性表的绝大部分空间
其实都是被浪费掉的。
那么如何进行优化呢?核心思想就在于按需分配,也就是说:有多少合法的虚拟页号,我们就维护一个多大的映射,并为此使用
那么如何进行优化呢?核心思想就在于 **按需分配** ,也就是说:有多少合法的虚拟页号,我们就维护一个多大的映射,并为此使用
多大的内存用来保存映射。这是因为,每个应用的地址空间最开始都是空的,或者说所有的虚拟页号均不合法,那么这样的页表
自然不需要占用任何内存, MMU 在地址转换的时候无需关心页表的内容而是将所有的虚拟页号均判为不合法即可。而在后面,
内核已经决定好了一个应用的各逻辑段存放位置之后,它就需要负责从零开始以虚拟页面为单位来让该应用的地址空间的某些部分
......@@ -367,8 +373,11 @@ usize 的一种简单包装。我们刻意将它们各自抽象出来而不是
非叶节点的页表项标志位含义和叶节点相比有一些不同:
- 当 V 为 0 的时候,代表当前字符指针是一个空指针,无法走向下一级节点;
- 只有当 R/W/X 均为 0 的时候才能向下走。在这里我们给出 SV39 中的 R/W/X 组合的含义:
- 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
- 只有当V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表。
- 注意: 当V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。
在这里我们给出 SV39 中的 R/W/X 组合的含义:
.. image:: pte-rwx.png
:align: center
......
......@@ -481,6 +481,7 @@
不会同时对一个 ``PhysPageNum`` 进行操作。读者也应该可以感觉出这并不能算是一种好的设计,因为这种约束从代码层面是很
难直接保证的,而是需要系统内部的某种一致性。虽然如此,它对于我们这个极简的内核而言算是很合适了。
.. chyyuu 上面一段提到了线程???
建立和拆除虚实地址映射关系
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
......@@ -533,7 +534,7 @@
- ``VirtPageNum`` 的 ``indexes`` 可以取出虚拟页号的三级页索引,并按照从高到低的顺序返回。注意它里面包裹的
usize 可能有 :math:`27` 位,也有可能有 :math:`64-12=52` 位,但这里我们是用来在多级页表上进行遍历,因此
只取出 :math:`27` 位。
只取出 :math:`27` 位。
- ``PageTable::find_pte_create`` 在多级页表找到一个虚拟页号对应的页表项的可变引用方便后续的读写。如果在
遍历的过程中发现有节点尚未创建则会新建一个节点。
......@@ -613,3 +614,5 @@
遇到空指针它就会直接返回 ``None`` 表示无法正确找到传入的虚拟页号对应的页表项;
- 第 28 行的 ``translate`` 调用 ``find_pte`` 来实现,如果能够找到页表项,那么它会将页表项拷贝一份并返回,否则就
返回一个 ``None`` 。
.. chyyuu 没有提到from_token的作用???
\ No newline at end of file
......@@ -8,18 +8,18 @@
页表 ``PageTable`` 只能以页为单位帮助我们维护一个地址空间的地址转换关系,它对于整个地址空间并没有一个全局的掌控。本节
我们就在内核中实现地址空间的抽象,并介绍内核和应用的地址空间中各需要包含哪些内容。
页表 ``PageTable`` 只能以页为单位帮助我们维护一个虚拟内存到物理内存的地址转换关系,它本身对于计算机系统的整个虚拟/物理内存空间并没有一个全局的描述和掌控。操作系统通过不同页表的管理,来完成对不同应用和操作系统自身所在的虚拟内存,以及虚拟内存与物理内存映射关系的全面管理。这种管理是建立在地址空间的抽象上的。本节
我们就在内核中通过基于页表的各种数据结构实现地址空间的抽象,并介绍内核和应用的虚拟和物理地址空间中各需要包含哪些内容。
实现地址空间抽象
------------------------------------------
表示连续地址空间的逻辑段抽象
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
逻辑段:一段连续地址的虚拟内存
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
我们以逻辑段 ``MemoryArea`` 为单位描述一个地址空间。所谓逻辑段,就是指地址区间中的一段实际可用(即 MMU 通过查多级页表
可以正确完成地址转换)的地址连续的虚拟地址区间,该区间内包含的所有虚拟页面都以一种相同的方式映射到物理页帧。
我们以逻辑段 ``MapArea`` 为单位描述一段连续地址的虚拟内存。所谓逻辑段,就是指地址区间中的一段实际可用(即 MMU 通过查多级页表
可以正确完成地址转换)的地址连续的虚拟地址区间,该区间内包含的所有虚拟页面都以一种相同的方式映射到物理页帧,具有可读/可写/可执行等属性
.. code-block:: rust
......@@ -35,11 +35,11 @@
其中 ``VPNRange`` 描述一段虚拟页号的连续区间,表示该逻辑段在地址区间中的位置和长度。它是一个迭代器,可以使用 Rust
的语法糖 for-loop 进行迭代。有兴趣的读者可以参考 ``os/src/mm/address.rs`` 中它的实现。
.. warning::
.. note::
**Rust 语法卡片:迭代器 Iterator**
之后有时间再补。
Rust编程的迭代器模式允许你对一个序列的项进行某些处理。迭代器(iterator)是负责遍历序列中的每一项和决定序列何时结束的控制逻辑。对于如何使用迭代器处理元素序列和如何实现 Iterator trait 来创建自定义迭代器的内容,可以参考 `Rust 程序设计语言-中文版第十三章第二节 <https://kaisery.github.io/trpl-zh-cn/ch13-02-iterators.html>`_
``MapType`` 描述该逻辑段内的所有虚拟页面映射到物理页帧的同一种方式,它是一个枚举类型,在内核当前的实现中支持两种方式:
......@@ -57,9 +57,9 @@
``Framed`` 则表示对于每个虚拟页面都需要映射到一个新分配的物理页帧。
当逻辑段采用 ``MapType::Framed`` 方式映射到物理内存的时候, ``data_frames`` 是一个保存了该逻辑段内的每个虚拟页面
和它被映射到的物理页帧 ``FrameTracker`` 的一个键值对容器 ``BTreeMap`` 中,这些物理页帧被用来实际存放数据而不是
多级页表中的节点。和之前的 ``PageTable`` 一样,这也用到了 RAII 的思想,将这些物理页帧的生命周期绑定到它所在的逻辑段
``MapArea`` 下,当逻辑段被回收之后这些之前分配的物理页帧也会同时被回收。
和它被映射到的物理页帧 ``FrameTracker`` 的一个键值对容器 ``BTreeMap`` 中,这些物理页帧被用来存放实际内存数据而不是
作为多级页表中的中间节点。和之前的 ``PageTable`` 一样,这也用到了 RAII 的思想,将这些物理页帧的生命周期绑定到它所在的逻辑段
``MapArea`` 下,当逻辑段被回收之后这些之前分配的物理页帧也会自动地同时被回收。
``MapPermission`` 表示控制该逻辑段的访问方式,它是页表项标志位 ``PTEFlags`` 的一个子集,仅保留 U/R/W/X
四个标志位,因为其他的标志位仅与硬件的地址转换机制细节相关,这样的设计能避免引入错误的标志位。
......@@ -79,10 +79,10 @@
表示一系列逻辑段的地址空间抽象
地址空间:一系列有关联的逻辑段
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
地址空间使用 ``MemorySet`` 类型来表示:
地址空间是一系列有关联的逻辑段,这种关联一般是指这些逻辑段属于一个运行的程序(目前把一个运行的程序称为任务,后续会称为进程)。用来表明正在运行的应用所在执行环境中的可访问内存空间,在这个内存空间中,包含了一系列的不一定连续的逻辑段。这样我们就有任务的地址空间,内核的地址空间等说法了。地址空间使用 ``MemorySet`` 类型来表示:
.. code-block:: rust
......@@ -94,7 +94,7 @@
}
它包含了该地址空间的多级页表 ``page_table`` 和一个逻辑段 ``MapArea`` 的向量 ``areas`` 。注意 ``PageTable`` 下
挂着所有多级页表的节点被放在的物理页帧,而每个 ``MapArea`` 下则挂着对应逻辑段的数据被内核实际放在的物理页帧,这两部分
挂着所有多级页表的节点所在的物理页帧,而每个 ``MapArea`` 下则挂着对应逻辑段中的数据所在的物理页帧,这两部分
合在一起构成了一个地址空间所需的所有物理页帧。这同样是一种 RAII 风格,当一个地址空间 ``MemorySet`` 生命周期结束后,
这些物理页帧都会被回收。
......
......@@ -20,11 +20,12 @@ mmap 系统调用新定义:
- Rust接口: ``fn mmap(start: usize, len: usize, port: usize) -> i32``
- 功能:申请长度为 len 字节的物理内存(不要求实际物理内存位置,可以随便找一块),并映射到 addr 开始的虚存,内存页属性为 port。
- 参数:
- start 需要映射的虚存起始地址。
- len:映射字节长度,可以为0(如果是则直接返回),不可过大(上限1G)。0位表示是否可读,第1位表示是否可写,第2位表示是否可执行。其他位无效(必须为0)。
- start:需要映射的虚存起始地址。
- len:映射字节长度,可以为 0 (如果是则直接返回),不可过大(上限 1GiB )。
- port:第 0 位表示是否可读,第 1 位表示是否可写,第 2 位表示是否可执行。其他位无效(必须为 0 )。
- 说明:
- 正确时返回实际 map size(为 4096 的倍数),错误返回-1
- 为了简单,addr 要求按页对其(否则报错),len 可直接按页取上整。
- 正确时返回实际 map size(为 4096 的倍数),错误返回 -1
- 为了简单,addr 要求按页对齐(否则报错),len 可直接按页上取整。
- 为了简单,不考虑分配失败时的页回收(也就是内存泄漏)。
- 错误:
- [addr, addr + len) 存在已经被映射的页。
......@@ -35,8 +36,8 @@ mmap 系统调用新定义:
munmap 系统调用新定义:
- syscall ID:215
- C接口: ``int mmap(void* start, unsigned long long len)``
- Rust接口: ``fn mmap(start: usize, len: usize) -> i32``
- C接口: ``int munmap(void* start, unsigned long long len)``
- Rust接口: ``fn munmap(start: usize, len: usize) -> i32``
- 功能:取消一块虚存的映射。
- 参数:同 mmap
- 说明:
......
引言
=========================================
本章导读
-----------------------------------------
在第六章中,我们为进程引入了文件的抽象,使得进程能够通过一个统一的接口来读写内核管理的多种不同的 I/O 资源。作为例子,我们实现了匿名管道,并通过它进行了简单的父子进程间的单向通信。
本章我们对一种非常重要的外设—— **持久存储设备** (Persistent Storage) 进行管理。事实上,大名鼎鼎的 MS-DOS 是 Disk Operating System 的缩写,在那个时候硬盘就已经成为操作系统管理的主要系统组件之一。在此之前我们仅使用一种存储,也就是内存(或称 RAM)。相比内存,持久存储设备的读写速度较慢,容量较大,且掉电之后并不会丢失数据。如果没有持久存储设备的话,我们在内核上的任何活动记录全部只能保存在内存上,一旦掉电这些数据将会全部丢失。因此,将需要持久保存的数据从内存写入到持久存储设备,或是从持久存储读入到内存是应用必不可少的一种需求。常见的持久存储设备有硬盘、磁盘以及固态硬盘 (SSD, Solid-State Drive) 等。
对于应用访问持久存储设备的需求,内核需要新增两种文件:标准文件和目录,它们均以文件系统的形式被组织并保存在持久存储设备上。
实践体验
-----------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch7
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
若要在 k210 平台上运行,首先需要将 microSD 通过读卡器插入 PC ,然后将打包应用 ELF 的文件系统镜像烧写到 microSD 中:
.. code-block:: console
$ cd os
$ make sdcard
Are you sure write to /dev/sdb ? [y/N]
y
16+0 records in
16+0 records out
16777216 bytes (17 MB, 16 MiB) copied, 1.76044 s, 9.5 MB/s
8192+0 records in
8192+0 records out
4194304 bytes (4.2 MB, 4.0 MiB) copied, 3.44472 s, 1.2 MB/s
途中需要输入 ``y`` 确认将文件系统烧写到默认的 microSD 所在位置 ``/dev/sdb`` 中。这个位置可以在 ``os/Makefile`` 中的 ``SDCARD`` 处进行修改,在烧写之前请确认它被正确配置为 microSD 的实际位置,否则可能会造成数据损失。
烧写之后,将 microSD 插入到 Maix 系列开发板并连接到 PC,然后在开发板上运行本章代码:
.. code-block:: console
$ cd os
$ make run BOARD=k210
内核初始化完成之后就会进入用户终端,在这里我们运行一下本章的测例 ``filetest_simple`` :
.. code-block::
>> filetest_simple
file_test passed!
Shell: Process 2 exited with code 0
>>
它会将 ``Hello, world!`` 输出到另一个文件 ``filea`` ,并读取里面的内容确认输出正确。我们也可以通过命令行工具 ``cat`` 来更直观的查看 ``filea`` 中的内容:
.. code-block::
>> cat filea
Hello, world!
Shell: Process 2 exited with code 0
>>
此外,在本章我们为用户终端支持了输入/输出重定向功能,可以将一个应用的输出保存到一个指定的文件。例如,下面的命令可以将 ``yield`` 应用的输出保存在文件 ``fileb`` 当中,并在应用执行完毕之后确认它的输出:
.. code-block::
>> yield > fileb
Shell: Process 2 exited with code 0
>> cat fileb
Hello, I am process 2.
Back in process 2, iteration 0.
Back in process 2, iteration 1.
Back in process 2, iteration 2.
Back in process 2, iteration 3.
Back in process 2, iteration 4.
yield pass.
Shell: Process 2 exited with code 0
>>
本章代码树
-----------------------------------------
.. code-block::
:linenos:
:emphasize-lines: 45
├── bootloader
│   ├── rustsbi-k210.bin
│   └── rustsbi-qemu.bin
├── Dockerfile
├── easy-fs(新增:从内核中独立出来的一个简单的文件系统 EasyFileSystem 的实现)
│   ├── Cargo.toml
│   └── src
│   ├── bitmap.rs(位图抽象)
│   ├── block_cache.rs(块缓存层,将块设备中的部分块缓存在内存中)
│   ├── block_dev.rs(声明块设备抽象接口 BlockDevice,需要库的使用者提供其实现)
│   ├── efs.rs(实现整个 EasyFileSystem 的磁盘布局)
│   ├── layout.rs(一些保存在磁盘上的数据结构的内存布局)
│   ├── lib.rs
│   └── vfs.rs(提供虚拟文件系统的核心抽象,即索引节点 Inode)
├── easy-fs-fuse(新增:将当前 OS 上的应用可执行文件按照 easy-fs 的格式进行打包)
│   ├── Cargo.toml
│   └── src
│   └── main.rs
├── LICENSE
├── Makefile
├── os
│   ├── build.rs
│   ├── Cargo.toml(修改:新增 Qemu 和 K210 两个平台的块设备驱动依赖 crate)
│   ├── Makefile(修改:新增文件系统的构建流程)
│   └── src
│   ├── config.rs(修改:新增访问块设备所需的一些 MMIO 配置)
│   ├── console.rs
│   ├── drivers(修改:新增 Qemu 和 K210 两个平台的块设备驱动)
│   │   ├── block
│   │   │   ├── mod.rs(将不同平台上的块设备全局实例化为 BLOCK_DEVICE 提供给其他模块使用)
│   │   │   ├── sdcard.rs(K210 平台上的 microSD 块设备)
│   │   │   └── virtio_blk.rs(Qemu 平台的 virtio-blk 块设备)
│   │   └── mod.rs
│   ├── entry.asm
│   ├── fs(修改:在文件系统中新增普通文件的支持)
│   │   ├── inode.rs(新增:将 easy-fs 提供的 Inode 抽象封装为内核看到的 OSInode
│   │   │ 并实现 fs 子模块的 File Trait)
│   │   ├── mod.rs
│   │   ├── pipe.rs
│   │   └── stdio.rs
│   ├── lang_items.rs
│   ├── link_app.S
│   ├── linker-k210.ld
│   ├── linker-qemu.ld
│   ├── loader.rs(移除:应用加载器 loader 子模块,本章开始从文件系统中加载应用)
│   ├── main.rs
│   ├── mm
│   │   ├── address.rs
│   │   ├── frame_allocator.rs
│   │   ├── heap_allocator.rs
│   │   ├── memory_set.rs(修改:在创建地址空间的时候插入 MMIO 虚拟页面)
│   │   ├── mod.rs
│   │   └── page_table.rs
│   ├── sbi.rs
│   ├── syscall
│   │   ├── fs.rs(修改:新增 sys_open/sys_dup)
│   │   ├── mod.rs
│   │   └── process.rs(修改:sys_exec 改为从文件系统中加载 ELF,并支持命令行参数)
│   ├── task
│   │   ├── context.rs
│   │   ├── manager.rs
│   │   ├── mod.rs(修改初始进程 INITPROC 的初始化)
│   │   ├── pid.rs
│   │   ├── processor.rs
│   │   ├── switch.rs
│   │   ├── switch.S
│   │   └── task.rs
│   ├── timer.rs
│   └── trap
│   ├── context.rs
│   ├── mod.rs
│   └── trap.S
├── README.md
├── rust-toolchain
├── tools
│   ├── kflash.py
│   ├── LICENSE
│   ├── package.json
│   ├── README.rst
│   └── setup.py
└── user
├── Cargo.lock
├── Cargo.toml
├── Makefile
└── src
├── bin
│   ├── cat.rs(新增)
│   ├── cmdline_args.rs(新增)
│   ├── exit.rs
│   ├── fantastic_text.rs
│   ├── filetest_simple.rs(新增:简单文件系统测例)
│   ├── forktest2.rs
│   ├── forktest.rs
│   ├── forktest_simple.rs
│   ├── forktree.rs
│   ├── hello_world.rs
│   ├── initproc.rs
│   ├── matrix.rs
│   ├── pipe_large_test.rs
│   ├── pipetest.rs
│   ├── run_pipe_test.rs
│   ├── sleep.rs
│   ├── sleep_simple.rs
│   ├── stack_overflow.rs
│   ├── user_shell.rs(修改:支持命令行参数解析和输入/输出重定向)
│   ├── usertests.rs
│   └── yield.rs
├── console.rs
├── lang_items.rs
├── lib.rs(修改:支持命令行参数解析)
├── linker.ld
└── syscall.rs(修改:新增 sys_open 和 sys_dup)
\ No newline at end of file
文件系统接口
=================================================
本节导读
-------------------------------------------------
本节我们首先介绍 Linux 上的标准文件和目录对用户来说值得注意的地方及使用方法。由于 Linux 上的文件系统模型还是比较复杂,在我们的内核实现中对它进行了很大程度的简化,我们会对简化的具体情形进行介绍。最后,我们介绍我们内核上应用的开发者应该如何使用我们简化后的文件系统和一些相关知识。
Linux 标准文件和目录
-------------------------------------------------
标准文件
+++++++++++++++++++++++++++++++++++++++++++++++++
在操作系统的用户看来,标准文件是保存在持久存储设备上的一个字节序列,每个标准文件都有一个 **文件名** (Filename) ,用户需要通过它来区分不同的标准文件。方便起见,在下面的描述中,“文件”有可能指的是包括标准文件在内的若干种进程可以读写的 I/O 资源,也有可能指的是标准文件本身,请读者自行根据上下文判断取哪种含义。
在 Linux 系统上, ``stat`` 工具可以获取文件的一些信息。下面以我们项目中的一个源代码文件 ``os/src/main.rs`` 为例:
.. code-block:: console
$ cd os/src/
$ stat main.rs
File: main.rs
Size: 940 Blocks: 8 IO Block: 4096 regular file
Device: 801h/2049d Inode: 4975 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ oslab) Gid: ( 1000/ oslab)
Access: 2021-02-28 23:32:50.289925450 +0800
Modify: 2021-02-28 23:32:50.133927136 +0800
Change: 2021-02-28 23:32:50.133927136 +0800
Birth: -
``stat`` 工具展示了 ``main.rs`` 的如下信息:
- File 表明它的文件名为 ``main.rs`` 。
- Size 表明它的字节大小为 940 字节。
- Blocks 表明它占据 8 个 **块** (Block) 来存储。在文件系统中,文件的数据以块为单位进行存储,在 IO Block 可以看出在 Ubuntu 系统中每个块的大小为 4096 字节。
- regular file 表明这个文件是一个标准文件。事实上,其他类型的文件也可以通过文件名来进行访问。
- 当文件是一个特殊文件(如块设备文件或者字符设备文件的时候),Device 将指出该特殊文件的 major/minor ID 。对于一个标准文件,我们无需关心它。
- Inode 表示文件的底层编号。在文件系统的底层实现中,并不是直接通过文件名来索引文件,而是首先需要将文件名转化为文件的底层编号,再根据这个编号去索引文件。然而,用户无需关心这一信息。
- Links 给出文件的硬链接数。有关软硬链接的相关知识我们在这里不进行介绍,有兴趣的读者可以自行搜索了解。
- Uid 给出该文件的所属的用户 ID , Gid 给出该文件所属的用户组 ID 。Access 的其中一种表示是一个长度为 10 的字符串(这里是 ``-rw-r--r--`` ),其中第 1 位给出该文件的类型,这个文件是一个标准文件,因此这第 1 位为 ``-`` 。后面的 9 位可以分为三组,分别表示该文件的所有者/在该文件所属的用户组内的其他用户以及剩下的所有用户能够读取/写入/将该文件作为一个可执行文件来执行。
- Access/Modify 分别给出该文件的最近一次访问/最近一次修改时间。
如果我们使用 ``stat`` 工具查看我们构建的一个能在我们的内核上执行的应用 ELF 可执行文件:
.. code-block:: console
$ cd user/target/riscv64gc-unknown-none-elf/release/
$ stat user_shell
File: user_shell
Size: 85712 Blocks: 168 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1460936 Links: 2
Access: (0755/-rwxr-xr-x) Uid: ( 1000/ oslab) Gid: ( 1000/ oslab)
Access: 2021-03-01 11:21:34.785309066 +0800
Modify: 2021-03-01 11:21:32.829332116 +0800
Change: 2021-03-01 11:21:32.833332069 +0800
Birth: -
从中可以看出我们构建的应用体积大概在数十 KiB 量级。它的 Access 指出所有用户均可将其作为一个可执行文件在当前 OS 中加载并执行。然而这仅仅是能够通过权限检查而已,这个应用只有在我们自己的内核上才能真正被加载运行。
用户常常通过文件的 **拓展名** (Filename extension) 来推断该文件的用途,如 ``main.rs`` 的拓展名是 ``.rs`` ,我们由此知道它是一个 Rust 源代码文件。但从内核的角度来看,它会将所有文件无差别的看成一个字节序列,文件内容的结构和含义则是交给对应的应用进行解析。
目录
+++++++++++++++++++++++++++++++++++++++++++++++++
最早的文件系统仅仅通过文件名来区分文件,但是这会造成一些归档和管理上的困难。如今我们的使用习惯是将文件根据功能、属性的不同分类归档到不同层级的目录之下。这样我们就很容易逐级找到想要的文件。结合用户和用户组的概念,目录的存在也使得权限控制更加容易,只需要对于目录进行设置就可以间接设置用户/用户组对该目录下所有文件的访问权限,这使得操作系统能够更加安全的支持多用户。
同样可以通过 ``stat`` 工具获取目录的一些信息:
.. code-block:: console
$ stat os
File: os
Size: 4096 Blocks: 8 IO Block: 4096 directory
Device: 801h/2049d Inode: 4982 Links: 5
Access: (0755/drwxr-xr-x) Uid: ( 1000/ oslab) Gid: ( 1000/ oslab)
Access: 2021-02-28 23:32:50.133927136 +0800
Modify: 2021-02-28 23:32:50.129927180 +0800
Change: 2021-02-28 23:32:50.129927180 +0800
Birth: -
directory 表明 ``os`` 是一个目录,从 Access 字符串的首位 ``d`` 也可以看出这一点。对于目录而言, Access 的 ``rwx`` 含义有所不同:
- ``r`` 表示是否允许获取该目录下有哪些文件和子目录;
- ``w`` 表示是否允许在该目录下创建/删除文件和子目录;
- ``x`` 表示是否允许“通过”该目录。
Blocks 给出 ``os`` 目录也占用 8 个块进行存储。实际上目录也可以看作一种标准文件,它也有属于自己的底层编号,它的内容中保存着若干 **目录项** (Dirent, Directory Entry) ,可以看成一组映射,根据它下面的文件或子目录的文件名或目录名能够查到文件和子目录在文件系统中的底层编号,即 Inode 编号。但是与标准文件不同的是,用户无法 **直接** 修改目录的内容,只能通过创建/删除它下面的文件或子目录才能间接做到这一点。
有了目录之后,我们就可以将所有的文件和目录组织为一种被称为 **目录树** (Directory Tree) 的有根树结构(不考虑软链接)。树中的每个节点都是一个文件或目录,一个目录下面的所有的文件和子目录都是它的孩子。可以看出所有的文件都是目录树的叶子节点。目录树的根节点也是一个目录,它被称为 **根目录** (Root Directory)。目录树中的每个目录和文件都可以用它的 **绝对路径** (Absolute Path) 来进行索引,该绝对路径是目录树上的根节点到待索引的目录和文件所在的节点之间自上而下的路径上的所有节点的文件或目录名两两之间加上路径分隔符拼接得到的。例如,在 Linux 上,根目录的绝对路径是 ``/`` ,路径分隔符也是 ``/`` ,因此:
- ``main.rs`` 的绝对路径是 ``/home/oslab/workspace/v3/rCore-Tutorial-v3/os/src/main.rs`` ;
- ``os`` 目录的绝对路径则是 ``/home/oslab/workspace/v3/rCore-Tutorial-v3/os/`` 。
上面的绝对路径因具体环境而异。
一般情况下,绝对路径都很长,用起来颇为不便。而且,在日常使用中,我们通常固定在一个工作目录下而不会频繁切换目录。因此更为常用的是 **相对路径** (Relative Path) 而非绝对路径。每个进程都会记录自己当前所在的工作目录,当它在索引文件或目录的时候,如果传给它的路径并未以 ``/`` 开头则会被内核认为是一个相对于进程当前工作目录的相对路径,这个路径会被拼接在进程当前路径的后面组成一个绝对路径,实际索引的是这个绝对路径对应的文件或目录。其中, ``./`` 表示当前目录,而 ``../`` 表示当前目录的父目录,这在通过相对路径进行索引的时候非常实用。在使用终端的时候, ``pwd`` 工具可以打印终端进程当前所在的目录,而通过 ``cd`` 可以切换终端进程的工作目录。
一旦引入目录之后,我们就不再单纯的通过文件名来索引文件,而是通过路径(绝对或相对)进行索引。在文件系统的底层实现中,也是对应的先将路径转化为一个文件或目录的底层编号,然后再通过这个编号具体索引文件或目录。将路径转化为底层编号的过程是逐级进行的,对于绝对路径的情况,需要从根目录出发,每次根据当前目录底层编号获取到它的内容,根据下一级子目录的目录名查到该子目录的底层编号,然后从该子目录继续向下遍历,依此类推。在这个过程目录的权限控制位将会起到保护作用,阻止无权限用户进行访问。
.. note::
**目录是否有必要存在**
基于路径的索引难以并行或分布式化,因为我们总是需要查到一级目录的底层编号才能查到下一级,这是一个天然串行的过程。在一些性能需求极高的环境中,可以考虑弱化目录的权限控制职能,将目录树结构扁平化,将文件系统的磁盘布局变为类键值对存储。
文件系统
+++++++++++++++++++++++++++++++++++++++++++++++++
标准文件和目录都是实际保存在持久存储设备中的。持久存储设备仅支持以扇区为单位的随机读写,这和上面介绍的通过路径即可索引到文件并进行读写的用户视角有很大的不同。负责中间转换的便是 **文件系统** (File System) 。具体而言,文件系统负责将逻辑上的目录树结构(包括其中每个文件或目录的数据和其他信息)映射到持久存储设备上,决定设备上的每个扇区各应存储哪些内容。反过来,文件系统也可以从持久存储设备还原出逻辑上的目录树结构。
文件系统有很多种不同的实现,每一种都能将同一个逻辑上目录树结构转化为一个不同的持久存储设备上的扇区布局。最著名的文件系统有 Windows 上的 FAT/NTFS 和 Linux 上的 ext3/ext4 。
在一个复杂的计算机系统中,可以同时包含多个持久存储设备,它们上面的数据可能是以不同文件系统格式存储的。为了能够对它们进行统一管理,在内核中有一层 **虚拟文件系统** (VFS, Virtual File System) ,它规定了逻辑上目录树结构的通用格式及相关操作的抽象接口,只要不同的底层文件系统均实现虚拟文件系统要求的那些抽象接口,再加上 **挂载** (Mount) 等方式,这些持久存储设备上的不同文件系统便可以用一个统一的逻辑目录树结构一并进行管理。
简易文件与目录抽象
-------------------------------------------------
我们的内核实现对于目录树结构进行了很大程度上的简化,这样做的目的是为了能够完整的展示文件系统的工作原理,但代码量又不至于太多。我们进行的简化如下:
- 扁平化:仅存在根目录 ``/`` 一个目录,剩下所有的文件都放在根目录内。在索引一个文件的时候,我们直接使用文件的文件名而不是它含有 ``/`` 的绝对路径。
- 权限控制:我们不设置用户和用户组概念,全程只有单用户。同时根目录和其他文件也都没有权限控制位,即完全不限制文件的访问方式,不会区分文件是否可执行。
- 不记录文件访问/修改的任何时间戳。
- 不支持软硬链接。
- 除了文档中即将介绍的系统调用之外,其他的很多文件系统相关系统调用均未实现。
文件打开与读写
--------------------------------------------------
文件打开
++++++++++++++++++++++++++++++++++++++++++++++++++
在读写一个标准文件之前,应用首先需要通过内核提供的 ``sys_open`` 系统调用将该文件打开在进程的文件描述符表中:
.. code-block:: rust
/// 功能:打开一个标准文件,并返回可以访问它的文件描述符。
/// 参数:path 描述要打开的文件的文件名(简单起见,文件系统不需要支持目录,所有的文件都放在根目录 / 下),
/// flags 描述打开文件的标志,具体含义下面给出。
/// 返回值:如果出现了错误则返回 -1,打开标准文件的文件描述符。可能的错误原因是:文件不存在。
/// syscall ID:56
pub fn sys_open(path: *const u8, flags: u32) -> isize;
目前我们的内核支持以下几种标志(多种不同标志可能共存):
- 如果 ``flags`` 为 0,则表示以只读模式 *RDONLY* 打开;
- 如果 ``flags`` 第 0 位被设置(0x001),表示以只写模式 *WRONLY* 打开;
- 如果 ``flags`` 第 1 位被设置(0x002),表示既可读又可写 *RDWR* ;
- 如果 ``flags`` 第 9 位被设置(0x200),表示允许创建文件 *CREATE* ,在找不到该文件的时候应创建文件;如果该文件已经存在则应该将该文件的大小归零;
- 如果 ``flags`` 第 10 位被设置(0x400),则在打开文件的时候应该清空文件的内容并将该文件的大小归零,也即 *TRUNC* 。
注意 ``flags`` 里面的权限设置只能控制进程对本次打开的文件的访问。一般情况下,在打开文件的时候首先需要经过文件系统的权限检查,比如一个文件自身不允许写入,那么进程自然也就不能以 *WRONLY* 或 *RDWR* 标志打开文件。但在我们简化版的文件系统中文件不进行权限设置,这一步就可以绕过。
在用户库 ``user_lib`` 中,我们将该系统调用封装为 ``open`` 接口:
.. code-block:: rust
// user/src/lib.rs
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}
pub fn open(path: &str, flags: OpenFlags) -> isize {
sys_open(path, flags.bits)
}
借助 ``bitflags!`` 宏我们将一个 ``u32`` 的 flags 包装为一个 ``OpenFlags`` 结构体更易使用,它的 ``bits`` 字段可以将自身转回 ``u32`` ,它也会被传给 ``sys_open`` :
.. code-block:: rust
// user/src/syscall.rs
const SYSCALL_OPEN: usize = 56;
pub fn sys_open(path: &str, flags: u32) -> isize {
syscall(SYSCALL_OPEN, [path.as_ptr() as usize, flags as usize, 0])
}
我们在 ``sys_open`` 传给内核的两个参数只有待打开文件的文件名字符串的起始地址(和之前一样,我们需要保证该字符串以 ``\0`` 结尾)还有标志位。由于每个通用寄存器为 64 位,我们需要先将 ``u32`` 的 ``flags`` 转换为 ``usize`` 。
文件的顺序读写
++++++++++++++++++++++++++++++++++++++++++++++++++
在打开一个文件之后,我们就可以用之前的 ``sys_read/sys_write`` 两个系统调用来对它进行读写了。需要注意的是,标准文件的读写模型和之前介绍过的几种文件有所不同。标准输入输出和匿名管道都属于一种流式读写,而标准文件则是顺序读写和随机读写的结合。由于标准文件可以看成一段字节序列,我们应该能够随意读写它的任一段区间的数据,即随机读写。然而用户仅仅通过 ``sys_read/sys_write`` 两个系统调用不能做到这一点。
事实上,进程为每个它打开的标准文件维护了一个偏移量,在刚打开时初始值一般为 0 字节。当 ``sys_read/sys_write`` 的时候,将会从文件字节序列偏移量的位置开始 **顺序** 把数据读到应用缓冲区/从应用缓冲区写入数据。操作完成之后,偏移量向后移动读取/写入的实际字节数。这意味着,下次 ``sys_read/sys_write`` 将会从刚刚读取/写入之后的位置继续。如果仅使用 ``sys_read/sys_write`` 的话,则只能从头到尾顺序对文件进行读写。当我们需要从头开始重新写入或读取的话,只能通过 ``sys_close`` 关闭并重新打开文件来将偏移量重置为 0。为了解决这种问题,有另一个系统调用 ``sys_lseek`` 可以调整进程打开的一个标准文件的偏移量,这样便能对文件进行随机读写。在本教程中并未实现这个系统调用,因为顺序文件读写就已经足够了。顺带一提,在文件系统的底层实现中都是对文件进行随机读写的。
下面我们从本章的测试用例 ``filetest_simple`` 来介绍文件系统接口的使用方法:
.. code-block:: rust
:linenos:
// user/src/bin/filetest_simple.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{
open,
close,
read,
write,
OpenFlags,
};
#[no_mangle]
pub fn main() -> i32 {
let test_str = "Hello, world!";
let filea = "filea\0";
let fd = open(filea, OpenFlags::CREATE | OpenFlags::WRONLY);
assert!(fd > 0);
let fd = fd as usize;
write(fd, test_str.as_bytes());
close(fd);
let fd = open(filea, OpenFlags::RDONLY);
assert!(fd > 0);
let fd = fd as usize;
let mut buffer = [0u8; 100];
let read_len = read(fd, &mut buffer) as usize;
close(fd);
assert_eq!(
test_str,
core::str::from_utf8(&buffer[..read_len]).unwrap(),
);
println!("file_test passed!");
0
}
- 第 20~25 行,我们打开文件 ``filea`` ,向其中写入字符串 ``Hello, world!`` 而后关闭文件。这里需要注意的是我们需要为字符串字面量手动加上 ``\0`` 作为结尾。在打开文件时 *CREATE* 标志使得如果 ``filea`` 原本不存在,文件系统会自动创建一个同名文件,如果已经存在的话则会清空它的内容。而 *WRONLY* 使得此次只能写入该文件而不能读取。
- 第 27~32 行,我们以只读 *RDONLY* 的方式将文件 ``filea`` 的内容读取到缓冲区 ``buffer`` 中。注意我们很清楚 ``filea`` 的总大小不超过缓冲区的大小,因此通过单次 ``read`` 即可将 ``filea`` 的内容全部读取出来。而更常见的情况是需要进行多次 ``read`` 直到它的返回值为 0 才能确认文件的内容已被读取完毕了。
- 最后的第 34~38 行我们确认从 ``filea`` 读取到的内容和之前写入的一致,则测试通过。
\ No newline at end of file
此差异已折叠。
在内核中使用 easy-fs
===============================================
本节导读
-----------------------------------------------
块设备驱动
-----------------------------------------------
Qemu 模拟器平台
+++++++++++++++++++++++++++++++++++++++++++++++
K210 真实硬件平台
+++++++++++++++++++++++++++++++++++++++++++++++
相关系统调用实现
-----------------------------------------------
\ No newline at end of file
命令行参数与标准 I/O 重定向
=================================================
命令行参数
-------------------------------------------------
标准输入输出重定向
-------------------------------------------------
\ No newline at end of file
......@@ -2,9 +2,13 @@
==============================================
.. toctree::
:hidden:
:maxdepth: 4
6exercise
0intro
1fs-interface
2fs-implementation
3using-easy-fs-in-kernel
4cmdargs-and-redirection
5exercise
最晚灭绝的“霸王龙”操作系统
\ No newline at end of file
......@@ -65,11 +65,12 @@ html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
# html_static_path = ['_static']
html_static_path = ['_static']
# html_css_files = [
# 'dracula.css',
# ]
html_css_files = [
'my_style.css',
#'dracula.css',
]
from pygments.lexer import RegexLexer
from pygments import token
......@@ -115,4 +116,4 @@ class RVLexer(RegexLexer):
],
}
lexers['riscv'] = RVLexer()
\ No newline at end of file
lexers['riscv'] = RVLexer()
......@@ -53,9 +53,10 @@ rCore-Tutorial-Book 第三版
:doc:`/log`
项目/文档于 2021-02-20 最后一次更新,情况如下:
项目/文档于 2021-03-03 最后一次更新,情况如下:
第六章文档完成。
- 更新了第四章练习题。
- 为方便调试,提供了 riscv64 gcc 工具链的下载链接。
项目简介
......
更新日志
===============================
2021-03-03
-------------------------------
- 更新了第四章练习题。
- 为方便调试,提供了 riscv64 gcc 工具链的下载链接。
2021-02-28
-------------------------------
修复了 ch3-coop 分支在 Rust 版本更新后无法成功运行的问题。
2021-02-27
-------------------------------
完善了 ``easy-fs`` :
- 订正了 ``easy-fs`` 块缓存层的实现,移除了 ``dirty`` 子模块。
- 支持二级间接块索引,使得支持的单个文件最大容量从 :math:`94\text{KiB}` 变为超过 :math:`8\text{MiB}` 。调整了单个 ``DiskInode`` 大小为 128 字节。
- 在新建一个索引节点的时候不再直接分配一二级间接索引块,而是完全按需分配。
- 将 ``easy-fs`` 的测试和应用程序打包的函数分离到另一个名为 ``easy-fs-fuse`` 的 crate 中。
从 ch7 开始:
- 出于后续的一些需求, ``sys_exec`` 需要支持命令行参数,为此用户终端 ``user_shell`` 中需要相应增加一些解析功能,内核中 ``sys_exec`` 的实现也需要进行修改。新增了应用 ``cmdline_args`` 来打印传入的命令行参数。
- 新增了应用 cat 工具可以读取一个文件的全部内容。
- 在用户终端中支持通过 ``<`` 和 ``>`` 进行简单的输入/输出重定向,为此在内核中新增了一个 ``sys_dup`` 系统调用。
另外,在所有章节分支新增了 docker 支持来尽可能降低环境配置的时间成本,详见 :ref:`使用 Docker 环境 <link-docker-env>` 。
2021-02-20
-------------------------------
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册