diff --git a/ch5/book/0intro.md b/ch5/book/0intro.md new file mode 100644 index 00000000..d4ef2cd2 --- /dev/null +++ b/ch5/book/0intro.md @@ -0,0 +1,286 @@ +# 引言 + +## 本章导读 + +在正式开始这一章的介绍之前,我们可以看到:在前面的章节中基本涵盖了一个功能相对完善的操作系统内核所需的核心硬件机制:中断与异常、特权级、页表,而且一个一个逐步进化的远古生物操作系统内核让应用程序在开发和运行方面也越来越便捷和安全了。但开发者的需求是无穷的,开发者希望能够在计算机上有更多的动态交互和控制能力,比如在操作系统启动后,能灵活选择执行某个程序。但我们目前实现的这些操作系统还无法做到,这说明操作系统还缺少对应用程序动态执行的灵活性和交互性的支持! + +到目前为止,操作系统启动后,能运行完它管理所有的应用程序。但在整个执行过程中,应用程序是被动地被操作系统加载运行,开发者与操作系统之间没有交互,开发者与应用程序之间没有交互,应用程序不能控制其它应用的执行。这使得开发者不能灵活地选择执行某个程序。为了方便开发者灵活执行程序,本章要完成的操作系统的核心目标是: **让开发者能够控制程序的运行** 。 + +在前面的章节中,随着应用的需求逐渐变得复杂,作为其执行环境的操作系统内核也需要在硬件提供的相关机制的支持之下努力为应用提供更多强大、易用且安全的抽象。让我们先来简单回顾一下: + +- 第一章《RV64 裸机应用》中,由于我们从始至终只需运行一个应用,这时我们的内核看起来只是一个 **函数库** ,它会对应用的执行环境进行初始化,使得应用能够正确接入计算机的启动流程,同时我们还设置好函数调用栈使得应用可以正常进行 Rust 函数调用。此外,它还将 SBI 接口函数进行了封装使得应用更容易使用这些功能。 +- 第二章《批处理系统》中,我们需要自动加载并执行一个固定序列内的多个应用,当一个应用出错或者正常退出之后则切换到下一个。为了让这个流程能够稳定进行而不至于被某个应用的错误所破坏,内核需要借助硬件提供的 **特权级机制** 将应用代码放在 U 特权级执行,并对它的行为进行限制,从而实现了内核的安全核心机制 -- **控制隔离** 。一旦应用出现错误或者请求一些只有内核才能提供的服务时,控制权会移交给内核并对该 **Trap** 进行处理。 +- 第三章《多道程序与分时多任务》中,出于对提高计算机系统总体性能的需求,操作系统在一个应用执行一段时间之后,会暂停这个应用并切换另外一个应用去执行,等到以后的某个时刻,操作系统再切换回之前的应用继续执行。这样就实现了内核的核心机制 -- **任务切换** 。对于每个应用来说,它会认为自己始终独占一个 CPU ,不过这只是内核对 CPU 资源的恰当抽象给它带来的一种幻象。 +- 第四章《地址空间》中,我们利用硬件的分页机制,实现了内核的安全核心机制 -- **内存隔离** ,建立了一种经典的抽象 -- **地址空间** ,让应用程序在操作系统管控的内存空间中执行,代替了先前应用程序对于物理内存的直接访问方式。这样做使得每个应用独占一个访存空间并与其他应用隔离起来,这是由内核通过设定应用的页表来保证不同应用的数据(应用间的共享数据除外)所在在物理内存区域互不相交。于是开发者在开发应用的时候无需顾及其他应用,整个系统的安全性也得到了一定保证。 + +目前为止,所有的应用都是在内核初始化阶段被一并加载到内存中的,之后也无法对应用的执行进行动态增删。从一般用户的角度来看,第四章和第二章的批处理系统似乎并没有什么不同。事实上,由于我们还没有充分发掘这些硬件机制和抽象概念的能力,应用的开发和使用仍然比较受限,且用户在应用运行过程中的动态控制能力不够强。其实用户可以与操作系统之间可以建立一个交互界面,在应用程序的执行过程中,让用户可以通过这个界面主动给操作系统发出请求,来创建并执行新的应用程序,暂停或停止应用程序的执行等。 + +#### NOTE + +**UNIX shell 的起源** + +“shell” 的名字和概念是从 UNIX 的前身 MULTICS 发展和继承过来的,应用程序可以通过 shell 程序来进行调用并被操作系统执行。Thompson shell 是历史上第一个 UNIX shell,在 1971 年由肯·汤普逊(Ken Thompson)写出了第一版并加入 UNIX 之中。Thompson shell 按照极简主义设计,语法非常简单,是一个简单的命令行解释器。它的许多特征影响了以后的操作系统命令行界面的发展。至 Version 7 Unix 之后,被 Bourne shell 取代。 + + + +#### NOTE + +**描述未来的 MULTICS 操作系统** + +在取得了 CTSS 操作系统的成功后,MIT 与 ARPA 在 1963 年 MAC 项目,其目标之一是设计和实现 CTSS 的后继操作系统。经过前期准备,在 1965 年,MIT 的 Fernando J. Corbató 教授联合贝尔实验室和通用电气公司联合启动了雄心勃勃的 MULTICS 操作系统项目。MULTICS 的目标是:改变人们使用计算机和计算机编程的方式,让人们能像使用电力或电话一样来方便地使用计算机的计算能力。类比于电力基础设施(Electric utility),MIT 的科学家想通过 MULTICS 构建未来的计算基础设施(Computer utility)。 + +为此开发小组对 GE645 计算机系统和 MULTICS 操作系统提出了一系列的非常先进的设计思路。同学们如果阅读了 “Introduction and Overview of the Multics System” [2](#corb65) 和 “Structure of the Multics Supervisor” [3](#vyssotsky65) 这两篇论文,可以发现 MULTICS 操作系统的设计思路即使放到二十一世纪的今天也不算过时。但相对较弱和进展缓慢的硬件,用于编写操作系统的 PL/I 高级语言的编译器严重滞后,操作系统各种功能带来的大型软件复杂性导致了 MULTICS 操作系统的开发困难重重。不过最终在 1969 年,MULTICS 操作系统开始提供服务,并一直持续到 2000 年,算得上是很长寿了。 + +这里我们只讲述 MULTICS 操作系统中与进程(Process)相关的一些设计思路。MULTICS 操作系统中的进程是指一个程序/作业的执行过程,如编译一个程序、产生一个文件等。每个进程在执行过程中所占的内存空间范围由 GE645 计算机中处理器指定的段(硬件机制)来描述和限制。操作系统通过处理器调度算法和调度分派机制来让不同的进程分时使用处理器,这样进程会有正在运行的运行态、准备运行的就绪态和等待条件满足的阻塞态这样不同的执行状态。在进程管理方面,有动态创建进程、阻塞进程和终止进程等不同的操作。每个子进程都是从某个进程(父进程)通过系统调用产生出来的。子进程可以共享父进程拥有的内存空间。用户进程通过系统调用获得操作系统的服务,不能直接访问操作系统的数据和代码,确保了操作系统的安全。 + +**成为未来基石的 UNIX 操作系统** + +Ken Thompson 和 Dennis Ritchie 这一对贝尔实验室的黄金搭档,在 1969 年退出 MULTICS 操作系统开发工作后,并没有放弃操作系统的研发,而是决定重新开始。Ken Thompson 从小处着手,从一台老旧的 DEC PDP-7 计算机开始,将 MULTICS 操作系统的设计想法进行简化,并一个一个地实现,完成了第一版 UNIX 操作系统内核,并带有汇编器、编辑器和 shell 应用程序。这时的操作系统只是一个简单的单任务操作系统。它的 UNIX 取名是对 MULTICS 的一种玩笑回应。Dennis Ritchie具有 MULTICS 项目中的高级语言 PL/I 编译器方面的经验,他创建了小巧灵活的 C 语言和 C 编译器,UNIX 后续版本用 C 语言进行了重写。然后 C 语言和 UNIX 操作系统联手,影响了后续几乎所有的计算机和操作系统(Linux、MacOS、Windows...),成为了未来的基石。 + +这里我们关注 UNIX 操作系统中与进程(Process)相关的一些设计实现思路。简单地说,UNIX 操作系统中的进程实现充分吸取了 MULTICS 中关于进程的设计思想,实现了 `fork exec wait exit` 四个精巧的系统调用来支持对进程的灵活管理。父进程进程通过 `fork` 系统调用创建自身的副本(子进程);称为“子进程”的副本可调用 `exec` 系统调用用另一个程序覆盖其内存空间,这样就可以执行新程序了;子进程执行完毕后,可通过调用 `exit` 系统调用来退出并通知父进程;父进程通过调用 `wait` 系统调用来等待子进程的退出。 + +一句话小结:MULTICS 操作系统的思想造就了 UNIX 操作系统,而 UNIX 操作系统引导了操作系统的发展历程,Linux 操作系统统治了当今世界。 + + + + + +于是,本章我们会开发一个用户 **终端** (Terminal) 程序或称 **命令行** 应用(Command Line Application, 俗称 **shell** ),形成用户与操作系统进行交互的命令行界面(Command Line Interface),它就和我们今天常用的 OS 中的命令行应用(如 Linux 中的 bash,Windows 中的 CMD 等)没有什么不同:只需在其中输入命令即可启动或杀死应用,或者监控系统的运行状况。这自然是现代 OS 中不可缺少的一部分,并大大增加了系统的 **可交互性** ,使得用户可以更加灵活地控制系统。 + +为了在用户态就可以借助操作系统的服务动态灵活地管理和控制应用的执行,我们需要在已有的 **任务** 抽象的基础上进一步扩展,形成新的抽象: **进程** ,并实现若干基于 **进程** 的强大系统调用。 + +- **创建** (Create):父进程创建新的子进程。用户在 shell 中键入命令或用鼠标双击应用程序图标(这需要 GUI 界面,目前我们还没有实现)时,会调用操作系统服务来创建新进程,运行指定的程序。 +- **销毁** (Destroy):进程退出。进程会在运行完成后可自行退出,但还需要其他进程(如创建这些进程的父进程)来回收这些进程最后的资源,并销毁这些进程。 +- **等待** (Wait):父进程等待子进程退出。父进程等待子进程停止是很有用的,比如上面提到的收集子进程的退出信息,回收退出的子进程占用的剩余资源等。 +- **信息** (Info):获取进程的状态信息:操作系统也可提供有关进程的身份和状态等进程信息,例如进程的ID,进程的运行状态,进程的优先级等。 +- **其他** (Other):其他的进程控制服务。例如,让一个进程能够杀死另外一个进程,暂停进程(停止运行一段时间),恢复进程(继续运行)等。 + +有了上述灵活强大的进程管理功能,就可以进化出本章的白垩纪“伤齿龙” [1](#troodon) 操作系统了。 + +#### NOTE + +**任务和进程的关系与区别** + +第三章提到的 **任务** 和这里提到的 **进程** 有何关系和区别? 这需要从二者对资源的占用和执行的过程这两个方面来进行分析。 + +- 相同点:站在一般用户和应用程序的角度看,任务和进程都表示运行的程序。站在操作系统的角度看,任务和进程都表示为一个程序的执行过程。二者都能够被操作系统打断并通过切换来分时占用 CPU 资源;都需要 **地址空间** 来放置代码和数据;都有从开始到结束运行这样的生命周期。 +- 不同点:第三/四章提到的 **任务** 是这里提到的 **进程** 的初级阶段,任务还没进化到拥有更强大的动态变化功能:进程可以在运行的过程中,创建 **子进程** 、 用新的 **程序** 内容覆盖已有的 **程序** 内容。这种动态变化的功能可让程序在运行过程中动态使用更多的物理或虚拟的 **资源** 。 + +## 实践体验 + +获取本章代码: + +```console +$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git +$ cd rCore-Tutorial-v3 +$ git checkout ch5 +``` + +在 qemu 模拟器上运行本章代码: + +```console +$ cd os +$ make run +``` + +待内核初始化完毕之后,将在屏幕上打印可用的应用列表并进入shell程序(以 K210 平台为例): + +```default +[RustSBI output] +[kernel] Hello, world! +last 808 Physical Frames. +.text [0x80020000, 0x8002e000) +.rodata [0x8002e000, 0x80032000) +.data [0x80032000, 0x800c7000) +.bss [0x800c7000, 0x802d8000) +mapping .text section +mapping .rodata section +mapping .data section +mapping .bss section +mapping physical memory +remap_test passed! +after initproc! +/**** APPS **** +exit +fantastic_text +forktest +forktest2 +forktest_simple +forktree +hello_world +initproc +matrix +sleep +sleep_simple +stack_overflow +user_shell +usertests +yield +**************/ +Rust user shell +>> +``` + +其中 `usertests` 打包了很多应用,只要执行它就能够自动执行一系列应用。 + +只需输入应用的名称并回车即可在系统中执行该应用。如果输入错误的话可以使用退格键 (Backspace) 。以应用 `exit` 为例: + +```default +>> exit +I am the parent. Forking the child... +I am the child. +I am parent, fork a child pid 3 +I am the parent, waiting now.. +waitpid 3 ok. +exit pass. +Shell: Process 2 exited with code 0 +>> +``` + +当应用执行完毕后,将继续回到shell程序的命令输入模式。 + +## 本章代码树 + +伤齿龙操作系统 -- ProcessOS的总体结构如下图所示: + +![伤齿龙操作系统 - Address Space OS总体结构](./process-os-detail.png) + +通过上图,大致可以看出伤齿龙操作系统 -- ProcessOS在内部结构上没有特别大的改动,但把任务抽象进化成了进程抽象,其主要改动集中在进程管理的功能上,即通过提供新的系统调用服务:sys_fork(创建子进程)、sys_waitpid(等待子进程结束并回收子进程资源)、sys_exec(用新的应用内容覆盖当前进程,即达到执行新应用的目的)。为了让用户能够输入命令或执行程序的名字,ProcessOS还增加了一个 read 系统调用服务,这样用户通过操作系统的命令行接口 -- 新添加的 shell 应用程序发出命令,来动态地执行各种新的应用,提高了用户与操作系统之间的交互能力。 + +而由于有了进程的新抽象,需要对已有任务控制块进行重构,ProcessOS中与进程相关的核心数据结构如下图所示: + +![进程相关的核心数据结构](./process-os-key-structures.png) + +从上图可知,进程控制块 `TaskControlBlock` 包含与进程运行/切换/调度/地址空间相关的各种资源和信息。以前的任务管理器 `TaskManager` 分离为处理器管理结构 `Processor` 和新的 `TaskManager` 。 `Processor` 负责管理 CPU 上正在执行的任务和一些相关信息;而新的任务管理器 `TaskManager` 仅负责管理没在执行的所有任务,以及各种新的进程管理相关的系统调用服务。 + +位于 `ch5` 分支上的伤齿龙操作系统 - ProcessOS的源代码如下所示: + +```default +./os/src +Rust 28 Files 1848 Lines +Assembly 3 Files 86 Lines + +├── bootloader +│   └── rustsbi-qemu.bin +├── LICENSE +├── os +│   ├── build.rs(修改:基于应用名的应用构建器) +│   ├── Cargo.toml +│   ├── Makefile +│   └── src +│   ├── config.rs +│   ├── console.rs +│   ├── entry.asm +│   ├── lang_items.rs +│   ├── link_app.S +│   ├── linker-qemu.ld +│   ├── loader.rs(修改:基于应用名的应用加载器) +│   ├── main.rs(修改) +│   ├── mm(修改:为了支持本章的系统调用对此模块做若干增强) +│   │   ├── address.rs +│   │   ├── frame_allocator.rs +│   │   ├── heap_allocator.rs +│   │   ├── memory_set.rs +│   │   ├── mod.rs +│   │   └── page_table.rs +│   ├── sbi.rs +│   ├── sync +│   │   ├── mod.rs +│   │   └── up.rs +│   ├── syscall +│   │   ├── fs.rs(修改:新增 sys_read) +│   │   ├── mod.rs(修改:新的系统调用的分发处理) +│   │   └── process.rs(修改:新增 sys_getpid/fork/exec/waitpid) +│   ├── task +│   │   ├── context.rs +│   │   ├── manager.rs(新增:任务管理器,为上一章任务管理器功能的一部分) +│   │   ├── mod.rs(修改:调整原来的接口实现以支持进程) +│   │   ├── pid.rs(新增:进程标识符和内核栈的 Rust 抽象) +│   │   ├── processor.rs(新增:处理器管理结构 ``Processor`` ,为上一章任务管理器功能的一部分) +│   │   ├── switch.rs +│   │   ├── switch.S +│   │   └── task.rs(修改:支持进程管理机制的任务控制块) +│   ├── timer.rs +│   └── trap +│   ├── context.rs +│   ├── mod.rs(修改:对于系统调用的实现进行修改以支持进程系统调用) +│   └── trap.S +├── README.md +├── rust-toolchain +└── user(对于用户库 user_lib 进行修改,替换了一套新的测例) + ├── Cargo.toml + ├── Makefile + └── src + ├── bin + │   ├── exit.rs + │   ├── fantastic_text.rs + │   ├── forktest2.rs + │   ├── forktest.rs + │   ├── forktest_simple.rs + │   ├── forktree.rs + │   ├── hello_world.rs + │   ├── initproc.rs + │   ├── matrix.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 +``` + +## 本章代码导读 + +本章的第一小节 [进程概念及重要系统调用](1process.md) 介绍了操作系统中经典的进程概念,并描述我们将要实现的参考自 UNIX 系内核并经过简化的精简版进程模型。在该模型下,若想对进程进行管理,实现创建、退出等操作,核心就在于 `fork/exec/waitpid` 三个系统调用。 + +首先我们修改运行在应用态的应用软件,它们均放置在 `user` 目录下。在新增系统调用的时候,需要在 `user/src/lib.rs` 中新增一个 `sys_*` 的函数,它的作用是将对应的系统调用按照与内核约定的 ABI 在 `syscall` 中转化为一条用于触发系统调用的 `ecall` 的指令;还需要在用户库 `user_lib` 将 `sys_*` 进一步封装成一个应用可以直接调用的与系统调用同名的函数。通过这种方式我们新增三个进程模型中核心的系统调用 `fork/exec/waitpid` ,一个查看进程 PID 的系统调用 `getpid` ,还有一个允许应用程序获取用户键盘输入的 `read` 系统调用。 + +基于进程模型,我们在 `user/src/bin` 目录下重新实现了一组应用程序。其中有两个特殊的应用程序:用户初始程序 `initproc.rs` 和 shell 程序 `user_shell.rs` ,可以认为它们位于内核和其他应用程序之间的中间层提供一些基础功能,但是它们仍处于用户态的应用层。前者会被内核唯一自动加载、也是最早加载并执行,后者则负责从键盘接收用户输入的应用名并执行对应的应用。剩下的应用从不同层面测试了我们内核实现的正确性,同学可以自行参考。值得一提的是, `usertests` 可以按照顺序执行绝大部分应用,会在测试操作系统功能和正确性上为我们提供很多方便。 + +接下来就需要在内核中实现简化版的进程管理机制并支持新增的系统调用。在本章第二小节 [进程管理的核心数据结构](2core-data-structures.md) 中我们对一些进程管理机制相关的数据结构进行了重构或者修改: + +- 为了支持基于应用名而不是应用 ID 来查找应用 ELF 可执行文件,从而实现灵活的应用加载,在 `os/build.rs` 以及 `os/src/loader.rs` 中更新了 `link_app.S` 的格式使得它包含每个应用的名字,另外提供 `get_app_data_by_name` 接口获取应用的 ELF 数据。 +- 在本章之前,任务管理器 `TaskManager` 不仅负责管理所有的任务状态,还维护着 CPU 当前正在执行的任务。这种设计耦合度较高,我们将后一个功能分离到 `os/src/task/processor.rs` 中的处理器管理结构 `Processor` 中,它负责管理 CPU 上执行的任务和一些其他信息;而 `os/src/task/manager.rs` 中的任务管理器 `TaskManager` 仅负责管理所有任务。 +- 针对新的进程模型,我们复用前面章节的任务控制块 `TaskControlBlock` 作为进程控制块来保存进程的一些信息,相比前面章节还要新增 PID 、内核栈、应用数据大小、父子进程、退出码等信息。它声明在 `os/src/task/task.rs` 中。 +- 从本章开始,进程的 PID 将作为查找进程控制块的索引,这样就可以通过进程的 PID 来查找到进程的内核栈等各种进程相关信息。 同时我们还面向进程控制块提供相应的资源自动回收机制。具体实现可以参考 `os/src/task/pid.rs` 。 + +有了这些数据结构的支撑,我们在本章第三小节 [进程管理机制的设计实现](3implement-process-mechanism.md) 实现进程管理机制。它可以分成如下几个方面: + +- 初始进程的创建:在内核初始化的时候需要调用 `os/src/task/mod.rs` 中的 `add_initproc` 函数,它会调用 `TaskControlBlock::new` 读取并解析初始应用 `initproc` 的 ELF 文件数据并创建初始进程 `INITPROC` ,随后会将它加入到全局任务管理器 `TASK_MANAGER` 中参与调度。 +- 进程切换机制:当一个进程退出或者是主动/被动交出 CPU 使用权之后,需要由内核将 CPU 使用权交给其他进程。在本章中我们沿用 `os/src/task/mod.rs` 中的 `suspend_current_and_run_next` 和 `exit_current_and_run_next` 两个接口来实现进程切换功能,但是需要适当调整它们的实现。我们需要调用 `os/src/task/task.rs` 中的 `schedule` 函数进行进程切换,它会首先切换到处理器的 idle 控制流(即 `os/src/task/processor` 的 `Processor::run` 方法),然后在里面选取要切换到的进程并切换过去。 +- 进程调度机制:在进程切换的时候我们需要选取一个进程切换过去。选取进程逻辑可以参考 `os/src/task/manager.rs` 中的 `TaskManager::fetch_task` 方法。 +- 进程生成机制:这主要是指 `fork/exec` 两个系统调用。它们的实现分别可以在 `os/src/syscall/process.rs` 中找到,分别基于 `os/src/process/task.rs` 中的 `TaskControlBlock::fork/exec` 。 +- 进程资源回收机制:当一个进程主动退出或出错退出的时候,在 `exit_current_and_run_next` 中会立即回收一部分资源并在进程控制块中保存退出码;而需要等到它的父进程通过 `waitpid` 系统调用(与 `fork/exec` 两个系统调用放在相同位置)捕获到它的退出码之后,它的进程控制块才会被回收,从而该进程的所有资源都被回收。 +- 进程的 I/O 输入机制:为了支持用户终端 `user_shell` 读取用户键盘输入的功能,还需要实现 `read` 系统调用,它可以在 `os/src/syscall/fs.rs` 中找到。 + +* **[1]** 伤齿龙是一种灵活的小型恐龙,生存于7500万年前的晚白垩纪,伤齿龙的脑袋与身体的比例是恐龙中最大之一,因此伤齿龙被认为是最有智能的恐龙之一。 +* **[2]** Fernando J. Corbató. "Introductmn and overvmw of the MULTICS system " In Proc AFIPS I965 Fall Joznt Computer Conf, Part I, Spartan Books, New York, 185-196. +* **[3]** 1. 1. Vyssotsky. "Structure of the Multics supervisor" In AFIPS Conf Proc 27 1965, Spartan Books Washington D C 1965 pp 203--212 diff --git a/ch5/book/1process.md b/ch5/book/1process.md new file mode 100644 index 00000000..2afb23ad --- /dev/null +++ b/ch5/book/1process.md @@ -0,0 +1,380 @@ +# 进程概念及重要系统调用 + +## 本节导读 + +本节的内容有: + +- 介绍进程的概念以及它和一些其他相近的概念的比较; +- 从应用开发者或是用户的角度介绍我们实现的一种简单类 Unix 进程模型; +- 介绍三个最重要的进程相关系统调用并给出一些用例。 + +## 进程概念 + + + +在本章的引言中,出于方便应用开发和使得应用功能更加强大的目标,我们引入了进程的概念。在本章之前,我们有 **任务** 的概念,即 **正在执行的程序** ,主角是程序。而相比于 **任务** , **进程** (Process) 的含义是 **在操作系统管理下的程序的一次执行过程**。这里的“程序的”成了形容词,而执行过程成为了主角,这充分体现了动态变化的执行特点。尽管这说起来很容易,但事实上进程是一个内涵相当丰富且深刻、难以从单个角度解释清楚的抽象概念。我们可以先试着从动态和静态的角度来进行初步的思考。我们知道,当一个应用的源程序被编译器成功构建之后,它会从源代码变为某种格式的可执行文件,如果将其展开,可以在它的内存布局中看到若干个功能迥异的逻辑段。但如果仅是这样,它也就只是某种格式特殊的、被 **静态** 归档到存储器上的一个文件而已。 + +然而,可执行文件与其他类型文件的根本性不同在于它可以被内核加载并执行。这一执行过程自然是不能凭空进行的,而是需要占据某些真实的硬件资源。例如,可执行文件一定需要被加载到物理内存的某些区域中才能执行,另外还可能需要预留一些可执行文件内存布局中未规划的区域(比如堆和栈),这就会消耗掉部分内存空间;在执行的时候需要占据一个 CPU 的全部硬件资源,我们之前介绍过的有通用寄存器(其中程序计数器 pc 和栈指针 sp 两个意义尤其重大)、CSR 、各级 cache 、TLB 等。 + +打一个比方,可执行文件本身可以看成一张编译器解析源代码之后总结出的一张记载如何利用各种硬件资源进行一轮生产流程的 **蓝图** 。而内核的一大功能便是作为一个硬件资源管理器,它可以随时启动一轮生产流程(即执行任意一个应用),这需要选中一张蓝图(此时确定执行哪个可执行文件),接下来就需要内核按照蓝图上所记载的对资源的需求来对应的将各类资源分配给它,让这轮生产流程得以顺利进行。当按照蓝图上的记载生产流程完成(应用退出)之后,内核还需要将对应的硬件资源回收以便后续的重复利用。 + +因此,进程就是操作系统选取某个可执行文件并对其进行一次动态执行的过程。相比可执行文件,它的动态性主要体现在: + +1. 它是一个过程,从时间上来看有开始也有结束; +2. 在该过程中对于可执行文件中给出的需求要相应对 **硬件/虚拟资源** 进行 **动态绑定和解绑** 。 + +这里需要指出的是,两个进程可以选择同一个可执行文件执行,然而它们却是截然不同的进程:它们的启动时间、占据的硬件资源、输入数据均有可能是不同的,这些条件均会导致它们是不一样的执行过程。在某些情况下,我们可以看到它们的输出是不同的——这是其中一种可能的直观表象。 + +在内核中,需要有一个进程管理器,在其中记录每个进程对资源的占用情况,这是内核作为一个硬件资源管理器所必须要做到的。进程管理器通常需要管理多个进程,因为如果同一时间只有一个进程的话,就可以简单的将所有的硬件资源都交给该进程,同时内核也会像第一章《RV64 裸机应用》那样退化成一个函数库。 + +本节接下来主要站在应用开发者和用户的角度来介绍如何理解进程概念并基于它编写应用程序。 + +#### NOTE + +**为何要在这里才引入进程** + +根据我们多年来的 OS 课程经验,学生对 **进程** 的简单定义“ **正在执行的程序** ”比较容易理解。但对于多个运行的程序之间如何切换,会带来哪些并发问题,进程创建与虚拟内存的关系等问题很难一下子理解清楚,也不清楚试图解决这些问题的原因。这主要是由于学生对进程的理解是站在应用程序角度来看的。 + +如果变化一下,让学生站在操作系统的角度来看,那么在进程这个定义背后,有特权级切换、异常处理,程序执行的上下文切换、地址映射、地址空间、虚存管理等一系列的知识支撑,才能理解清楚操作系统对进程的整个管理过程。所以,我们在前面几章对上述知识进行了铺垫。并以此为基础,更加全面地来分析操作系统是如何管理进程的。 + +#### NOTE + +**进程,线程和协程** + +进程,线程和协程是操作系统中经常出现的名词,它们都是操作系统中的抽象概念,有联系和共同的地方,但也有区别。计算机的核心是 CPU,它承担了基本上所有的计算任务;而操作系统是计算机的管理者,它可以以进程,线程和协程为基本的管理和调度单位来使用 CPU 执行具体的程序逻辑。 + +从历史角度上看,它们依次出现的顺序是进程、线程和协程。在还没有进程抽象的早期操作系统中,计算机科学家把程序在计算机上的一次执行过程称为一个任务(Task)或一个工作(Job),其特点是任务和工作在其整个的执行过程中,不会被切换。这样其他任务必须等待一个任务结束后,才能执行,这样系统的效率会比较低。 + +在引入面向 CPU 的分时切换机制和面向内存的虚拟内存机制后,进程的概念就被提出了,进程成为 CPU(也称处理器)调度(Scheduling)和分派(Switch)的对象,各个进程间以时间片为单位轮流使用 CPU,且每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。这时,操作系统通过进程这个抽象来完成对应用程序在 CPU 和内存使用上的管理。 + +随着计算机的发展,对计算机系统性能的要求越来越高,而进程之间的切换开销相对较大,于是计算机科学家就提出了线程。线程是程序执行中一个单一的顺序控制流程,线程是进程的一部分,一个进程可以包含一个或多个线程。各个线程之间共享进程的地址空间,但线程要有自己独立的栈(用于函数访问,局部变量等)和独立的控制流。且线程是处理器调度和分派的基本单位。对于线程的调度和管理,可以在操作系统层面完成,也可以在用户态的线程库中完成。用户态线程也称为绿色线程(GreenThread)。如果是在用户态的线程库中完成,操作系统是“看不到”这样的线程的,也就谈不上对这样线程的管理了。 + +协程(Coroutines,也称纤程(Fiber)),也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又是比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善。在具体实现上,协程可以在用户态运行时库这一层面通过函数调用来实现;也可在语言级支持协程,比如 Rust 借鉴自其他语言的的 `async` 、 `await` 关键字等,通过编译器和运行时库二者配合来简化程序员编程的负担并提高整体的性能。 + +## 进程模型与重要系统调用 + +目前,我们只介绍本章实现的内核中采用的一种非常简单的进程模型。这个进程模型有三个运行状态:就绪态、运行态和等待态;有基于独立页表的地址空间;可被操作系统调度来分时占用 CPU 执行;可以动态创建和退出;可通过系统调用获得操作系统的服务。 + +前面我们并没有给出进程需要使用哪些类型的资源,这其实取决于内核提供给应用的系统调用接口以及内核的具体实现。我们实现的进程模型建立在地址空间抽象之上:每个进程都需要一个地址空间,它涵盖了它选择的可执行文件的内存布局,还包含一些其他的逻辑段。且进程模型需要操作系统支持一些重要的系统调用:创建进程、执行新程序、等待进程结束等,来达到应用程序执行的动态灵活性。接下来会介绍这些系统调用的基本功能和设计思路。 + +### fork 系统调用 + + + + + +系统中同一时间存在的每个进程都被一个不同的 **进程标识符** (PID, Process Identifier) 所标识。在内核初始化完毕之后会创建一个进程——即 **用户初始进程** (Initial Process) ,它是目前在内核中以硬编码方式创建的唯一一个进程。其他所有的进程都是通过一个名为 `fork` 的系统调用来创建的。 + +```rust +/// 功能:当前进程 fork 出来一个子进程。 +/// 返回值:对于子进程返回 0,对于当前进程则返回子进程的 PID 。 +/// syscall ID:220 +pub fn sys_fork() -> isize; +``` + +进程 A 调用 `fork` 系统调用之后,内核会创建一个新进程 B,这个进程 B 和调用 `fork` 的进程A在它们分别返回用户态那一瞬间几乎处于相同的状态:这意味着它们包含的用户态的代码段、堆栈段及其他数据段的内容完全相同,但是它们是被放在两个独立的地址空间中的。因此新进程的地址空间需要从原有进程的地址空间完整拷贝一份。两个进程通用寄存器也几乎完全相同。例如, pc 相同意味着两个进程会从同一位置的一条相同指令(我们知道其上一条指令一定是用于系统调用的 ecall 指令)开始向下执行, sp 相同则意味着两个进程的用户栈在各自的地址空间中的位置相同。其余的寄存器相同则确保了二者回到了相同的控制流状态。 + + + + + +但是唯有用来保存 `fork` 系统调用返回值的 a0 寄存器(这是 RISC-V 64 的函数调用规范规定的函数返回值所用的寄存器)的值是不同的。这区分了两个进程:原进程的返回值为它新创建进程的 PID ,而新创建进程的返回值为 0 。由于新的进程是原进程主动调用 `fork` 衍生出来的,我们称新进程为原进程的 **子进程** (Child Process) ,相对的原进程则被称为新进程的 **父进程** (Parent Process) 。这样二者就建立了一种父子关系。注意到每个进程可能有多个子进程,但最多只能有一个父进程,于是所有进程可以被组织成一颗树,其根节点正是代表用户初始程序——initproc,也即第一个用户态的初始进程。 + +相比创建一个进程, `fork` 的另一个重要功能是建立一对新的父子关系。在我们的进程模型中,父进程和子进程之间的联系更为紧密,它们更容易进行合作或通信,而且一些重要的机制(如第七章会涉及的进程间通信机制)也需要在它们之间才能展开。 + +### waitpid 系统调用 + + + +当一个进程通过 `exit` 系统调用退出之后,它所占用的资源并不能够立即全部回收。比如该进程的内核栈目前就正用来进行系统调用处理,如果将放置它的物理页帧回收的话,可能会导致系统调用不能正常处理。对于这种问题,一种典型的做法是当进程退出的时候内核立即回收一部分资源并将该进程标记为 **僵尸进程** (Zombie Process) 。之后,由该进程的父进程通过一个名为 `waitpid` 的系统调用来收集该进程的返回状态并回收掉它所占据的全部资源,这样这个进程才被彻底销毁。系统调用 `waitpid` 的原型如下: + +```rust +/// 功能:当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。 +/// 参数:pid 表示要等待的子进程的进程 ID,如果为 -1 的话表示等待任意一个子进程; +/// exit_code 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。 +/// 返回值:如果要等待的子进程不存在则返回 -1;否则如果要等待的子进程均未结束则返回 -2; +/// 否则返回结束的子进程的进程 ID。 +/// syscall ID:260 +pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize; +``` + +一般情况下一个进程要负责通过 `waitpid` 系统调用来等待它 `fork` 出来的子进程结束并回收掉它们占据的资源,这也是父子进程间的一种同步手段。但这并不是必须的。如果一个进程先于它的子进程结束,在它退出的时候,它的所有子进程将成为进程树的根节点——用户初始进程的子进程,同时这些子进程的父进程也会转成用户初始进程。这之后,这些子进程的资源就由用户初始进程负责回收了,这也是用户初始进程很重要的一个用途。后面我们会介绍用户初始进程是如何实现的。 + +### exec 系统调用 + +如果仅有 `fork` 的话,那么所有的进程都只能和用户初始进程一样执行同样的代码段,这显然是远远不够的。于是我们还需要引入 `exec` 系统调用来执行不同的可执行文件: + +```rust +/// 功能:将当前进程的地址空间清空并加载一个特定的可执行文件,返回用户态后开始它的执行。 +/// 参数:path 给出了要加载的可执行文件的名字; +/// 返回值:如果出错的话(如找不到名字相符的可执行文件)则返回 -1,否则不应该返回。 +/// syscall ID:221 +pub fn sys_exec(path: &str) -> isize; +``` + +注意,我们知道 `path` 作为 `&str` 类型是一个胖指针,既有起始地址又包含长度信息。在实际进行系统调用的时候,我们只会将起始地址传给内核(对标 C 语言仅会传入一个 `char*` )。这就需要应用负责在传入的字符串的末尾加上一个 `\0` ,这样内核才能知道字符串的长度。下面给出了用户库 `user_lib` 中的调用方式: + +```rust +// user/src/exec.rs + +pub fn sys_exec(path: &str) -> isize { + syscall(SYSCALL_EXEC, [path.as_ptr() as usize, 0, 0]) +} +``` + +这样,利用 `fork` 和 `exec` 的组合,我们很容易在一个进程内 `fork` 出一个子进程并执行一个特定的可执行文件。 + + + +#### NOTE + +**为何创建进程要通过两个系统调用而不是一个?** + +同学可能会有疑问,对于要达成执行不同应用的目标,我们为什么不设计一个系统调用接口同时实现创建一个新进程并加载给定的可执行文件两种功能?如果使用 `fork` 和 `exec` 的组合,那么 `fork` 出来的进程仅仅是为了 `exec` 一个新应用提供空间。而执行 `fork` 中对父进程的地址空间拷贝没有用处,还浪费了时间,且在后续清空地址空间的时候还会产生一些资源回收的额外开销。这样的设计来源于早期的 MULTICS [1](#multics) 和 UNIX 操作系统 [2](#unix) ,在当时是经过实践考验的,事实上 `fork` 和 `exec` 是一种灵活的系统调用组合,在当时内存空间比较小的情况下,可以支持更快的进程创建,且上述的开销能够通过一些结合虚存的技术方法(如 *Copy on write* 等)来缓解。而且拆分为两个系统调用后,可以灵活地支持 **重定向** (Redirection) 等功能。 +上述方法是 UNIX 类操作系统的典型做法。 + +这一点与 Windows 操作系统不一样。在 Windows 中, `CreateProcess` 函数用来创建一个新的进程和它的主线程,通过这个新进程运行指定的可执行文件。虽然是一个函数,但这个函数的参数十个之多,使得这个函数很复杂,且没有 `fork` 和 `exec` 的组合的灵活性。而基于 POSIX 标准的 `posix_spawn` 系统调用则类似 Windows 的 `CreateProcess` 函数,不过对参数进行了简化,更适合现在的计算机系统(有更大的物理内存空间)和类 UNIX 应用程序(更加复杂的软件)。 + +## 应用程序示例 + +我们刚刚介绍了 `fork/waitpid/exec` 三个重要系统调用,我们可以借助它们和之前实现的系统调用开发出功能更为强大的应用程序。下面我们通过描述两个重要的应用程序: **用户初始程序-init** 和 **shell程序-user_shell** 的开发过程,来展示这些重要系统调用的使用方法。 + +### 系统调用封装 + +同学可以在 `user/src/syscall.rs` 中看到以 `sys_*` 开头的系统调用的函数原型,它们后续还会在 `user/src/lib.rs` 中被封装成方便应用程序使用的形式。如 `sys_fork` 被封装成 `fork` ,而 `sys_exec` 被封装成 `exec` 。这里值得一提的是 `sys_waitpid` 被封装成两个不同的 API : + +```rust +// user/src/lib.rs + +pub fn wait(exit_code: &mut i32) -> isize { + loop { + match sys_waitpid(-1, exit_code as *mut _) { + -2 => { yield_(); } + // -1 or a real pid + exit_pid => return exit_pid, + } + } +} + +pub fn waitpid(pid: usize, exit_code: &mut i32) -> isize { + loop { + match sys_waitpid(pid as isize, exit_code as *mut _) { + -2 => { yield_(); } + // -1 or a real pid + exit_pid => return exit_pid, + } + } +} +``` + +其中 `wait` 表示等待任意一个子进程结束,根据 `sys_waitpid` 的约定它需要传的 pid 参数为 `-1` ;而 `waitpid` 则等待一个进程标识符的值为pid 的子进程结束。在具体实现方面,我们看到当 `sys_waitpid` 返回值为 `-2` ,即要等待的子进程存在但它却尚未退出的时候,我们调用 `yield_` 主动交出 CPU 使用权,待下次 CPU 使用权被内核交还给它的时候再次调用 `sys_waitpid` 查看要等待的子进程是否退出。这样做可以减小 CPU 资源的浪费。 + +目前的实现风格是尽可能简化内核,因此 `sys_waitpid` 是立即返回的,即它的返回值只能给出返回这一时刻的状态。如果这一时刻要等待的子进程还尚未结束,那么也只能如实向应用报告这一结果。于是用户库 `usr/src/lib.rs` 就需要负责对返回状态进行持续的监控,因此它里面便需要进行循环检查。在后续的实现中,我们会将 `sys_waitpid` 的内核实现设计为 **阻塞** 的,即直到得到一个确切的结果之前,其对应的进程暂停(不再继续执行)在内核内;如果 `sys_waitpid` 需要的值能够得到,则它对应的进程会被内核唤醒继续执行,且内核返回给应用的结果可以直接使用。那时 `wait` 和 `waitpid` 两个 API 的实现便会更加简单。 + +### 用户初始程序 initproc + +我们首先来看用户初始程序 initproc 是如何实现的: + +```rust +// user/src/bin/initproc.rs + +#![no_std] +#![no_main] + +#[macro_use] +extern crate user_lib; + +use user_lib::{ + fork, + wait, + exec, + yield_, +}; + +#[no_mangle] +fn main() -> i32 { + if fork() == 0 { + exec("user_shell\0"); + } else { + loop { + let mut exit_code: i32 = 0; + let pid = wait(&mut exit_code); + if pid == -1 { + yield_(); + continue; + } + println!( + "[initproc] Released a zombie process, pid={}, exit_code={}", + pid, + exit_code, + ); + } + } + 0 +} +``` + +- 第 19 行为 `fork` 返回值为 0 的分支,表示子进程,此行直接通过 `exec` 执行 shell 程序 `user_shell` ,注意我们需要在字符串末尾手动加入 `\0` ,因为 Rust 在将这些字符串连接到只读数据段的时候不会插入 `\0` 。 +- 第 21 行开始则为返回值不为 0 的分支,表示调用 `fork` 的用户初始程序 initproc 自身。可以看到它在不断循环调用 `wait` 来等待那些被移交到它下面的子进程并回收它们占据的资源。如果回收成功的话则会打印一条报告信息给出被回收子进程的 pid 值和返回值;否则就 `yield_` 交出 CPU 资源并在下次轮到它执行的时候再回收看看。这也可以看出,用户初始程序 initproc 对于资源的回收并不算及时,但是对于已经退出的僵尸进程,用户初始程序 initproc 最终总能够成功回收它们的资源。 + +### shell程序 user_shell + +由于shell程序 user_shell 需要捕获我们的输入并进行解析处理,我们需要加入一个新的用于输入的系统调用: + +```rust +/// 功能:从文件中读取一段内容到缓冲区。 +/// 参数:fd 是待读取文件的文件描述符,切片 buffer 则给出缓冲区。 +/// 返回值:如果出现了错误则返回 -1,否则返回实际读到的字节数。 +/// syscall ID:63 +pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize; +``` + +在实际调用的时候我们必须要同时向内核提供缓冲区的起始地址及长度: + +```rust +// user/src/syscall.rs + +pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize { + syscall(SYSCALL_READ, [fd, buffer.as_mut_ptr() as usize, buffer.len()]) +} +``` + +我们在用户库中将其进一步封装成每次能够从 **标准输入** 中获取一个字符的 `getchar` 函数: + +```rust +// user/src/lib.rs + +pub fn read(fd: usize, buf: &mut [u8]) -> isize { sys_read(fd, buf) } + +// user/src/console.rs + +const STDIN: usize = 0; + +pub fn getchar() -> u8 { + let mut c = [0u8; 1]; + read(STDIN, &mut c); + c[0] +} +``` + +其中,我们每次临时声明一个长度为 1 的缓冲区。 + +接下来就可以介绍 shell 程序 `user_shell` 是如何实现的了: + +```rust +// user/src/bin/user_shell.rs + +#![no_std] +#![no_main] + +extern crate alloc; + +#[macro_use] +extern crate user_lib; + +const LF: u8 = 0x0au8; +const CR: u8 = 0x0du8; +const DL: u8 = 0x7fu8; +const BS: u8 = 0x08u8; + +use alloc::string::String; +use user_lib::{fork, exec, waitpid, yield_}; +use user_lib::console::getchar; + +#[no_mangle] +pub fn main() -> i32 { + println!("Rust user shell"); + let mut line: String = String::new(); + print!(">> "); + loop { + let c = getchar(); + match c { + LF | CR => { + println!(""); + if !line.is_empty() { + line.push('\0'); + let pid = fork(); + if pid == 0 { + // child process + if exec(line.as_str()) == -1 { + println!("Error when executing!"); + return -4; + } + unreachable!(); + } else { + let mut exit_code: i32 = 0; + let exit_pid = waitpid(pid as usize, &mut exit_code); + assert_eq!(pid, exit_pid); + println!( + "Shell: Process {} exited with code {}", + pid, exit_code + ); + } + line.clear(); + } + print!(">> "); + } + BS | DL => { + if !line.is_empty() { + print!("{}", BS as char); + print!(" "); + print!("{}", BS as char); + line.pop(); + } + } + _ => { + print!("{}", c as char); + line.push(c as char); + } + } + } +} +``` + +可以看到,在以第 25 行开头的主循环中,每次都是调用 `getchar` 获取一个用户输入的字符,并根据它相应进行一些动作。第 23 行声明的字符串 `line` 则维护着用户当前输入的命令内容,它也在不断发生变化。 + +#### NOTE + +**在应用中使能动态内存分配** + +我们知道,在 Rust 中可变长字符串类型 `String` 是基于动态内存分配的。因此本章我们还要在用户库 `user_lib` 中支持动态内存分配,与第四章的做法相同,只需加入以下内容即可: + +```rust +use buddy_system_allocator::LockedHeap; + +const USER_HEAP_SIZE: usize = 16384; + +static mut HEAP_SPACE: [u8; USER_HEAP_SIZE] = [0; USER_HEAP_SIZE]; + +#[global_allocator] +static HEAP: LockedHeap = LockedHeap::empty(); + +#[alloc_error_handler] +pub fn handle_alloc_error(layout: core::alloc::Layout) -> ! { + panic!("Heap allocation error, layout = {:?}", layout); +} + +#[no_mangle] +#[link_section = ".text.entry"] +pub extern "C" fn _start() -> ! { + unsafe { + HEAP.lock() + .init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE); + } + exit(main()); +} +``` + +- 如果用户输入回车键(第 28 行),那么 user_shell 会 fork 出一个子进程(第 34 行开始)并试图通过 `exec` 系统调用执行一个应用,应用的名字在字符串 `line` 中给出。这里我们需要注意的是,由于子进程是从 user_shell 进程中 fork 出来的,它们除了 fork 的返回值不同之外均相同,自然也可以看到一个和user_shell 进程维护的版本相同的字符串 `line` 。第 35 行对 `exec` 的返回值进行了判断,如果返回值为 -1 则说明在应用管理器中找不到名字相同的应用,此时子进程就直接打印错误信息并退出;反之 `exec` 则根本不会返回,而是开始执行目标应用。 + + fork 之后的 user_shell 进程自己的逻辑可以在第 41 行找到。可以看出它只是在等待 fork 出来的子进程结束并回收掉它的资源,还会顺带收集子进程的退出状态并打印出来。 +- 如果用户输入退格键(第 53 行),首先我们需要将屏幕上当前行的最后一个字符用空格替换掉,这可以通过输入一个特殊的退格字节 `BS` 来实现。其次,user_shell 进程内维护的 `line` 也需要弹出最后一个字符。 +- 如果用户输入了一个其他字符(第 61 行),它将会被视为用户的正常输入,我们直接将它打印在屏幕上并加入到 `line` 中。 + +当内核初始化完毕之后,它会从可执行文件 `initproc` 中加载并执行用户初始程序 initproc,而用户初始程序 initproc中又会 `fork` 并 `exec` 来运行shell程序 `user_shell` 。这两个应用虽然都是在 CPU 的 U 特权级执行的,但是相比其他应用,它们要更加底层和基础。原则上应该将它们作为一个组件打包在操作系统中。但这里为了实现更加简单,我们并不将它们和其他应用进行区分。 + +除此之外,我们还从 $\mu\text{Core}$ [3](#ucore) 中借鉴了很多应用测例。它们可以做到同一时间 **并发** 多个进程并能够有效检验我们内核实现的正确性。感兴趣的同学可以参考 `matrix` 和 `forktree` 等应用。 + +* **[1]** 1965年,MIT、通用电气公司、贝尔实验室联合开发 MULTICS 操作系统,开发不够成功,但产生了很多新的设计思想,并催生了UNIX操作系统。 +* **[2]** 1969 年,贝尔实验室的 Ken Thompson 和 Dennis Ritchie 在退出 MUITICS 操作系统研发后,吸收其好的想法,设计实现了 UNIX 操作系统和 C 语言,并开始广泛推广。 +* **[3]** uCore OS 是用于清华大学计算机系本科操作系统课程的 OS 教学试验内容。 uCore OS 起源于 MIT CSAIL PDOS 课题组开发的xv6&jos、哈佛大学开发的 OS161 教学操作系统、以及 Linux-2.4 内核。目前 rCore/uCore Tutorial OS 逐步代替 uCore OS 成为新的教学 OS。 diff --git a/ch5/book/2core-data-structures.md b/ch5/book/2core-data-structures.md new file mode 100644 index 00000000..8a2ce005 --- /dev/null +++ b/ch5/book/2core-data-structures.md @@ -0,0 +1,555 @@ +# 进程管理的核心数据结构 + +## 本节导读 + +为了更好实现进程管理,同时也使得操作系统整体架构更加灵活,能够满足后续的一些需求,我们需要重新设计一些数据结构包含的内容及接口。本节将按照如下顺序来进行介绍: + +- 基于应用名的应用链接:在编译阶段的链接过程中,生成包含多个应用和应用位置信息的 `link_app.S` 文件。 +- 基于应用名的加载器:根据应用名字来加载应用的 ELF 文件中代码段和数据段到内存中,为创建一个新进程做准备。 +- 进程标识符 `PidHandle` 以及内核栈 `KernelStack` :进程控制块的重要组成部分。 +- 任务控制块 `TaskControlBlock` :表示进程的核心数据结构。 +- 任务管理器 `TaskManager` :管理进程集合的核心数据结构。 +- 处理器管理结构 `Processor` :用于进程调度,维护进程的处理器状态。 + +## 应用的链接与加载支持 + +### 基于应用名的应用链接 + +在实现 `exec` 系统调用的时候,我们需要根据应用的名字而不仅仅是一个编号来获取应用的 ELF 格式数据。因此原有的链接和加载接口需要做出如下变更: + +在 Rust 编译&链接辅助程序 `os/build.rs` 中,会读取位于 `user/src/bin` 中应用程序对应的执行文件,并生成 `link_app.S` ,按顺序保存链接进来的每个应用的名字: + +```rust +// os/build.rs + +for i in 0..apps.len() { + writeln!(f, r#" .quad app_{}_start"#, i)?; +} +writeln!(f, r#" .quad app_{}_end"#, apps.len() - 1)?; + +writeln!(f, r#" +.global _app_names +_app_names:"#)?; +for app in apps.iter() { + writeln!(f, r#" .string "{}""#, app)?; +} + +for (idx, app) in apps.iter().enumerate() { + ... +} +``` + +第 8~13 行,我们按照顺序将各个应用的名字通过 `.string` 伪指令放到数据段中,注意链接器会自动在每个字符串的结尾加入分隔符 `\0` ,它们的位置则由全局符号 `_app_names` 指出。这样在编译操作系统的过程中,会生成如下的 `link_app.S` 文件: + +```default + .section .data + .global _num_app +_num_app: + .quad 15 + .quad app_0_start + .quad app_1_start +...... + .global _app_names +_app_names: + .string "exit" + .string "fantastic_text" +...... + .section .data + .global app_0_start + .global app_0_end + .align 3 +app_0_start: + .incbin "../user/target/riscv64gc-unknown-none-elf/release/exit" +app_0_end: +...... +``` + +在这个文件中,可以看到应用代码和表示应用的元数据信息都放在数据段。第10行是第一个应用的名字 `exit` ,第13~14行是第一个应用 `exit` 在OS镜像文件中的开始和结束位置;第18行是第一个应用 `exit` 的ELF格式执行文件的内容, + +### 基于应用名的应用加载器 + +而在加载器 `loader.rs` 中,我们会分析 `link_app.S` 中的内容,并用一个全局可见的 *只读* 向量 `APP_NAMES` 来按照顺序将所有应用的名字保存在内存中: + +```Rust +// os/src/loader.rs + +lazy_static! { + static ref APP_NAMES: Vec<&'static str> = { + let num_app = get_num_app(); + extern "C" { fn _app_names(); } + let mut start = _app_names as usize as *const u8; + let mut v = Vec::new(); + unsafe { + for _ in 0..num_app { + let mut end = start; + while end.read_volatile() != '\0' as u8 { + end = end.add(1); + } + let slice = core::slice::from_raw_parts(start, end as usize - start as usize); + let str = core::str::from_utf8(slice).unwrap(); + v.push(str); + start = end.add(1); + } + } + v + }; +} +``` + +使用 `get_app_data_by_name` 可以按照应用的名字来查找获得应用的 ELF 数据,而 `list_apps` 在内核初始化时被调用,它可以打印出所有可用的应用的名字。 + +```rust +// os/src/loader.rs + +pub fn get_app_data_by_name(name: &str) -> Option<&'static [u8]> { + let num_app = get_num_app(); + (0..num_app) + .find(|&i| APP_NAMES[i] == name) + .map(|i| get_app_data(i)) +} + +pub fn list_apps() { + println!("/**** APPS ****"); + for app in APP_NAMES.iter() { + println!("{}", app); + } + println!("**************/") +} +``` + +这样,操作系统就可以读取并加载某个应用的执行文件到内存中了,这就为通过 `exec` 系统调用创建新进程做好了前期准备。 + +## 进程标识符和内核栈 + +### 进程标识符 + +同一时间存在的所有进程都有一个唯一的进程标识符,它们是互不相同的整数,这样才能表示表示进程的唯一性。这里我们使用 RAII 的思想,将其抽象为一个 `PidHandle` 类型,当它的生命周期结束后对应的整数会被编译器自动回收: + +```rust +// os/src/task/pid.rs + +pub struct PidHandle(pub usize); +``` + +类似之前的物理页帧分配器 `FrameAllocator` ,我们实现一个同样使用简单栈式分配策略的进程标识符分配器 `PidAllocator` ,并将其全局实例化为 `PID_ALLOCATOR` : + +```rust +// os/src/task/pid.rs + +struct PidAllocator { + current: usize, + recycled: Vec, +} + +impl PidAllocator { + pub fn new() -> Self { + PidAllocator { + current: 0, + recycled: Vec::new(), + } + } + pub fn alloc(&mut self) -> PidHandle { + if let Some(pid) = self.recycled.pop() { + PidHandle(pid) + } else { + self.current += 1; + PidHandle(self.current - 1) + } + } + pub fn dealloc(&mut self, pid: usize) { + assert!(pid < self.current); + assert!( + self.recycled.iter().find(|ppid| **ppid == pid).is_none(), + "pid {} has been deallocated!", pid + ); + self.recycled.push(pid); + } +} + +lazy_static! { + static ref PID_ALLOCATOR : UPSafeCell = unsafe { + UPSafeCell::new(PidAllocator::new()) + }; +} +``` + +`PidAllocator::alloc` 将会分配出去一个将 `usize` 包装之后的 `PidHandle` 。我们将其包装为一个全局分配进程标识符的接口 `pid_alloc` 提供给内核的其他子模块: + +```rust +// os/src/task/pid.rs + +pub fn pid_alloc() -> PidHandle { + PID_ALLOCATOR.exclusive_access().alloc() +} +``` + +同时我们也需要为 `PidHandle` 实现 `Drop` Trait 来允许编译器进行自动的资源回收: + +```rust +// os/src/task/pid.rs + +impl Drop for PidHandle { + fn drop(&mut self) { + PID_ALLOCATOR.exclusive_access().dealloc(self.0); + } +} +``` + +### 内核栈 + +在前面的章节中我们介绍过 [内核地址空间布局](../chapter4/5kernel-app-spaces.md#kernel-as-high) ,当时我们将每个应用的内核栈按照应用编号从小到大的顺序将它们作为逻辑段从高地址到低地址放在内核地址空间中,且两两之间保留一个守护页面使得我们能够尽可能早的发现内核栈溢出问题。从本章开始,我们将应用编号替换为进程标识符。我们可以在内核栈 `KernelStack` 中保存着它所属进程的 PID : + +```rust +// os/src/task/pid.rs + +pub struct KernelStack { + pid: usize, +} +``` + +它提供以下方法: + +```rust +// os/src/task/pid.rs + +/// Return (bottom, top) of a kernel stack in kernel space. +pub fn kernel_stack_position(app_id: usize) -> (usize, usize) { + let top = TRAMPOLINE - app_id * (KERNEL_STACK_SIZE + PAGE_SIZE); + let bottom = top - KERNEL_STACK_SIZE; + (bottom, top) +} + +impl KernelStack { + pub fn new(pid_handle: &PidHandle) -> Self { + let pid = pid_handle.0; + let (kernel_stack_bottom, kernel_stack_top) = kernel_stack_position(pid); + KERNEL_SPACE + .exclusive_access() + .insert_framed_area( + kernel_stack_bottom.into(), + kernel_stack_top.into(), + MapPermission::R | MapPermission::W, + ); + KernelStack { + pid: pid_handle.0, + } + } + pub fn push_on_top(&self, value: T) -> *mut T where + T: Sized, { + let kernel_stack_top = self.get_top(); + let ptr_mut = (kernel_stack_top - core::mem::size_of::()) as *mut T; + unsafe { *ptr_mut = value; } + ptr_mut + } + pub fn get_top(&self) -> usize { + let (_, kernel_stack_top) = kernel_stack_position(self.pid); + kernel_stack_top + } +} +``` + +- 第 11 行, `new` 方法可以从一个 `PidHandle` ,也就是一个已分配的进程标识符中对应生成一个内核栈 `KernelStack` 。它调用了第 4 行声明的 `kernel_stack_position` 函数来根据进程标识符计算内核栈在内核地址空间中的位置,随即在第 14 行将一个逻辑段插入内核地址空间 `KERNEL_SPACE` 中。 +- 第 25 行的 `push_on_top` 方法可以将一个类型为 `T` 的变量压入内核栈顶并返回其裸指针,这也是一个泛型函数。它在实现的时候用到了第 32 行的 `get_top` 方法来获取当前内核栈顶在内核地址空间中的地址。 + +内核栈 `KernelStack` 也用到了 RAII 的思想,具体来说,实际保存它的物理页帧的生命周期与它绑定在一起,当 `KernelStack` 生命周期结束后,这些物理页帧也将会被编译器自动回收: + +```rust +// os/src/task/pid.rs + +impl Drop for KernelStack { + fn drop(&mut self) { + let (kernel_stack_bottom, _) = kernel_stack_position(self.pid); + let kernel_stack_bottom_va: VirtAddr = kernel_stack_bottom.into(); + KERNEL_SPACE + .exclusive_access() + .remove_area_with_start_vpn(kernel_stack_bottom_va.into()); + } +} +``` + +这仅需要为 `KernelStack` 实现 `Drop` Trait,一旦它的生命周期结束则在内核地址空间中将对应的逻辑段删除(为此在 `MemorySet` 中新增了一个名为 `remove_area_with_start_vpn` 的方法,感兴趣的同学可以参考其实现),由前面章节的介绍我们知道这也就意味着那些物理页帧被同时回收掉了。 + +## 进程控制块 + +在内核中,每个进程的执行状态、资源控制等元数据均保存在一个被称为 **进程控制块** (PCB, Process Control Block) 的结构中,它是内核对进程进行管理的单位,故而是一种极其关键的内核数据结构。在内核看来,它就等价于一个进程。 + +![进程控制块示意图](./pcb.png) + +承接前面的章节,我们仅需对任务控制块 `TaskControlBlock` 进行若干改动并让它直接承担进程控制块的功能: + +```rust +// os/src/task/task.rs + +pub struct TaskControlBlock { + // immutable + pub pid: PidHandle, + pub kernel_stack: KernelStack, + // mutable + inner: UPSafeCell, +} + +pub struct TaskControlBlockInner { + pub trap_cx_ppn: PhysPageNum, + pub base_size: usize, + pub task_cx: TaskContext, + pub task_status: TaskStatus, + pub memory_set: MemorySet, + pub parent: Option>, + pub children: Vec>, + pub exit_code: i32, +} +``` + +任务控制块中包含两部分: + +- 在初始化之后就不再变化的元数据:直接放在任务控制块中。这里将进程标识符 `PidHandle` 和内核栈 `KernelStack` 放在其中; +- 在运行过程中可能发生变化的元数据:则放在 `TaskControlBlockInner` 中,将它再包裹上一层 `UPSafeCell` 放在任务控制块中。这是因为在我们的设计中外层只能获取任务控制块的不可变引用,若想修改里面的部分内容的话这需要 `UPSafeCell` 所提供的内部可变性。 + +`TaskControlBlockInner` 中则包含下面这些内容: + +- `trap_cx_ppn` 指出了应用地址空间中的 Trap 上下文(详见第四章)被放在的物理页帧的物理页号。 +- `base_size` 的含义是:应用数据仅有可能出现在应用地址空间低于 `base_size` 字节的区域中。借助它我们可以清楚的知道应用有多少数据驻留在内存中。 +- `task_cx` 将暂停的任务的任务上下文保存在任务控制块中。 +- `task_status` 维护当前进程的执行状态。 +- `memory_set` 表示应用地址空间。 +- `parent` 指向当前进程的父进程(如果存在的话)。注意我们使用 `Weak` 而非 `Arc` 来包裹另一个任务控制块,因此这个智能指针将不会影响父进程的引用计数。 +- `children` 则将当前进程的所有子进程的任务控制块以 `Arc` 智能指针的形式保存在一个向量中,这样才能够更方便的找到它们。 +- 当进程调用 exit 系统调用主动退出或者执行出错由内核终止的时候,它的退出码 `exit_code` 会被内核保存在它的任务控制块中,并等待它的父进程通过 waitpid 回收它的资源的同时也收集它的 PID 以及退出码。 + +注意我们在维护父子进程关系的时候大量用到了引用计数 `Arc/Weak` 。进程控制块的本体是被放到内核堆上面的,对于它的一切访问都是通过智能指针 `Arc/Weak` 来进行的,这样是便于建立父子进程的双向链接关系(避免仅基于 `Arc` 形成环状链接关系)。当且仅当智能指针 `Arc` 的引用计数变为 0 的时候,进程控制块以及被绑定到它上面的各类资源才会被回收。子进程的进程控制块并不会被直接放到父进程控制块中,因为子进程完全有可能在父进程退出后仍然存在。 + +`TaskControlBlockInner` 提供的方法主要是对于它内部的字段的快捷访问: + +```rust +// os/src/task/task.rs + +impl TaskControlBlockInner { + pub fn get_trap_cx(&self) -> &'static mut TrapContext { + self.trap_cx_ppn.get_mut() + } + pub fn get_user_token(&self) -> usize { + self.memory_set.token() + } + fn get_status(&self) -> TaskStatus { + self.task_status + } + pub fn is_zombie(&self) -> bool { + self.get_status() == TaskStatus::Zombie + } +} +``` + +而任务控制块 `TaskControlBlock` 目前提供以下方法: + +```rust +// os/src/task/task.rs + +impl TaskControlBlock { + pub fn inner_exclusive_access(&self) -> RefMut<'_, TaskControlBlockInner> { + self.inner.exclusive_access() + } + pub fn getpid(&self) -> usize { + self.pid.0 + } + pub fn new(elf_data: &[u8]) -> Self {...} + pub fn exec(&self, elf_data: &[u8]) {...} + pub fn fork(self: &Arc) -> Arc {...} +} +``` + +- `inner_exclusive_access` 通过 `UPSafeCell.exclusive_access()` 来得到一个 `RefMut<'_, TaskControlBlockInner>` ,它可以被看成一个内层 `TaskControlBlockInner` 的可变引用并可以对它指向的内容进行修改。 +- `getpid` 以 `usize` 的形式返回当前进程的进程标识符。 +- `new` 用来创建一个新的进程,目前仅用于内核中手动创建唯一一个初始进程 `initproc` 。 +- `exec` 用来实现 `exec` 系统调用,即当前进程加载并执行另一个 ELF 格式可执行文件。 +- `fork` 用来实现 `fork` 系统调用,即当前进程 fork 出来一个与之几乎相同的子进程。 + +`new/exec/fork` 的实现我们将在下一小节再介绍。 + +## 任务管理器 + +在前面的章节中,任务管理器 `TaskManager` 不仅负责管理所有的任务,还维护着 CPU 当前在执行哪个任务。由于这种设计不够灵活,不能拓展到后续的多核环境,我们需要将任务管理器对于 CPU 的监控职能拆分到下面即将介绍的处理器管理结构 `Processor` 中去,任务管理器自身仅负责管理所有任务。在这里,任务指的就是进程。 + +```rust +// os/src/task/manager.rs + +pub struct TaskManager { + ready_queue: VecDeque>, +} + +/// A simple FIFO scheduler. +impl TaskManager { + pub fn new() -> Self { + Self { ready_queue: VecDeque::new(), } + } + pub fn add(&mut self, task: Arc) { + self.ready_queue.push_back(task); + } + pub fn fetch(&mut self) -> Option> { + self.ready_queue.pop_front() + } +} + +lazy_static! { + pub static ref TASK_MANAGER: UPSafeCell = unsafe { + UPSafeCell::new(TaskManager::new()) + }; +} + +pub fn add_task(task: Arc) { + TASK_MANAGER.exclusive_access().add(task); +} + +pub fn fetch_task() -> Option> { + TASK_MANAGER.exclusive_access().fetch() +} +``` + +`TaskManager` 将所有的任务控制块用引用计数 `Arc` 智能指针包裹后放在一个双端队列 `VecDeque` 中。正如之前介绍的那样,我们并不直接将任务控制块放到 `TaskManager` 里面,而是将它们放在内核堆上,在任务管理器中仅存放他们的引用计数智能指针,这也是任务管理器的操作单位。这样做的原因在于,任务控制块经常需要被放入/取出,如果直接移动任务控制块自身将会带来大量的数据拷贝开销,而对于智能指针进行移动则没有多少开销。其次,允许任务控制块的共享引用在某些情况下能够让我们的实现更加方便。 + +`TaskManager` 提供 `add/fetch` 两个操作,前者表示将一个任务加入队尾,后者则表示从队头中取出一个任务来执行。从调度算法来看,这里用到的就是最简单的 RR 算法。全局实例 `TASK_MANAGER` 则提供给内核的其他子模块 `add_task/fetch_task` 两个函数。 + + + +## 处理器管理结构 + +处理器管理结构 `Processor` 负责从任务管理器 `TaskManager` 中分出去的维护 CPU 状态的职责: + +```rust +// os/src/task/processor.rs + +pub struct Processor { + current: Option>, + idle_task_cx: TaskContext, +} + +impl Processor { + pub fn new() -> Self { + Self { + current: None, + idle_task_cx: TaskContext::zero_init(), + } + } +} +``` + +在 `Processor` 中存放所有在运行过程中可能变化的内容,目前包括: + +- `current` 表示在当前处理器上正在执行的任务; +- `idle_task_cx` 表示当前处理器上的 idle 控制流的任务上下文。 + +`Processor` 是描述CPU 执行状态 的数据结构。在单核CPU环境下,我们仅创建单个 `Processor` 的全局实例 `PROCESSOR` : + +```rust +// os/src/task/processor.rs + +lazy_static! { + pub static ref PROCESSOR: UPSafeCell = unsafe { + UPSafeCell::new(Processor::new()) + }; +} +``` + +### 正在执行的任务 + +在抢占式调度模型中,在一个处理器上执行的任务常常被换入或换出,因此我们需要维护在一个处理器上正在执行的任务,可以查看它的信息或是对它进行替换: + +```rust +// os/src/task/processor.rs + +impl Processor { + pub fn take_current(&mut self) -> Option> { + self.current.take() + } + pub fn current(&self) -> Option> { + self.current.as_ref().map(|task| Arc::clone(task)) + } +} + +pub fn take_current_task() -> Option> { + PROCESSOR.exclusive_access().take_current() +} + +pub fn current_task() -> Option> { + PROCESSOR.exclusive_access().current() +} + +pub fn current_user_token() -> usize { + let task = current_task().unwrap(); + let token = task.inner_exclusive_access().get_user_token(); + token +} + +pub fn current_trap_cx() -> &'static mut TrapContext { + current_task().unwrap().inner_exclusive_access().get_trap_cx() +} +``` + +- 第 4 行的 `Processor::take_current` 可以取出当前正在执行的任务。 +- 第 7 行的 `Processor::current` 返回当前执行的任务的一份拷贝。 +- 第 12 行的 `take_current_task` 以及第 16 行的 `current_task` 是对 `Processor::take_current/current` 进行封装并提供给内核其他子模块的接口。 +- 第 20 行的 `current_user_token` 和第 26 行的 `current_trap_cx` 基于 `current_task` 实现,可以提供当前正在执行的任务的更多信息。 + +### 任务调度的 idle 控制流 + +> `Processor` 有一个不同的 idle 控制流,它运行在这个 CPU 核的启动栈上,功能是尝试从任务管理器中选出一个任务来在当前 CPU 核上执行。在内核初始化完毕之后,会通过调用 `run_tasks` 函数来进入 idle 控制流: +```rust +// os/src/task/processor.rs + +pub fn run_tasks() { + loop { + let mut processor = PROCESSOR.exclusive_access(); + if let Some(task) = fetch_task() { + let idle_task_cx_ptr = processor.get_idle_task_cx_ptr(); + // access coming task TCB exclusively + let mut task_inner = task.inner_exclusive_access(); + let next_task_cx_ptr = &task_inner.task_cx as *const TaskContext; + task_inner.task_status = TaskStatus::Running; + // stop exclusively accessing coming task TCB manually + drop(task_inner); + processor.current = Some(task); + // stop exclusively accessing processor manually + drop(processor); + unsafe { + __switch( + idle_task_cx_ptr, + next_task_cx_ptr, + ); + } + } + } +} + +impl Processor { + fn get_idle_task_cx_ptr(&mut self) -> *mut TaskContext { + &mut self.idle_task_cx as *mut _ + } +} +``` + +可以看到,调度功能的主体是 `run_tasks()` 。它循环调用 `fetch_task` 直到顺利从任务管理器中取出一个任务,随后便准备通过任务切换的方式来执行: + +- 第 7 行得到 `__switch` 的第一个参数,也就是当前 idle 控制流的 task_cx_ptr,这调用了第 25 行的 `Processor.get_idle_task_cx_ptr` 方法。 +- 第 9~11 行需要先获取从任务管理器中取出对应的任务控制块,并获取任务块内部的 `next_task_cx_ptr` 作为 `__switch` 的第二个参数,然后修改任务的状态为 `Running` 。 +- 第 13 行需要手动回收对即将执行任务的任务控制块的借用标记,使得后续我们仍可以访问该任务控制块。这里我们不能依赖编译器在 `if let` 块结尾时的自动回收,因为中间我们会在自动回收之前调用 `__switch` ,这将导致我们在实际上已经结束访问却没有进行回收的情况下切换到下一个任务,最终可能违反 `UPSafeCell` 的借用约定而使得内核报错退出。同理在第 16 行我们手动回收 `PROCESSOR` 的借用标记。 +- 第 14 行我们修改当前 `Processor` 正在执行的任务为我们取出的任务。注意这里相当于 `Arc` 形式的任务从任务管理器流动到了处理器管理结构中。也就是说,在稳定的情况下,每个尚未结束的进程的任务控制块都只能被引用一次,要么在任务管理器中,要么则是在代表 CPU 处理器的 `Processor` 中。 +- 第 18 行我们调用 `__switch` 来从当前的 idle 控制流切换到接下来要执行的任务。 + +上面介绍了从 idle 控制流通过任务调度切换到某个任务开始执行的过程。而反过来,当一个应用用尽了内核本轮分配给它的时间片或者它主动调用 `yield` 系统调用交出 CPU 使用权之后,内核会调用 `schedule` 函数来切换到 idle 控制流并开启新一轮的任务调度。 + +```rust +// os/src/task/processor.rs + +pub fn schedule(switched_task_cx_ptr: *mut TaskContext) { + let mut processor = PROCESSOR.exclusive_access(); + let idle_task_cx_ptr = processor.get_idle_task_cx_ptr(); + drop(processor); + unsafe { + __switch( + switched_task_cx_ptr, + idle_task_cx_ptr, + ); + } +} +``` + +这里,我们需要传入即将被切换出去的任务的 task_cx_ptr 来在合适的位置保存任务上下文,之后就可以通过 `__switch` 来切换到 idle 控制流。从源代码来看,切换回去之后,内核将跳转到 `run_tasks` 中 `__switch` 返回之后的位置,也即开启了下一轮的调度循环。 diff --git a/ch5/book/3implement-process-mechanism.md b/ch5/book/3implement-process-mechanism.md new file mode 100644 index 00000000..a7c99554 --- /dev/null +++ b/ch5/book/3implement-process-mechanism.md @@ -0,0 +1,668 @@ +# 进程管理机制的设计实现 + +## 本节导读 + +有了上节的数据结构和相关基本方法的介绍后,我们还需完成进程管理关键功能的实现,从而构造出一个完整的白垩纪“伤齿龙”操作系统。本节将从如下四个方面介绍如何基于上一节设计的内核数据结构来实现进程管理: + +- 创建初始进程:创建第一个用户态进程 `initproc`; +- 进程调度机制:当进程主动调用 `sys_yield` 交出 CPU 使用权或者内核把本轮分配的时间片用尽的进程换出且换入下一个进程; +- 进程生成机制:介绍进程相关的两个重要系统调用 `sys_fork/sys_exec` 的实现; +- 进程资源回收机制:当进程调用 `sys_exit` 正常退出或者出错被内核终止之后如何保存其退出码,其父进程通过 `sys_waitpid` 系统调用收集该进程的信息并回收其资源。 +- 字符输入机制:为了支持shell程序-user_shell获得字符输入,介绍 `sys_read` 系统调用的实现; + +## 初始进程的创建 + +内核初始化完毕之后即会调用 `task` 子模块提供的 `add_initproc` 函数来将初始进程 `initproc` 加入任务管理器,但在这之前我们需要初始化初始进程的进程控制块 `INITPROC` ,这个过程基于 `lazy_static` 在运行时完成。 + +```rust +// os/src/task/mod.rs + +use crate::loader::get_app_data_by_name; +use manager::add_task; + +lazy_static! { + pub static ref INITPROC: Arc = Arc::new( + TaskControlBlock::new(get_app_data_by_name("initproc").unwrap()) + ); +} + +pub fn add_initproc() { + add_task(INITPROC.clone()); +} +``` + +我们调用 `TaskControlBlock::new` 来创建一个进程控制块,它需要传入 ELF 可执行文件的数据切片作为参数,这可以通过加载器 `loader` 子模块提供的 `get_app_data_by_name` 接口查找 `initproc` 的 ELF 执行文件数据来获得。在初始化 `INITPROC` 之后,就可以在 `add_initproc` 中调用 `task` 的任务管理器 `manager` 子模块提供的 `add_task` 接口,将其加入到任务管理器。 + +接下来介绍 `TaskControlBlock::new` 是如何实现的: + +```rust +// os/src/task/task.rs + +use super::{PidHandle, pid_alloc, KernelStack}; +use super::TaskContext; +use crate::config::TRAP_CONTEXT; +use crate::trap::TrapContext; + +// impl TaskControlBlock +pub fn new(elf_data: &[u8]) -> Self { + // memory_set with elf program headers/trampoline/trap context/user stack + let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data); + let trap_cx_ppn = memory_set + .translate(VirtAddr::from(TRAP_CONTEXT).into()) + .unwrap() + .ppn(); + // alloc a pid and a kernel stack in kernel space + let pid_handle = pid_alloc(); + let kernel_stack = KernelStack::new(&pid_handle); + let kernel_stack_top = kernel_stack.get_top(); + // push a task context which goes to trap_return to the top of kernel stack + let task_control_block = Self { + pid: pid_handle, + kernel_stack, + inner: unsafe { UPSafeCell::new(TaskControlBlockInner { + trap_cx_ppn, + base_size: user_sp, + task_cx: TaskContext::goto_trap_return(kernel_stack_top), + task_status: TaskStatus::Ready, + memory_set, + parent: None, + children: Vec::new(), + exit_code: 0, + })}, + }; + // prepare TrapContext in user space + let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx(); + *trap_cx = TrapContext::app_init_context( + entry_point, + user_sp, + KERNEL_SPACE.exclusive_access().token(), + kernel_stack_top, + trap_handler as usize, + ); + task_control_block +} +``` + +- 第 11 行我们解析应用的 ELF 执行文件得到应用地址空间 `memory_set` ,用户栈在应用地址空间中的位置 `user_sp` 以及应用的入口点 `entry_point` 。 +- 第 12 行我们手动查页表找到位于应用地址空间中新创建的Trap 上下文被实际放在哪个物理页帧上,用来做后续的初始化。 +- 第 16~19 行我们为该进程分配 PID 以及内核栈,并记录下内核栈在内核地址空间的位置 `kernel_stack_top` 。 +- 第 20 行我们在该进程的内核栈上压入初始化的任务上下文,使得第一次任务切换到它的时候可以跳转到 `trap_return` 并进入用户态开始执行。 +- 第 21 行我们整合之前的部分信息创建进程控制块 `task_control_block` 。 +- 第 37 行我们初始化位于该进程应用地址空间中的 Trap 上下文,使得第一次进入用户态的时候时候能正确跳转到应用入口点并设置好用户栈,同时也保证在 Trap 的时候用户态能正确进入内核态。 +- 第 44 行将 `task_control_block` 返回。 + +## 进程调度机制 + +通过调用 `task` 子模块提供的 `suspend_current_and_run_next` 函数可以暂停当前任务并切换到下一个任务,当应用调用 `sys_yield` 主动交出使用权、本轮时间片用尽或者由于某些原因内核中的处理无法继续的时候,就会在内核中调用此函数触发调度机制并进行任务切换。下面给出了两种典型的使用情况: + +```rust +// os/src/syscall/process.rs + +pub fn sys_yield() -> isize { + suspend_current_and_run_next(); + 0 +} + +// os/src/trap/mod.rs + +#[no_mangle] +pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Interrupt(Interrupt::SupervisorTimer) => { + set_next_trigger(); + suspend_current_and_run_next(); + } + ... + } + trap_return(); +} +``` + +随着进程概念的引入, `suspend_current_and_run_next` 的实现也需要发生变化: + +```rust +// os/src/task/mod.rs + +use processor::{task_current_task, schedule}; +use manager::add_task; + +pub fn suspend_current_and_run_next() { + // There must be an application running. + let task = take_current_task().unwrap(); + + // ---- access current TCB exclusively + let mut task_inner = task.inner_exclusive_access(); + let task_cx_ptr = &mut task_inner.task_cx as *mut TaskContext; + // Change status to Ready + task_inner.task_status = TaskStatus::Ready; + drop(task_inner); + // ---- stop exclusively accessing current PCB + + // push back to ready queue. + add_task(task); + // jump to scheduling cycle + schedule(task_cx_ptr); +} +``` + +首先通过 `take_current_task` 来取出当前正在执行的任务,修改其进程控制块内的状态,随后将这个任务放入任务管理器的队尾。接着调用 `schedule` 函数来触发调度并切换任务。注意,当仅有一个任务的时候, `suspend_current_and_run_next` 的效果是会继续执行这个任务。 + +## 进程的生成机制 + +在内核中手动生成的进程只有初始进程 `initproc` ,余下所有的进程都是它直接或间接 fork 出来的。当一个子进程被 fork 出来之后,它可以调用 `exec` 系统调用来加载并执行另一个可执行文件。因此, `fork/exec` 两个系统调用提供了进程的生成机制。下面我们分别来介绍二者的实现。 + +### fork 系统调用的实现 + +在实现 fork 的时候,最为关键且困难的是为子进程创建一个和父进程几乎完全相同的应用地址空间。我们的实现如下: + +```rust +// os/src/mm/memory_set.rs + +impl MapArea { + pub fn from_another(another: &MapArea) -> Self { + Self { + vpn_range: VPNRange::new( + another.vpn_range.get_start(), + another.vpn_range.get_end() + ), + data_frames: BTreeMap::new(), + map_type: another.map_type, + map_perm: another.map_perm, + } + } +} + +impl MemorySet { + pub fn from_existed_user(user_space: &MemorySet) -> MemorySet { + let mut memory_set = Self::new_bare(); + // map trampoline + memory_set.map_trampoline(); + // copy data sections/trap_context/user_stack + for area in user_space.areas.iter() { + let new_area = MapArea::from_another(area); + memory_set.push(new_area, None); + // copy data from another space + for vpn in area.vpn_range { + let src_ppn = user_space.translate(vpn).unwrap().ppn(); + let dst_ppn = memory_set.translate(vpn).unwrap().ppn(); + dst_ppn.get_bytes_array().copy_from_slice(src_ppn.get_bytes_array()); + } + } + memory_set + } +} +``` + +这需要对内存管理子模块 `mm` 做一些拓展: + +- 第 4 行的 `MapArea::from_another` 可以从一个逻辑段复制得到一个虚拟地址区间、映射方式和权限控制均相同的逻辑段,不同的是由于它还没有真正被映射到物理页帧上,所以 `data_frames` 字段为空。 +- 第 18 行的 `MemorySet::from_existed_user` 可以复制一个完全相同的地址空间。首先在第 19 行,我们通过 `new_bare` 新创建一个空的地址空间,并在第 21 行通过 `map_trampoline` 为这个地址空间映射上跳板页面,这是因为我们解析 ELF 创建地址空间的时候,并没有将跳板页作为一个单独的逻辑段插入到地址空间的逻辑段向量 `areas` 中,所以这里需要单独映射上。 + + 剩下的逻辑段都包含在 `areas` 中。我们遍历原地址空间中的所有逻辑段,将复制之后的逻辑段插入新的地址空间,在插入的时候就已经实际分配了物理页帧了。接着我们遍历逻辑段中的每个虚拟页面,对应完成数据复制,这只需要找出两个地址空间中的虚拟页面各被映射到哪个物理页帧,就可转化为将数据从物理内存中的一个位置复制到另一个位置,使用 `copy_from_slice` 即可轻松实现。 + +接着,我们实现 `TaskControlBlock::fork` 来从父进程的进程控制块创建一份子进程的控制块: + +```rust +// os/src/task/task.rs + +impl TaskControlBlock { + pub fn fork(self: &Arc) -> Arc { + // ---- access parent PCB exclusively + let mut parent_inner = self.inner_exclusive_access(); + // copy user space(include trap context) + let memory_set = MemorySet::from_existed_user( + &parent_inner.memory_set + ); + let trap_cx_ppn = memory_set + .translate(VirtAddr::from(TRAP_CONTEXT).into()) + .unwrap() + .ppn(); + // alloc a pid and a kernel stack in kernel space + let pid_handle = pid_alloc(); + let kernel_stack = KernelStack::new(&pid_handle); + let kernel_stack_top = kernel_stack.get_top(); + let task_control_block = Arc::new(TaskControlBlock { + pid: pid_handle, + kernel_stack, + inner: unsafe { UPSafeCell::new(TaskControlBlockInner { + trap_cx_ppn, + base_size: parent_inner.base_size, + task_cx: TaskContext::goto_trap_return(kernel_stack_top), + task_status: TaskStatus::Ready, + memory_set, + parent: Some(Arc::downgrade(self)), + children: Vec::new(), + exit_code: 0, + })}, + }); + // add child + parent_inner.children.push(task_control_block.clone()); + // modify kernel_sp in trap_cx + // **** access children PCB exclusively + let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx(); + trap_cx.kernel_sp = kernel_stack_top; + // return + task_control_block + // ---- stop exclusively accessing parent/children PCB automatically + } +} +``` + +它基本上和新建进程控制块的 `TaskControlBlock::new` 是相同的,但要注意以下几点: + +- 子进程的地址空间不是通过解析 ELF 文件,而是通过在第 8 行调用 `MemorySet::from_existed_user` 复制父进程地址空间得到的; +- 第 24 行,我们让子进程和父进程的 `base_size` ,也即应用数据的大小保持一致; +- 在 fork 的时候需要注意父子进程关系的维护。第 28 行我们将父进程的弱引用计数放到子进程的进程控制块中,而在第 33 行我们将子进程插入到父进程的孩子向量 `children` 中。 + +我们在子进程内核栈上压入一个初始化的任务上下文,使得内核一旦通过任务切换到该进程,就会跳转到 `trap_return` 来进入用户态。而在复制地址空间的时候,子进程的 Trap 上下文也是完全从父进程复制过来的,这可以保证子进程进入用户态和其父进程回到用户态的那一瞬间 CPU 的状态是完全相同的(后面我们会让它们的返回值不同从而区分两个进程)。而两个进程的应用数据由于地址空间复制的原因也是完全相同的,这是 fork 语义要求做到的。 + +在具体实现 `sys_fork` 的时候,我们需要特别注意如何体现父子进程的差异: + +```rust +// os/src/syscall/process.rs + +pub fn sys_fork() -> isize { + let current_task = current_task().unwrap(); + let new_task = current_task.fork(); + let new_pid = new_task.pid.0; + // modify trap context of new_task, because it returns immediately after switching + let trap_cx = new_task.inner_exclusive_access().get_trap_cx(); + // we do not have to move to next instruction since we have done it before + // for child process, fork returns 0 + trap_cx.x[10] = 0; //x[10] is a0 reg + // add new task to scheduler + add_task(new_task); + new_pid as isize +} + +// os/src/trap/mod.rs + +#[no_mangle] +pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Exception(Exception::UserEnvCall) => { + // jump to next instruction anyway + let mut cx = current_trap_cx(); + cx.sepc += 4; + // get system call return value + let result = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]); + // cx is changed during sys_exec, so we have to call it again + cx = current_trap_cx(); + cx.x[10] = result as usize; + } + ... +} +``` + +在调用 `syscall` 进行系统调用分发并具体调用 `sys_fork` 之前, 第28行,`trap_handler` 已经将当前进程 Trap 上下文中的 `sepc` 向后移动了 4 字节,使得它回到用户态之后,会从发出系统调用的 `ecall` 指令的下一条指令开始执行。之后当我们复制地址空间的时候,子进程地址空间 Trap 上下文的 `sepc` 也是移动之后的值,我们无需再进行修改。 + +父子进程回到用户态的瞬间都处于刚刚从一次系统调用返回的状态,但二者的返回值不同。第 8~11 行我们将子进程的 Trap 上下文中用来存放系统调用返回值的 a0 寄存器修改为 0 ;第 33 行,而父进程系统调用的返回值会在 `trap_handler` 中 `syscall` 返回之后再设置为 `sys_fork` 的返回值,这里我们返回子进程的 PID 。这就做到了父进程 `fork` 的返回值为子进程的 PID ,而子进程的返回值则为 0 。通过返回值是否为 0 可以区分父子进程。 + +另外,不要忘记在第 13 行,我们将生成的子进程通过 `add_task` 加入到任务管理器中。 + +### exec 系统调用的实现 + +`exec` 系统调用使得一个进程能够加载一个新应用的 ELF 可执行文件中的代码和数据替换原有的应用地址空间中的内容,并开始执行。我们先从进程控制块的层面进行修改: + +```rust +// os/src/task/task.rs + +impl TaskControlBlock { + pub fn exec(&self, elf_data: &[u8]) { + // memory_set with elf program headers/trampoline/trap context/user stack + let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data); + let trap_cx_ppn = memory_set + .translate(VirtAddr::from(TRAP_CONTEXT).into()) + .unwrap() + .ppn(); + + // **** access inner exclusively + let mut inner = self.inner_exclusive_access(); + // substitute memory_set + inner.memory_set = memory_set; + // update trap_cx ppn + inner.trap_cx_ppn = trap_cx_ppn; + // initialize trap_cx + let trap_cx = inner.get_trap_cx(); + *trap_cx = TrapContext::app_init_context( + entry_point, + user_sp, + KERNEL_SPACE.exclusive_access().token(), + self.kernel_stack.get_top(), + trap_handler as usize, + ); + // **** stop exclusively accessing inner automatically + } +} +``` + +它在解析传入的 ELF 格式数据之后只做了两件事情: + +- 首先是从 ELF 文件生成一个全新的地址空间并直接替换进来(第 15 行),这将导致原有的地址空间生命周期结束,里面包含的全部物理页帧都会被回收; +- 然后是修改新的地址空间中的 Trap 上下文,将解析得到的应用入口点、用户栈位置以及一些内核的信息进行初始化,这样才能正常实现 Trap 机制。 + +这里无需对任务上下文进行处理,因为这个进程本身已经在执行了,而只有被暂停的应用才需要在内核栈上保留一个任务上下文。 + +有了 `exec` 函数后, `sys_exec` 就很容易实现了: + +```rust +// os/src/mm/page_table.rs + +pub fn translated_str(token: usize, ptr: *const u8) -> String { + let page_table = PageTable::from_token(token); + let mut string = String::new(); + let mut va = ptr as usize; + loop { + let ch: u8 = *(page_table.translate_va(VirtAddr::from(va)).unwrap().get_mut()); + if ch == 0 { + break; + } else { + string.push(ch as char); + va += 1; + } + } + string +} + +// os/src/syscall/process.rs + +pub fn sys_exec(path: *const u8) -> isize { + let token = current_user_token(); + let path = translated_str(token, path); + if let Some(data) = get_app_data_by_name(path.as_str()) { + let task = current_task().unwrap(); + task.exec(data); + 0 + } else { + -1 + } +} +``` + +应用在 `sys_exec` 系统调用中传递给内核的只有一个要执行的应用名字符串在当前应用地址空间中的起始地址,如果想在内核中具体获得字符串的话就需要手动查页表。第 3 行的 `translated_str` 便可以从内核地址空间之外的某个应用的用户态地址空间中拿到一个字符串,其原理就是针对应用的字符串中字符的用户态虚拟地址,查页表,找到对应的内核虚拟地址,逐字节地构造字符串,直到发现一个 `\0` 为止(第7~15行)。 + + + +回到 `sys_exec` 的实现,它调用 `translated_str` 找到要执行的应用名并试图在应用加载器提供的 `get_app_data_by_name` 接口中找到对应的 ELF 格式的数据。如果找到,就调用 `TaskControlBlock::exec` 替换掉地址空间并返回 0。这个返回值其实并没有意义,因为我们在替换地址空间的时候本来就对 Trap 上下文重新进行了初始化。如果没有找到,就不做任何事情并返回 -1。在shell程序-user_shell中我们也正是通过这个返回值来判断要执行的应用是否存在。 + +### 系统调用后重新获取 Trap 上下文 + +过去的 `trap_handler` 实现是这样处理系统调用的: + +```rust +// os/src/trap/mod.rs + +#[no_mangle] +pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let cx = current_trap_cx(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Exception(Exception::UserEnvCall) => { + cx.sepc += 4; + cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize; + } + ... + } + trap_return(); +} +``` + +这里的 `cx` 是当前应用的 Trap 上下文的可变引用,我们需要通过查页表找到它具体被放在哪个物理页帧上,并构造相同的虚拟地址来在内核中访问它。对于系统调用 `sys_exec` 来说,一旦调用它之后,我们会发现 `trap_handler` 原来上下文中的 `cx` 失效了——因为它是用来访问之前地址空间中 Trap 上下文被保存在的那个物理页帧的,而现在它已经被回收掉了。因此,为了能够处理类似的这种情况,我们在 `syscall` 分发函数返回之后需要重新获取 `cx` ,目前的实现如下: + +```rust +// os/src/trap/mod.rs + +#[no_mangle] +pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Exception(Exception::UserEnvCall) => { + // jump to next instruction anyway + let mut cx = current_trap_cx(); + cx.sepc += 4; + // get system call return value + let result = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]); + // cx is changed during sys_exec, so we have to call it again + cx = current_trap_cx(); + cx.x[10] = result as usize; + } + ... + } + trap_return(); +} +``` + +## shell程序 user_shell 的输入机制 + +为了实现shell程序 `user_shell` 的输入机制,我们需要实现 `sys_read` 系统调用使得应用能够取得用户的键盘输入。 + +```rust +// os/src/syscall/fs.rs + +use crate::sbi::console_getchar; + +const FD_STDIN: usize = 0; + +pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize { + match fd { + FD_STDIN => { + assert_eq!(len, 1, "Only support len = 1 in sys_read!"); + let mut c: usize; + loop { + c = console_getchar(); + if c == 0 { + suspend_current_and_run_next(); + continue; + } else { + break; + } + } + let ch = c as u8; + let mut buffers = translated_byte_buffer(current_user_token(), buf, len); + unsafe { buffers[0].as_mut_ptr().write_volatile(ch); } + 1 + } + _ => { + panic!("Unsupported fd in sys_read!"); + } + } +} +``` + +目前我们仅支持从标准输入 `FD_STDIN` 即文件描述符 0 读入,且单次读入的长度限制为 1,即每次只能读入一个字符。我们调用 `sbi` 子模块提供的从键盘获取输入的接口 `console_getchar` ,如果返回 0 则说明还没有输入,我们调用 `suspend_current_and_run_next` 暂时切换到其他进程,等下次切换回来的时候再看看是否有输入了。获取到输入之后,我们退出循环并手动查页表将输入的字符正确的写入到应用地址空间。 + +注:我们这里还没有涉及 **文件** 的概念,在后续章节中有具体的介绍。 + +## 进程资源回收机制 + +### 进程的退出 + + + +当应用调用 `sys_exit` 系统调用主动退出或者出错由内核终止之后,会在内核中调用 `exit_current_and_run_next` 函数退出当前进程并切换到下一个进程。使用方法如下: + +```rust +// os/src/syscall/process.rs + +pub fn sys_exit(exit_code: i32) -> ! { + exit_current_and_run_next(exit_code); + panic!("Unreachable in sys_exit!"); +} + +// os/src/trap/mod.rs + +#[no_mangle] +pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Exception(Exception::StoreFault) | + Trap::Exception(Exception::StorePageFault) | + Trap::Exception(Exception::InstructionFault) | + Trap::Exception(Exception::InstructionPageFault) | + Trap::Exception(Exception::LoadFault) | + Trap::Exception(Exception::LoadPageFault) => { + println!( + "[kernel] {:?} in application, bad addr = {:#x}, bad instruction = {:#x}, core dumped.", + scause.cause(), + stval, + current_trap_cx().sepc, + ); + // page fault exit code + exit_current_and_run_next(-2); + } + Trap::Exception(Exception::IllegalInstruction) => { + println!("[kernel] IllegalInstruction in application, core dumped."); + // illegal instruction exit code + exit_current_and_run_next(-3); + } + ... + } + trap_return(); +} +``` + +相比前面的章节, `exit_current_and_run_next` 带有一个退出码作为参数。当在 `sys_exit` 正常退出的时候,退出码由应用传到内核中;而出错退出的情况(如第 29 行的访存错误或第 34 行的非法指令异常)则是由内核指定一个特定的退出码。这个退出码会在 `exit_current_and_run_next` 写入当前进程的进程控制块中: + +```rust +// os/src/mm/memory_set.rs + +impl MemorySet { + pub fn recycle_data_pages(&mut self) { + self.areas.clear(); + } +} + +// os/src/task/mod.rs + +pub fn exit_current_and_run_next(exit_code: i32) { + // take from Processor + let task = take_current_task().unwrap(); + // **** access current TCB exclusively + let mut inner = task.inner_exclusive_access(); + // Change status to Zombie + inner.task_status = TaskStatus::Zombie; + // Record exit code + inner.exit_code = exit_code; + // do not move to its parent but under initproc + + // ++++++ access initproc TCB exclusively + { + let mut initproc_inner = INITPROC.inner_exclusive_access(); + for child in inner.children.iter() { + child.inner_exclusive_access().parent = Some(Arc::downgrade(&INITPROC)); + initproc_inner.children.push(child.clone()); + } + } + // ++++++ stop exclusively accessing parent PCB + + inner.children.clear(); + // deallocate user space + inner.memory_set.recycle_data_pages(); + drop(inner); + // **** stop exclusively accessing current PCB + // drop task manually to maintain rc correctly + drop(task); + // we do not have to save task context + let mut _unused = TaskContext::zero_init(); + schedule(&mut _unused as *mut _); +} +``` + +- 第 13 行我们调用 `take_current_task` 来将当前进程控制块从处理器监控 `PROCESSOR` 中取出而不是得到一份拷贝,这是为了正确维护进程控制块的引用计数; +- 第 17 行我们将进程控制块中的状态修改为 `TaskStatus::Zombie` 即僵尸进程,这样它后续才能被父进程在 `waitpid` 系统调用的时候回收; +- 第 19 行我们将传入的退出码 `exit_code` 写入进程控制块中,后续父进程在 `waitpid` 的时候可以收集; +- 第 24~26 行所做的事情是将当前进程的所有子进程挂在初始进程 `initproc` 下面,其做法是遍历每个子进程,修改其父进程为初始进程,并加入初始进程的孩子向量中。第 32 行将当前进程的孩子向量清空。 +- 第 34 行对于当前进程占用的资源进行早期回收。在第 4 行可以看出, `MemorySet::recycle_data_pages` 只是将地址空间中的逻辑段列表 `areas` 清空(即执行 `Vec` 向量清空),这将导致应用地址空间被回收(即进程的数据和代码对应的物理页帧都被回收),但用来存放页表的那些物理页帧此时还不会被回收(会由父进程最后回收子进程剩余的占用资源)。 +- 最后在第 41 行我们调用 `schedule` 触发调度及任务切换,由于我们再也不会回到该进程的执行过程中,因此无需关心任务上下文的保存。 + +### 父进程回收子进程资源 + +父进程通过 `sys_waitpid` 系统调用来回收子进程的资源并收集它的一些信息: + +```rust +// os/src/syscall/process.rs + +/// If there is not a child process whose pid is same as given, return -1. +/// Else if there is a child process but it is still running, return -2. +pub fn sys_waitpid(pid: isize, exit_code_ptr: *mut i32) -> isize { + let task = current_task().unwrap(); + // find a child process + + // ---- access current TCB exclusively + let mut inner = task.inner_exclusive_access(); + if inner.children + .iter() + .find(|p| {pid == -1 || pid as usize == p.getpid()}) + .is_none() { + return -1; + // ---- stop exclusively accessing current PCB + } + let pair = inner.children + .iter() + .enumerate() + .find(|(_, p)| { + // ++++ temporarily access child PCB exclusively + p.inner_exclusive_access().is_zombie() && (pid == -1 || pid as usize == p.getpid()) + // ++++ stop exclusively accessing child PCB + }); + if let Some((idx, _)) = pair { + let child = inner.children.remove(idx); + // confirm that child will be deallocated after removing from children list + assert_eq!(Arc::strong_count(&child), 1); + let found_pid = child.getpid(); + // ++++ temporarily access child TCB exclusively + let exit_code = child.inner_exclusive_access().exit_code; + // ++++ stop exclusively accessing child PCB + *translated_refmut(inner.memory_set.token(), exit_code_ptr) = exit_code; + found_pid as isize + } else { + -2 + } + // ---- stop exclusively accessing current PCB automatically +} + +// user/src/lib.rs + +pub fn wait(exit_code: &mut i32) -> isize { + loop { + match sys_waitpid(-1, exit_code as *mut _) { + -2 => { yield_(); } + // -1 or a real pid + exit_pid => return exit_pid, + } + } +} +``` + +`sys_waitpid` 是一个立即返回的系统调用,它的返回值语义是:如果当前的进程不存在一个进程 ID 为 pid(pid==-1 或 pid > 0)的子进程,则返回 -1;如果存在一个进程 ID 为 pid 的僵尸子进程,则正常回收并返回子进程的 pid,并更新系统调用的退出码参数为 `exit_code` 。这里还有一个 -2 的返回值,它的含义是子进程还没退出,通知用户库 `user_lib` (是实际发出系统调用的地方),这样用户库看到是 -2 后,就进一步调用 `sys_yield` 系统调用(第46行),让当前父进程进入等待状态。 + +注:在编写应用的开发者看来, 位于用户库 `user_lib` 中的 `wait/waitpid` 两个辅助函数都必定能够返回一个有意义的结果,要么是 -1,要么是一个正数 PID ,是不存在 -2 这种通过等待即可消除的中间结果的。让调用 `wait/waitpid` 两个辅助函数的进程等待正是在用户库 `user_lib` 中完成。 + +第 11~17 行判断 `sys_waitpid` 是否会返回 -1 ,这取决于当前进程是否有一个符合要求的子进程。当传入的 `pid` 为 -1 的时候,任何一个子进程都算是符合要求;但 `pid` 不为 -1 的时候,则只有 PID 恰好与 `pid` 相同的子进程才算符合条件。我们简单通过迭代器即可完成判断。 + +第 18~26 行判断符合要求的子进程中是否有僵尸进程,如果有的话还需要同时找出它在当前进程控制块子进程向量中的下标。如果找不到的话直接返回 `-2` ,否则进入第 27~35 行的处理: + +- 第 27 行我们将子进程从向量中移除并置于当前上下文中; +- 第 29 行确认这是对于该子进程控制块的唯一一次强引用,即它不会出现在某个进程的子进程向量中,更不会出现在处理器监控器或者任务管理器中。当它所在的代码块结束,这次引用变量的生命周期结束,将导致该子进程进程控制块的引用计数变为 0 ,彻底回收掉它占用的所有资源,包括:内核栈和它的 PID 还有它的应用地址空间存放页表的那些物理页帧等等。 + +剩下主要是将收集的子进程信息返回。 + +- 第 30 行得到子进程的 PID 并会在最终返回; +- 第 32 行得到了子进程的退出码; +- 第 34 行写入到当前进程的应用地址空间中。由于应用传递给内核的仅仅是一个指向应用地址空间中保存子进程返回值的内存区域的指针,我们还需要在 `translated_refmut` 中手动查页表找到应该写入到物理内存中的哪个位置,这样才能把子进程的退出码 `exit_code` 返回给父进程。其实现可以在 `os/src/mm/page_table.rs` 中找到,比较简单,在这里不再赘述。 + +到这里,“伤齿龙”操作系统就算完成了。它在启动后,会加载执行用户态的shell程序,并可以通过shell程序提供的命令行交互界面,让使用者敲入要执行的应用程序名字,就可以创建一个子进程来执行这个应用程序,实现了灵活的人机交互和进程管理的动态灵活性。 + + diff --git a/ch5/book/4scheduling.md b/ch5/book/4scheduling.md new file mode 100644 index 00000000..68487612 --- /dev/null +++ b/ch5/book/4scheduling.md @@ -0,0 +1,554 @@ +# 进程调度 + +## 本节导读 + +计算机内存中可执行的程序个数大于处理器个数时,这些程序可通过共享处理器来完成各自的任务。而操作系统负责让它们能够高效合理地共享处理器资源,这就引入了调度(scheduling)这个概念。进程调度(也称处理器调度)是进程管理的重要组成部分。 + +在计算机出现之前,调度这个概念就出现在人类的生活与工作环境中了,如在商店排队购买商品、汽车装配线调度、工厂作业车间调度等。调度的一般定义是:在一定的约束条件下,把有限的资源在时间上分配给若干个任务,以满足或优化一个或多个性能指标。对于计算机系统而言,就是在一台计算机中运行了多个进程,操作系统把有限的处理器在时间上分配给各个进程,以满足或优化进程执行的性能指标。 + +所以本节要阐述的核心问题是: **操作系统如何通过进程调度来提高进程和系统的性能** 。我们可以把这个问题进一步细化为操作系统的一系列关键子问题: + +- 运行进程的约束条件是啥? +- 有哪些调度策略和算法? +- 调度的性能指标是啥? +- 如何评价调度策略和算法? + +## 回顾历史 + +### 本章之前的操作系统实例 + +本章之前已经实现了多个操作系统实例,它们相对比较简单,重点体现各种操作系统核心知识点的设计与实现。对于 **处理器调度** 这个核心知识点,从面向批处理的“邓式鱼” 操作系统开始,就有非常简单的设计与实现。 + +最早的“三叶虫”操作系统以库的形式支持单个裸机应用程序,单个应用程序独占整个计算机系统,这时还没有调度的必要性。在批处理系统中,需要支持多个程序运行,“邓式鱼” 操作系统把要执行的应用按名字排名的先后顺序,按一次加载一个应用的方式把应用放入内存中。当一个应用执行完毕后,再加载并执行下一个应用。这就是一种简单的“排名”调度策略。 + +面向多道程序的“锯齿螈”操作系统可以把多个要执行的程序放到内存中,其调度策略与“邓式鱼” 操作系统一样,也是“排名”调度策略。当进化到“始初龙”操作系统后,我们把运行的程序称为任务,并且任务可以主动放弃处理器。操作系统会把各个任务放到一个调度队列中,从队列头选择一个任务执行。当任务主动放弃处理器或任务执行完毕后,操作系统从队列头选择下一个任务执行,并把主动放弃处理器的任务安置在队列尾。这是一种先来先服务的调度策略,实现起来非常简单。 + +进一步进化的面向分时多任务的“腔骨龙”操作系统继承了始初龙”操作系统的调度策略和组织方式,也会把各个任务放到一个调度队列中,并从队列头选择一个任务执行。但它还考虑了应用间的公平性和系统的执行效率。为此,它给每个任务分配了一个固定的时间片。当正在执行的任务消耗完分配给它的时间片后,操作系统就可以通过时钟中断抢占正在执行的任务,把处理器分配给其他任务执行,而被抢占的任务将放置到队列尾。这是一种基于时间片的轮转调度策略。后续的支持地址空间的“头甲龙”操作系统和支持进程的“伤齿龙”操作系统都采用了这种轮转调度策略。 + +### 计算机发展历史中的调度 + +早期应用主要面向计算密集型的科学计算,用户对与计算机交互的需求还不强。在早期以纸带、卡片或磁带作为程序/数据输入的批处理系统时代,操作系统的调度很简单,只需依次加载并运行每一个作业(应用以作业的形式存在)即可。操作系统不需要考虑多用户,分时复用等情况,能让程序正常运行就很不错了。 + +到了多道程序系统时代,内存中存在多个应用,而应用是属于不同用户的,处理器是用户都想占用的宝贵资源。操作系统需要尽量加快应用的执行,减少用户等待应用执行结果的时间,从而提高用户的满意度。这时的调度策略主要考虑如何尽可能地让处理器一直忙起来,减少完成应用的时间。 + +随着用户对与计算机交互的需求越来越强烈,导致批处理任务(计算密集型应用)和交互任务(I/O密集型应用)都需要在计算机系统上执行。计算机科学家不得不花费更大心思在操作系统的调度上,以应对不同性能指标的任务要求。这一阶段的计算机系统还是以昂贵的大型机/小型机为主,服务的用户主要来源于科学计算和商业处理等科研机构和公司,他们希望把计算机系统的性能榨干,能及时与计算机进行交互,这样才对得起他们付出的大量金钱。 + +当发展到了个人计算机时代,计算机的价格大幅下降,个人计算机上的多数应用相对简单,对处理器的性能要求不高,但需要通过键盘/鼠标/图形显示等I/O设备来展示其丰富多彩的功能。这样造成的结果是,早期操作系统面向多用户的调度功能在相对简单的单用户个人计算机上作用并不显著。 + +随着网络和计算机技术的发展,支持并行的多核处理器已经成为处理器的主流,以数据中心为代表的大规模网络服务器集群系统改变了我们的生活。各种日常应用(搜索、网络社交、网络游戏等)会消耗数据中心中大量的处理器资源和网络/存储资源,多个后端服务应用经常会竞争处理器,因此操作系统的调度功能再一次变得至关重要,且要应对更加复杂多样的应用需求和硬件环境。 + +当移动互联网成为基础设施,移动终端越来越普及,大家几乎人手一台智能手机、智能平板或智能手表等,人们关注的除了流畅地执行各种应用外,还希望这些移动终端能够长时间使用。这使得除了增加电池容量外,操作系统还能在应用不必运行时,让它们尽量休眠,并通过关闭可暂时不用的外设,来减少电量的消耗。可以看到,随着计算机系统的发展和应用需求的变化,操作系统的调度功能也会有新的变化。 + +虽然各个实际操作系统的调度策略比较复杂,但其基本的设计思路是可以分析清楚的。接下来,我们将针对不同计算机系统特点,简化其中的应用执行过程,形成在该系统下应用执行的约束条件;并进一步分析其对应的性能指标,提出有针对性的设计思路,阐述各种可行的调度策略。 + + + +## 批处理系统的调度 + +在设计具体的调度策略之前,需要了解计算机系统和应用的运行环境,对应用的特点和它期望的性能指标也要清楚。我们先看看批处理系统下的应用的特点和约束条件。在批处理系统下,应用以科学计算为主,I/O操作较少,且I/O操作主要集中在应用开始和结束的一小段时间,应用的执行时间主要消耗在占用处理器进行计算上,且应用的大致执行时间一般可以预估到。 + +### 约束条件 + +批处理系统中的进程有如下一些约束/前提条件: + +1. 每个进程同时到达。 +2. 每个进程的执行时间相同。 +3. 进程的执行时间是已知的。 +4. 进程在整个执行过程期间很少执行I/O操作。 +5. 进程在执行过程中不会被抢占。 + +对于条件4,可理解为在操作系统调度过程中,可以忽略进程执行I/O操作的开销。我们这里设定的各种条件是可以调整的,即可以进一步简化或更加贴近实际情况,这样可以简化或加强对调度策略的设计。比如,我们可以把条件 2 改变一下: + +1. 每个进程的执行时间不同。 + +### 性能指标 + +我们还需给出性能指标,用于衡量,比较和评价不同的调度策略。对于批处理系统中的一般应用而言,可以只有一个性能指标:周转时间(turn around),即进程完成时间(completion)与进程到达时间(arrival)的差值: + +$$ +T_{\text{turn around}} = T_{\text{completion}} − T_{\text{arrival}} +$$ + +由于前提条件1 明确指出所有进程在同一时间到达,那么 $T_{\text{arrival}} = 0$ ,因此 $T_{\text{turn around}} = T_{\text{completion}}$ 。除了总的周转时间,我们还需要关注平均周转时间(average turnaround)这样的统计值: + +$$ +T_{\text{average turnaround}} = T_{\text{turn around}} / \text{number of ready processes} +$$ + +对于单个进程而言,平均周转时间是一个更值得关注的性能指标。 + +### 先来先服务 + +先来先服务(first-come first-severd,也称First-in first-out,先进先出)调度策略的基本思路就是按进程请求处理器的先后顺序来使用处理器。在具体实现上,操作系统首先会建立一个就绪调度队列(简称就绪队列)和一个等待队列(也称阻塞队列)。大致的调度过程如下: + +- 操作系统每次执行调度时,都是从就绪队列的队头取出一个进程来执行; +- 当一个应用被加载到内存,并创建对应的进程,设置进程为就绪进程,按进程到达的先后顺序,把进程放入就绪调度队列的队尾; +- 当正在运行的进程主动放弃处理器时,操作系统会把该进程放到就绪队列末尾,并从就绪队列头取出一个进程执行; +- 当正在运行的进程执行完毕时,操作系统会回收该进程所在资源,并从就绪队列头取出一个进程执行; +- 当正在运行的进程需要等待某个事件或资源时,操作系统会把该进程从就绪队列中移出,放到等待队列中(此时这个进程从就绪进程变成等待进程),并从就绪队列头取出下一个进程执行; +- 当等待进程所等待的某个事件出现或等待的资源得到满足时,操作系统会把该进程转为就绪进程,并会把该进程从等待队列中移出,并放到就绪队列末尾。 + +![先来先服务示意图](./fcome-fserverd.png) + +该调度策略的优点是简单,容易实现。对于满足1~5约束条件的执行环境,用这个调度策略的平均周转时间性能指标也很好。如果在一个在较长的时间段内,每个进程都能结束,那么公平性这个指标也是能得到保证的。 + +操作系统不会主动打断进程的运行。 + +### 最短作业优先 + +满足1~5的约束条件的执行环境太简化和理想化了,在实际系统中,每个应用的执行时间很可能不同,所以约束条件2“每个进程的执行时间相同”就不合适了。如果把约束条件2改为 "每个进程的执行时间不同",那么在采用先来先服务调度策略的系统中,可能就会出现短进程不得不等长进程结束后才能运行的现象,导致短进程的等待时间太长,且系统的平均周转时间也变长了。 + +假设有两个进程PA、PB,它们大致同时到达,但PA稍微快一点,进程PA执行时间为100,进程PB的执行时间为20。如果操作系统采用先来先服务的调度策略,进程的平均周转时间为: + +> (100+120)/2 = 110 + +但如果操作系统先调度进程PB,那么进程的平均周转时间为: + +> (20+120)/2 = 70 + +可以看到,如果采用先来先服务调度策略,执行时间短的进程(简称短进程)可被排在执行时间长的进程(长进程)后面,导致进程的平均周转时间变长。 + +为应对短进程不得不等长进程结束后才能运行的问题,我们可以想到一个调度的方法:优先让短进程执行。这就是最短作业优先(Shortest Job First,简称SJF)调度策略。其实上面让PB先执行的调度方法,就是采用了最短作业优先策略。 + +![最短作业优先示意图](./sjf1.png) + +在更新约束条件2的前提下,如果我们把平均周转时间作为唯一的性能指标,那么SJF是一个最优调度算法。这可以用数学方法进行证明。如果有同学感兴趣,可以试试。 + +![最短作业优先周转时间示意图](./sjf2.png) + +虽然SJF调度策略在理论上表现很不错,但在具体实现中,需要对处于就绪队列上的进程按执行时间进行排序,这会引入一定的调度执行开销。而且如果进一步放宽约束,贴近实际情况,SJF就会显现出它的不足。如果我们放宽约束条件1: + +1. 每个进程可以在不同时间到达。 + +那么可能会发生一种情况,当前正在运行的进程还需 k 执行时间才能完成,这时来了一个执行时间为 h 的进程,且 h < K ,但根据约束条件5,操作系统不能强制切换正在运行的进程。所以,在这种情况下,最短作业优先的含义就不是那么确切了,而且在理论上,SJF也就不是最优调度算法了。 + +例如,操作系统采用SJF调度策略(不支持抢占进程),有两个进程,PA在时间0到达,执行时间为100, PB在时间20到达,执行时间为20,那么周转时间为 + +> (100 -0) + (120-20) = 200 + +平均周转时间为 100 。 + +## 交互式系统的调度 + +交互式系统是指支持人机交互和各种I/O交互的计算机系统。可抢占任务执行的分时多任务操作系统对人机交互性和I/O及时响应更加友好,对进程特征的约束条件进一步放宽,进程的 **可抢占特性** 需要我们重新思考如何调度。 + +### 约束条件 + +交互式系统中的进程有如下一些约束/前提条件: + +1. 每个进程可不同时间到达。 +2. 每个进程的执行时间不同。 +3. 进程的执行时间是已知的。 +4. 进程在整个执行过程期间会执行I/O操作。 +5. 进程在执行过程中会被抢占。 + +相对于批处理操作系统,约束条件4发生了变化,这意味着在进程执行过程中,操作系统不能忽视其I/O操作。约束条件5也发生了改变,即进程可以被操作系统随时打断和抢占。 + +### 性能指标 + +操作系统支持任务/进程被抢占的一个重要目标是提高用户的交互性体验和减少I/O响应时间。用户希望计算机系统能及时响应他发出的I/O请求(如键盘、鼠标等),但平均周转时间这个性能指标不足以反映人机交互或I/O响应的性能。所以,我们需要定义新的性能指标 -- 响应时间(response time): + +$$ +T_{\text{response time}} = T_{\text{first execution}} - T_{\text{arrival}} +$$ + +而对应的平均响应时间(average response time)是: + +$$ +T_{\text{average response time}} = T_{\text{response time}} / \text{number of ready processes} +$$ + +例如,操作系统采用SJF调度策略(不支持抢占进程),有两个进程,PA在时间0到达,执行时间为100, PB在时间20到达,执行时间为20,那么PA的响应时间为0,PB为80,平均响应时间为 40 。 + +### 最短完成时间优先(STCF) + +由于约束条件5表示了操作系统允许抢占,那么我们就可以实现一种支持进程抢占的改进型SJF调度策略,即最短完成时间优先(Shortest Time to Complet First)调度策略。 + +基于前述的例子,操作系统采用STCF调度策略,有两个进程,PA在时间0到达,执行时间为100, PB在时间20到达,执行时间为20,那么周转时间为 + +> (120 - 0) + (40 - 20) = 140 + +平均周转时间为 70 。可以看到,如果采用STCF调度策略,相比于SJF调度策略,在周转时间这个性能指标上得到了改善。 + +但对于响应时间而言,可能就不这么好了。考虑一个例子,有两个用户发出了执行两个进程的请求,且两个进程大约同时到达,PA和PB的执行时间都为20。我们发现,无论操作系统采用FIFO/SJF/STCF中的哪一种调度策略,某一个用户不得不等待20个时间单位后,才能让他的进程开始执行,这是一个非常不好的交互体验。从性能指标上看,响应时间比较差。 +这就引入了新的问题:操作系统如何支持看重响应时间这一指标的应用程序? + +### 基于时间片的轮转 + +如果操作系统分给每个运行的进程的运行时间是一个足够小的时间片(time slice,quantum),时间片一到,就抢占当前进程并切换到另外一个进程执行。这样进程以时间片为单位轮流占用处理器执行。对于交互式进程而言,就有比较大的机会在较短的时间内执行,从而有助于减少响应时间。这种调度策略称为轮转(Round-Robin,简称RR)调度,即基本思路就是从就绪队列头取出一个进程,让它运行一个时间片,然后把它放回到队列尾,再从队列头取下一个进程执行,周而复始。 + +![基于时间片的轮转原理示意图](./round-robin-theory.png) + +在具体实现上,需要考虑时间片的大小,一般时间片的大小会设置为时钟中断的时间间隔的整数倍。比如,时钟中断间隔为1ms,时间片可设置为10ms,两个用户发出了执行两个进程的请求,且两个进程大约同时到达,PA和PB的执行时间都为20s(即20,000ms)。如果采用轮转调度,那么进程的响应时间为: + +> 0+10 = 10ms + +平均响应时间为: + +> (0+10)/2 = 5ms + +这两个值都远小于采用之前介绍的三种调度策略的结果。 这看起来不错,而且,直观上可以进一步发现,如果我们进一步减少时间片的大小,那么采用轮转调度策略会得到更好的响应时间。但其实这是有潜在问题的,因为每次进程切换是有切换代价的,参考之前介绍的进程切换的实现,可以看到,进程切换涉及多个寄存器的保存和回复操作,页表的切换操作等。如果进程切换的时间开销是0.5ms,时间片设置为1ms,那么会有大约50%的时间用于进程切换,这样进程实际的整体执行时间就大大减少了。所以,我们需要通过在响应时间和进程切换开销之间进行权衡。不能把时间片设置得太小,且让响应时间在用户可以接受的范围内。 + +看来轮转调度对于响应时间这个指标很友好。但如果用户也要考虑周转时间这个指标,那轮转调度就变得不行了。还是上面的例子,我们可以看到,PA和PB两个进程几乎都在40s左右才结束,这意味着平均周转时间为: + +> (40+40)/2 = 40s + +这大于基于SJF的平均周转时间: + +> ((20-0) + (40-0))/2 = 30s + +如果活跃进程的数量增加,我们会发现轮转调度的平均周转时间会进一步加强。也许有同学会说,那我们可以通过调整时间片,把时间片拉长,这样就会减少平均周转时间了。但这样又会把响应时间也给增大了。而且如果把时间片无限拉长,轮转调度就变成了FCFS调度了。 + +到目前为止,我们看到以SJF为代表的调度策略对周转时间这个性能指标很友好,而以轮转调度为代表的调度策略对响应时间这个性能指标很友好。但鱼和熊掌难以兼得。 + +## 通用计算机系统的调度 + +个人计算机和互联网的发展推动了计算机的广泛使用,并出现了新的特点,内存越来越大,各种I/O设备成为计算机系统的基本配置,一般用户经常和随时使用交互式应用(如字处理、上网等),驻留在内存中的应用越来越多,应用的启动时间和执行时间无法提前知道。而且很多情况下,处理器大部分时间处于空闲状态,在等待用户或其它各种外设的输入输出操作。 + +### 约束条件 + +这样,我们的约束条件也随之发生了新的变化: + +1. 每个进程可不同时间到达。 +2. 每个进程的执行时间不同。 +3. 进程的启动时间和执行时间是未知的。 +4. 进程在整个执行过程期间会执行I/O操作。 +5. 进程在执行过程中会被抢占。 + +可以看到,其中的第3点改变了,导致进程的特点也发生了变化。有些进程为I/O密集型的进程,大多数时间用于等待外设I/O操作的完成,需要进程能及时响应。有些进程是CPU密集型的,大部分时间占用处理器进行各种计算,不需要及时响应。还有一类混合型特点的进程,它在不同的执行阶段有I/O密集型或CPU密集型的特点。这使得我们的调度策略需要能够根据进程的动态运行状态进行调整,以应对各种复杂的情况。 + +### 性能指标 + +如果把各个进程运行时间的公平性考虑也作为性能指标,那么我们就需要定义何为公平。我们先给出一个公平的描述性定义:在一个时间段内,操作系统对每个个处于就绪状态的进程均匀分配占用处理器的时间。 + +这里需要注意,为了提高一个性能指标,可能会以牺牲其他性能指标作为代价。所以,调度策略需要综合考虑和权衡各个性能指标。在其中找到一个折衷或者平衡。 + +### 多级反馈队列调度 + +在无法提前知道进程执行时间的前提下,如何设计一个能同时减少响应时间和周转时间的调度策略是一个挑战。不过计算机科学家早就对此进行深入分析并提出了了解决方案。在1962年,MIT的计算机系教授Fernando Jose Corbato(1990年图灵奖获得者)首次提出多级反馈队列(Multi-level Feedback Queue,简称MLFQ)调度策略,并用于当时的CTSS(兼容时分共享系统)操作系统中。 + + + +Corbato教授的思路很巧妙,用四个字来总结,就是 **以史为鉴** 。即根据进程过去一段的执行特征来预测其未来一段时间的执行情况,并以此假设为依据来动态设置进程的优先级,调度子系统选择优先级最高的进程执行。这里可以看出,进程有了优先级的属性,而且进程的优先级是可以根据过去行为的反馈来动态调整的,不同优先级的进程位于不同的就绪队列中。 + +接下来,我们逐步深入分析多级反馈队列调度的设计思想。 + +#### 固定优先级的多级无反馈队列 + +MLFQ调度策略的关键在于如何设置优先级。一旦设置好进程的优先级,MLFQ总是优先执行位于高优先级就绪队列中的进程。对于挂在同一优先级就绪队列中的进程,采用轮转调度策略。 + +先考虑简单情况下,如果我们提前知道某些进程是I/O密集型的,某些进程是CPU密集型的,那么我们可以给I/O密集型设置高优先级,而CPU密集型进程设置低优先级。这样就绪队列就变成了两个,一个包含I/O密集型进程的高优先级队列,一个是处理器密集型的低优先级队列。 + +那我们如何调度呢?MLFQ调度策略是先查看高优先级队列中是否有就绪进程,如果有,就执行它,然后基于时间片进行轮转。由于位于此高优先级队列中的进程都是I/O密集型进程,所以它们很快就会处于阻塞状态,等待I/O设备的操作完成,这就会导致高优先级队列中没有就绪进程。 + +在高优先级队列没有就绪进程的情况下,MLFQ调度策略就会从低优先级队列中选择CPU密集型就绪进程,同样按照时间片轮转的方式进行调度。如果在CPU密集型进程执行过程中,某个I/O密集型进程所等待的I/O设备的操作完成了,那么操作系统会打断CPU密集型进程的执行,以及时响应该中断,并让此I/O密集型进程从阻塞状态变成就绪态,重新接入到高优先级队列的尾部。这时调度子系统会优先选择高优先级队列中的进程执行,从而抢占了CPU密集型进程的执行。 + +这样,我们就得到了MLFQ的基本设计规则: + +1. 如果进程PA的优先级 > PB的优先级,抢占并运行PA。 +2. 如果进程PA的优先级 = PB的优先级,轮转运行PA和PB。 + +但还是有些假设过于简单化了,比如: + +1. 通常情况下,操作系统并不能提前知道进程是I/O密集型还是CPU密集型的。 +2. I/O密集型进程的密集程度不一定一样,所以把它们放在一个高优先级队列中体现不出差异。 +3. 进程在不同的执行阶段会有不同的特征,可能前一阶段是I/O密集型,后一阶段又变成了CPU密集型。 + +而在进程执行过程中固定进程的优先级,将难以应对上述情况。 + +#### 可降低优先级的多级反馈队列 + +改进的MLFQ调度策略需要感知进程的过去执行特征,并根据这种特征来预测进程的未来特征。简单地说,就是如果进程在过去一段时间是I/O密集型特征,就调高进程的优先级;如果进程在过去一段时间是CPU密集型特征,就降低进程的优先级。 +由于会动态调整进程的优先级,所以,操作系统首先需要以优先级的数量来建立多个队列。当然这个数量是一个经验值,比如Linux操作系统设置了140个优先级。 + +那如何动态调整进程的优先级呢?首先,我们假设新创建的进程是I/O密集型的,可以把它设置为最高优先级。接下来根据它的执行表现来调整其优先级。如果在分配给它的时间配额内,它睡眠或等待I/O事件完成而主动放弃了处理器,操作系统预测它接下来的时间配额阶段很大可能还是具有I/O密集型特征,所以就保持其优先级不变。如果进程用完了分配给它的时间配额,操作系统预测它接下来有很大可能还是具有CPU密集型特征,就会降低其优先级。 +这里的时间配额的具体值是一个经验值,一般是时间片的整数倍。 + +这样,如果一个进程的执行时间小于分配给它的一个或几个时间配额,我们把这样的进程称为短进程。那么这个短进程会以比较高的优先级迅速地结束。而如果一个进程有大量的I/O操作,那么一般情况下,它会在时间配额结束前主动放弃处理器,进入等待状态,一旦被唤醒,会以原有的高优先级继续执行。如果一个进程的执行时间远大于几个时间配额,我们把这样的进程称为长进程。那么这个长进程经过一段时间后,会处于优先级最底部的队列,只有在没有高优先级进程就绪的情况下,它才会继续执行,从而不会影响交互式进程的响应时间。 + +这样,我们进一步扩展了MLFQ的基本规则: + +1. 创建进程并让进程首次进入就绪队列时,设置进程的优先级为最高优先级。 +2. 进程用完其时间配额后,就会降低其优先级。 + +虽然这样的调度看起来对短进程、I/O密集型进程或长进程的支持都还不错。但这样的调度只有降低优先级的操作,对于某些情况还是会应对不足。比如: + +1. 一个进程先执行了一段比较长时间的CPU密集型任务,导致它到了底部优先级队列,然后它在下一阶段执行I/O密集型任务,但被其他高优先级任务阻挡了,难以减少响应时间。 +2. 在计算机系统中有大量的交互型进程,虽然每个进程执行时间短,但它们还是会持续地占用处理器,追导致位于低优先级的长进程一直无法执行,出现饥饿(starvation)现象。 + +这主要是调度策略还缺少提升优先级的灵活规则。 + +#### 可提升/降低优先级的多级反馈队列 + +对于可降低优先级的多级反馈队列调度策略难以解决的上述情况1和2,我们需要考虑如何提升某些进程的优先级。一个可以简单实现的优化思路是,每过一段时间,周期性地把所有进程的优先级都设置为最高优先级。这样长进程不会饿死;而被降到最低优先级的进程,如果当前处于I/O密集型任务,至少在一段时间后,会重新减少其响应时间。不过这个“一段时间”的具体值如何设置?看起来又是一个经验值。这样,我们又扩展了MLFQ的基本规则。 + +1. 经过一段时间,把所有就绪进程重新加入最高优先级队列。 + +但这样就彻底解决问题了吗?其实还不够,比如对于优先级低且处于I/O密集型任务的进程,必须等待一段时间后,才能重新加入到最高优先级,才能减少响应时间。难道这样的进程不能不用等待一段时间吗? + +而对于长进程,如果有不少长进程位于最低优先级,一下子把它们都提升为最高优先级,就可能影响本来处于最高优先级的交互式进程的响应时间。看来,第5条规则还有进一步改进的空间,提升优先级的方法可以更灵活一些。 + +先看长进程,可以发现,所谓长进程“饥饿”,是指它有很长时间没有得到执行了。如果我们能够统计其在就绪态没有被执行的等待时间长度,就可以基于这个动态变量来逐步提升其优先级。比如每过一段时间,查看就绪进程的等待时间(进程在就绪态的等待时间)长度,让其等待时间长度与其优先级成反比,从而能够逐步第动态提升长进程的优先级。 + +再看优先级低且处于I/O密集型任务的进程,可以发现,它也有很长时间没有得到执行的特点,这可以通过上面的逐步提升优先级的方法获得执行的机会,并在执行I/O操作并处于等待状态,但此时的优先级还不够高。但操作系统在I/O操作完成的中断处理过程中,统计其I/O等待时间(进程在阻塞态下的等待时间),该进程的I/O等待时间越长,那么其优先级的提升度就越高,这可以使其尽快到达最高优先级。 + +这样根据就绪等待时间和阻塞等待时间来提升进程的优先级,可以比较好第应对上面的问题。我们可以改进第5条规则: + +1. 定期统计进程在就绪态/阻塞态的等待时间,等待时间越长,其优先级的提升度就越高。 + +对于就绪态等待时间对应的优先级提升度一般时小于阻塞态等待时间对应的优先级提升度,从而让调度策略优先调度当前具有I/O密集型任务的进程。 + +经过我们总结出来的MLFQ调度规则,使得操作系统不需要对进程的运行方式有先验知识,而是通过观测和统计进程的运行特征来给出对应的优先级,使得操作系统能灵活支持各种运行特征的应用在计算机系统中高效执行。 + +### 公平份额调度 + +在大公司的数据中心中有着大量的计算机服务器,给互联网上的人们提供各种各样的服务。在这样的服务器中,有着相对个人计算机而言更加巨大的内存和强大的计算处理能力,给不同用户提供服务的各种进程的数量也越来越多。这个时候,面向用户或进程相对的公平性就是不得不考虑的一个问题,甚至时要优先考虑的性能指标。比如,在提供云主机的数据中心中,用户可能会希望分配20%的处理器时间给Windows虚拟机,80%的处理器时间给Linux系统,如果采用公平份额调度的方式可以更简单高效。 + +从某种程度上看,MLFQ调度策略总提到的优先级就是对公平性的一种划分方式,有些进程优先级高,会更快地得到处理器执行,所分配到的处理器时间也多一些。但MLFQ并不是把公平性放在第一位。如果把公平性放在首位,我们就可以设计出另外一类调度策略 -- 公平份额(Fair Share,又称为 比例份额,Proportional Share)调度。其基本思路是基于每个进程的重要性(即优先级)的比例关系,分配给该进程同比例的处理器执行时间。 + + + +在1993~1994年,MIT的计算机系博士生Carl A. Waldspurger 和他的导师 William E. Weihl提出了与众不同的调度策略:彩票调度(Lottery Scheduling)和步长调度(Stride Scheduling)。它们都属于公平份额调度策略。彩票调度很有意思,它是从经济学的的彩票行为中吸取营养,模拟了购买彩票和中奖的随机性,给每个进程发彩票,进程优先级越高,所得到的彩票就越多;然后每隔一段时间(如,一个时间片),举行一次彩票抽奖,抽出来的号属于哪个进程,哪个进程就能运行。 + +例如,计算机系统中有两个进程PA和PB,优先级分别为2和8,这样它们分别拥有2张(编号为0-1)和8张彩票(编号为2-9),按照彩票调度策略,操作系统会分配PA大约20%的处理器时间,而PB会分配到大约80%的处理器时间。 + +其具体实现过程是,在每个时间片到时,操作系统就抽取彩票,由于操作系统已知总彩票数有10张,所以操作系统产生一个从0和9之间随机数作为获奖彩票号,拥有这个彩票号的进程中奖,并获得下一次处理器执行机会。通过在一段较长的时间内不断地抽彩票,基于统计学,可以保证两个两个进程可以获得与优先级等比例的处理器执行时间。 + +这个彩票调度的优势有两点,第一点是可以解决饥饿问题,即使某个低优先级进程获得的彩票比较少,但经过比较长的时间,按照概率,会有获得处理器执行的时间片。第二点是调度策略的实现开销小,因为它不像之前的调度策略,还需要记录、统计、排序、查找历史信息(如统计就绪态等待时间等),彩票调度几乎不需要记录任何历史信息,只需生产一个随机数,然后查找该随机数应该属于那个进程即可。 + +但彩票调度虽然想法新颖,但有一个问题:如何为进程分配彩票?如果创建进程的用户清楚进程的优先级,并给进程分配对应比例的彩票,那么看起来这个问题就解决了。但彩票调度是在运行时的某个时刻产生一个随机值,并看这个随机值属于当前正在运行中的进程集合中的哪一个进程。而用户无法预知,未来的这个时刻,他创建的进程与当时的那些进程之间的优先级相对关系,这会导致公平性不一定能得到保证。 + +另外一个问题是,基于概率的操作方法的随机性会带来不确定性,特别是在一个比较短的时间段里面,进程间的优先级比例关系与它们获得的处理器执行时间的比例关系之间有比较大的偏差,只有在执行时间很长的情况下,它们得到的处理器执行时间比例会比较接近优先级比例。 + +#### NOTE + +能否用彩票来表示各种计算机资源的份额? + +彩票调度中的彩票表示了进程所占处理器时间的相对比例,那么能否用彩票来表示进程占用内存或其他资源的相对比例? + +为了解决彩票调度策略中的偶然出现不准确的进程执行时间比例的问题。Waldspurger等进一步提出了步长调度(Stride Scheduling)。这是一个确定性的公平配额调度策略。其基本思路是:每个进程有一个步长(Stride)属性值,这个值与进程优先级成反比,操作系统会定期记录每个进程的总步长,即行程(pass),并选择拥有最小行程值的进程运行。 + +例如,计算机系统中有两个进程PA和PB几乎同时到达,优先级分别为2和8,用一个预设的大整数(如1000)去除以优先级,就可获得对应的步长,这样它们的步长分别是500和125在具体执行时,先选择PA执行,它在执行了一个时间片后,其行程为500;在接下来的4个时间片,将选择执行行程少的PB执行,它在连续执行执行4个时间片后,其形成也达到了500;并这样周而复始地执行下去,直到进程执行结束。,按照步长调度调度策略,操作系统会分配PA大约20%的处理器时间,而PB会分配到大约80%的处理器时间。 + +比较一下这两种调度策略,可以看出彩票调度算法只能在一段比较长的时间后,基于概率上实现优先级等比的时间分配,而步长调度算法可以在每个调度周期后做到准确的优先级等比的时间分配。但彩票算法的优势是几乎不需要全局信息,这在合理处理新加入的进程时很精炼。比如一个新进程开始执行时,按照步长调度策略,其行程值为0,那么该进程将在一段比较长的时间内一直占用处理器执行,这就有点不公平了。如果要设置一个合理的进程值,就需要全局地统计每个进程的行程值,这就带来了比较大的执行开销。但彩票调度策略不需要统计每个进程的彩票数,只需用新进程的票数更新全局的总票数即可。 + +## 实时计算机系统的调度 + +计算机系统的应用领域非常广泛,如机器人、物联网、军事、工业控制等。在这些领域中,要求计算机系统能够实时响应,如果采用上述调度方式,不能满足这些需求,这对操作系统提出了新的挑战。 + +这里,我们首先需要理解实时的含义。实时计算机系统通常可以分为硬实时(Hard Real Time)和软实时(Soft Real Time)两类,硬实时是指任务完成时间必须在绝对的截止时间内,如果超过意味着错误和失败,可能导致严重后果。软实时是指任务完成时间尽量在绝对的截止时间内,偶尔超过可以接受。 + +实时的任务是由一组进程来实现,其中每个进程的行为是可预测和提前确定的。这些进程称为实时进程,它们的执行时间一般较短。支持实时任务的操作系统称为实时操作系统。 + +![实时计算机系统的调度示意图](./real-time-schedule.png) + +### 约束条件 + +实时计算机系统是一种以确定的时间范围起到主导作用的计算机系统,一旦外设发给计算机一个事件(如时钟中断、网络包到达等),计算机必须在一个确定时间范围内做出响应。 + +实时计算机系统中的事件可以按照响应方式进一步分类为周期性(以规则的时间间隔发生)事件或非周期性(发生时间不可预知)事件。一个系统可能要响应多个周期性事件流。根据每个事件需要处理时间的长短,系统甚至有可能无法处理完所有的事件。 + +这样,实时计算机系统的约束条件也随之发生了新的变化: + +1. 每个进程可不同时间到达。 +2. 每个进程的执行时间不同。 +3. 进程的启动时间和执行时间是未知的。 +4. 进程在整个执行过程期间会执行I/O操作。 +5. 进程在执行过程中会被抢占。 +6. 进程的行为是可预测和提前确定的,即进程在独占处理器的情况下,执行时间的上限是可以提前确定的。 +7. 触发进程运行的事件需要进程实时响应,即进程要在指定的绝对截止时间内完成对各种事件的处理。 + +这里主要增加了第6和7点。第6点说明了实时进程的特点,第7点说明了操作系统调度的特点。 + +### 性能指标 + +对于实时计算机系统而言,进程的周转时间快和响应时间低这样的性能指标并不是最主要的,进程要在指定的绝对的截止时间内完成是第一要务。这里首先需要理解实时计算机系统的可调度性。如果有m个周期事件,事件i以周期时间Pi 发生,并需要Ci 时间处理一个事件,那么计算机系统可以处理任务量(也称负载)的条件是: + +> SUM(Ci/Pi) <= 1 + +能满足这个条件的实时计算机系统是可实时调度的。 + +满足这个条件的实时系统称为是可调度的。例如,一个具有两个周期性事件的计算机系统,其事件周期分别是20ms、80ms。如果这些事件分别需要10ms、20ms来进行处理,那么该计算机系统是可实时调度的,因为 + +> (10/20)+ (20/80) = 0.75 < 1 + +如果再增加第三个周期事件,其周期是100ms,需要50ms的时间来处理,我们可以看到: + +> (10/20)+ (20/80) + (50/100) = 1.25 > 1 + +这说明该计算机系统是不可实时调度的。 + +实时计算机系统的调度策略/算法可以是静态或动态的。静态调度在进程开始运行之前就作出调度决策;而动态调度要在运行过程中进行调度决策。只有在预知进程要所完成的工作时间上限以及必须满足的截止时间等全部信息时,静态调度才能工作;而动态调度则不需要这些前提条件。 + +### 速率单调调度 + + + +速率单调调度(Rate Monotonic Scheduling,RMS)算法是由刘炯朗(Chung Laung Liu)教授和James W. Layland在1973年提出的。该算法的基本思想是根据进程响应事件的执行周期的长短来设定进程的优先级,即执行周期越短的进程优先级越高。操作系统在调度过程中,选择优先级最高的就绪进程执行,高优先级的进程会抢占低优先级的进程。 + +![速率单调调度示意图](./rms.png) + +该调度算法有如下的前提假设: + +1. 每个周期性进程必须在其执行周期内完成,以完成对周期性事件的响应。 +2. 进程执行不依赖于任何其他进程。 +3. 进程的优先级在执行前就被确定,执行期间不变。 +4. 进程可被抢占。 + +可以看出,RMS调度算法在每个进程执行前就分配给进程一个固定的优先级,优先级等比于进程所响应的事件发生的周期频率,即进程优先级与进程执行的速率(单位时间内运行进程的次数)成线性关系,这正是为什么将其称为速率单调的原因。例如,必须每20ms运行一次(每秒要执行50次)的进程的优先级为50,必须每50ms运行一次(每秒20次)的进程的优先级为20。Liu和Layland证明了在静态实时调度算法中,RMS是最优的。 + + + +任务执行中间既不接收新的进程,也不进行优先级的调整或进行CPU抢占。因此这种算法的优点是系统消耗小,缺点是不灵活。一旦该系统的任务决定了,就不能再接收新的任务。 + +采用抢占的、静态优先级的策略,调度周期性任务。 + + + +### EDF调度 + +另一个典型的实时调度算法是最早截止时间优先(Earliest Deadline First,EDF)算法,其基本思想是根据进程的截止时间来确定任务的优先级。截止时间越早,其优先级就越高。如果进程的截止期相同,则处理时间短的进程优先级高。操作系统在调度过程中,选择优先级最高的就绪进程执行,高优先级的进程会抢占低优先级的进程。 + +![EDF调度示意图](./edf1.png) + +该调度算法有如下的前提假设: + +1. 进程可以是周期性或非周期性的。 +2. 进程执行不依赖于任何其他进程。 +3. 进程的优先级在执行过程中会基于进程的截止期动态变化。 +4. 进程可被抢占。 + +![EDF调度抢占示意图](./edf2.png) + +EDF调度算法按照进程的截止时间的早晚来分配优先级,截止时间越近的进程优先级越高。操作系统在进行进程调度时,会根据各个进程的截止时间重新计算进程优先级,并选择优先级最高的进程执行,即操作系统总是优先运行最紧迫的进程。在不同时刻,两个周期性进程的截止时间的早晚关系可能会变化,所以EDF调度算法是一种动态优先级调度算法。 + +### 实时调度实例 + +系统中有三个周期性进程PA、PB和PC,它们在一开始就处于就绪状态,它们的执行周期分别是20ms、50ms和100ms,它们响应事件的处理时间分别为5ms、20ms和25ms。操作系统需要考虑如何调度PA、PB和PC,以确保它们在周期性的截止时间(最终时限,即当前执行周期的绝对时间)到来前都能完成各自的任务。 + +我们先分析一下系统的可调度性: + +> (5/20) + (20/50) + (25/100)= 0.25+0.4 + 0.25 = 0.9 < 1 + +可以看到处理器在理论上有10%的空闲时间,不会被超额执行,所以找到一个合理的调度应该是可能的。我们首先看看RMS调度算法,由于进程的优先级只与进程的执行周期成线性关系,所以三个进程的优先级分别为50、20和10。对于RMS调度算法而言,具有如下的调度执行过程: + + +- t=0:在t=0时刻,优先级最高的PA先执行(PA的第一个周期开始),并在5ms时完成; +- t=5:在PA完成后,PB接着执行; +- t=20:在执行到20ms时(PA的第二个周期开始),PA抢占PB并再次执行,直到25m时结束; +- t=25:然后被打断的PB继续执行,直到30ms时结束; +- t=30:接着PC开始执行(PC的第一个周期开始); +- t=40:在执行到40ms时(PA的第三个周期开始),PA抢占PC并再次执行,直到45ms结束; +- t=45:然后被打断的PC继续执行; +- t=50:然后在50ms时(PB的第二个周期),PB抢占PC并再次执行; +- t=60:然后在60ms时(PA的第四个周期开始),PA抢占PB并再次执行,直到65ms时结束; +- t=65:接着PB继续执行,并在80ms时结束; +- t=80:接着PA继续抢占PC(PA的第五个周期开始),在85ms时结束; +- t=85:然后PC再次执行,在90ms时结束。 + +这样,在100ms的时间内,PA执行了5个周期任务,PB执行了2个周期任务,PC执行了1个周期任务。在下一个100ms的时间内,上述过程再次重复。 + +对于EDF调度算法而言,具有如下的调度执行过程: + +- t=0:首先选择截止时间最短的PA,所以它先执行(PA的第一个周期开始),并在5ms时完成; +- t=5:在PA完成后,截止时间第二的PB接着执行; +- t=20:在执行到20ms时(PA的第二个周期开始),PA截止时间40ms小于PB截止时间50ms,所以抢占PB并再次执行,直到25m时结束; +- t=25:然后被打断的PB继续执行,直到30ms时结束; +- t=30:接着PC开始执行(PC的第一个周期开始); +- t=40:在执行到40ms时(PA的第三个周期开始),PA截止时间40ms小于PC截止时间100ms,PA抢占PC并再次执行,直到45ms结束; +- t=45:然后被打断的PC继续执行; +- t=50:然后在50ms时(PB的第二个周期),PB截止时间100ms小于等于PC截止时间100ms,PB抢占PC并再次执行; +- t=60:然后在60ms时(PA的第四个周期开始),PA截止时间80ms小于PB截止时间100ms,PA抢占PB并再次执行,直到65ms时结束; +- t=65:接着PB继续执行,并在80ms时结束; +- t=80:接着PA截止时间100ms小于等于PC截止时间100ms,PA继续抢占PC(PA的第五个周期开始),在85ms时结束; +- t=85:然后PC再次执行,在90ms时结束。 + +上述例子的一个有趣的现象是,虽然RMS调度算法与EDF的调度策略不同,但它们的调度过程是一样的。注意,这不是普遍现象,也有一些例子会出现二者调度过程不同的情况,甚至RMS调度无法满足进程的时限要求,而EDF能满足进程的时限要求。同学们能举出这样的例子吗? + +## 多处理器计算机系统的调度 + +在2000年前,多处理器计算机的典型代表是少见的高端服务器和超级计算机,但到了2000年后,单靠提高处理器的频率越来越困难,而芯片的集成度还在进一步提升,所以在一个芯片上集成多个处理器核成为一种自然的选择。到目前位置,在个人计算机、以手机为代表的移动终端上,多核处理器(Multi Core)已成为一种普遍的现象,多个处理器核能够并行执行,且可以共享Cache和内存。 + +![多处理器计算机系统的调度示意图](./multi-core-schedule.png) + +之前提到的调度策略/算法都是面向单处理器的,如果把这些策略和算法扩展到多处理器环境下,是否需要解决新问题? + +#### NOTE + +并行处理需要了解更多的硬件并行架构问题和软件的同步互斥等技术,而深入的硬件并行架构目前不在本书的范畴之内,同步互斥等技术在后续章节才介绍。按道理需要先学习这些内容才能真正和深入理解本小节的内容,但本小节的内容在逻辑上都属于进程调度的范畴,所以就放在这里了。建议可以先大致学习本小节内容,在掌握了进程间通信、同步互斥等技术后,再回头重新学习一些本小节内容。 + +### 约束条件 + +为了理解多处理器调度需要解决的新问题,我们需要理解单处理器计算机与多处理器计算机的基本区别。对于多处理器计算机而言,每个处理器核心会有共享的Cache,也会有它们私有的Cache,而各自的私有Cache中的数据有硬件来保证数据的Cache一致性(也称缓存一致性)。 + + + +简单地说,位于不同私有Cache中的有效数据(是某一内存单元的值)要保证是相同的,这样处理器才能取得正确的数据,保证计算的正确性,这就是Cache一致性的基本含义。保证一致性的控制逻辑是由硬件来完成的,对操作系统和应用程序而言,是透明的。 + +在共享Cache和内存层面,由于多个处理器可以并行访问位于共享Cache和内存中的共享数据,所以需要有后面章节讲解的同步互斥机制来保证程序执行的正确性。这里,我们仅仅介绍一下简单的思路。 + +以给创建的新子进程设置进程号为例。在单处理器情况下,操作系统用一个整型全局变量保存当前可用进程号,初始值为 0 。给新进程设置新进程号的过程很简单: + +1. 新进程号= 当前可用进程号; +2. 当前可用进程号 = 当前可用进程号 + 1; + +在多处理器情况下,假设两个位于不同处理器上的进程都发起了创建子进程的系统调用请求,操作系统可以并行地执行创建两个子进程,而且需要给子进程设置一个新的进程号。如果没有一些同步互斥的手段,那么可能出现如下的情况: + +t0: ID-PA = CurID ID-PB= CurID +t1: CurID = CurID+1 CurID = CurID +1 + +这样两个新进程的进程号就是一样的了,这就会在后续的执行中出现各种问题。为了正确处理共享变量,就需要用类似互斥锁(Mutex)的方法,让在不同处理器上执行的控制流互斥地访问共享变量,这样就能解决正确性问题。 + +所以,对于多处理器下运行的进程而言,新增加了如下的假设条件: + +1. 运行在不同处理器上的多个进程可用并行执行,但对于共享资源/变量的处理,需要有同步互斥等机制的正确性保证。 + +### 性能指标 + +这里的性能指标与之前描述的基于单处理器的通用计算机系统一样,主要是周转时间、响应时间和公平性。 + +### 单队列调度 + +对于多处理器系统而言,两个进程数量多于处理器个数,我们希望每个处理器都执行进程。这一点是之前单处理器调度不会碰到的情况。单处理器的调度只需不断地解答:“接下来应该运行哪个进程?”,而在多处理机中,调度还需解答一个问题:“要运行的进程在哪一个CPU上运行?”。这就增加了调度的复杂性。 + +如果我们直接使用单处理器调度的数据结构,其中的重点就是放置就绪进程的就绪队列或其他与调度相关的数据结构。那么这些数据结构就是需要互斥访问的共享数据。 为简化分析过程,我们以轮转调度采用的单就绪队列为例,面向多处理器的单队列调度的策略逻辑没有改变,只是在读写/修改就绪队列等共享数据时,需要用同步互斥的一些操作保护起来,确保对这些共享数据访问的正确性。 + +![单队列调度示意图](./queue-schedule.png) + +采用单队列调度的一个好处是,它支持自动负载平衡,因为决不会出现一个CPU空闲而其他CPU过载的情况。 + +**处理器亲和性** + +另外,还需考虑的一个性能问题是调度中的处理器亲和性(也称缓存亲和性、调度亲和性)问题。其基本思想是,尽量使一个进程在它前一次运行过的同一个CPU上运行。其原因是,现代的处理器都有私有Cache,基于局部性的考虑,如果操作系统在下次调度时要给该进程选择处理器,会优先选择该进程上次执行所在的处理器,从而使得Cache中缓存的数据可重用,提高了进程执行的局部性。 + +### 多队列调度 + +如果处理器的个数较多,频繁对共享数据执行同步互斥操作的开销会很大。为此,能想到的一个方法是,还是保持单处理器调度策略的基本逻辑,但把就绪队列或和他与调度相关的数据结构按处理器个数复制多份,这样操作系统在绝大多数情况下,只需访问本处理器绑定的调度相关数据结构,就可用完成调度操作。这样在一个调度控制框架下就包含多个调度队列。当要把一个新进程或被唤醒的进程放入就绪队列时,操作系统可根据一些启发式方法(如随机选择某个处理器上的就绪队列或选择就绪进程数量最少的就绪队列)来放置进程到某个就绪队列。操作系统通过访问本处理器上的调度相关数据结构,就可以选择出要执行的进程,这样就避免了开销大的同步互斥操作。 + +多队列调度比单队列调度具有更好的可扩展性,多队列的数量会随着处理器的增加而增加,也具有良好的缓存亲和度。当多队列调度也有它自己的问题:负载均衡(Load Balance)问题。 + +![多队列调度示意图](./deque-schedule.png) + +考虑如下的例子,在一个有4个进程,两个处理器的计算机系统中,有两个就绪队列,PA和PB在就绪队列Q1,PC和PD在就绪队列Q2,如果采用基于轮转调度的多队列调度,那么两个处理器可以均匀地让4给进程分时使用处理器。这是一种理想的情况。如果进程PB结束,而调度不进行进一步的干预,那么就会出现PA独占处理器1,PC和PD分时共享处理器2。如果PA也结束了,而调度还不进行进一步的干预,那么(Load Imbalance)就会出现处理器1空闲,而处理器2繁忙的情况,这就是典型的负载不均衡(Load Imbalance)的现象了。这就没有达到轮转调度的意图。 + +所以多队列调度需要解决负载不均衡的问题。一个简单的思路就是允许进程根据处理器的负载情况从一个处理器迁移到另外一个处理器上。对于上面的例子,如果是处理器1空闲,处理器2繁忙的而情况,操作系统只需把处理器2上的进程分一半,迁移到处理器1即可。当如果是处理器1上运行了PA,处理器2上运行了PC和PD,这就需要统计每个进程的执行时间,根据进程的执行时间,让进程在两个处理器间不停的迁移,达到在一定时间段内,每个进程所占用的处理器时间大致相同,这就达到了轮转调度的意图,并达到了负载均衡。具体处理有多种方式,比如如下方式调度: + +> 处理器1: A A C A A C ... +> 处理器2: C D D C D D ... + +或者是: + +> 处理器1: A C A A C A ... +> 处理器2: C D D C D D ... + +当然,这个例子是一种简单的理想情况,实际的多处理器计算机系统中运行的进程行为会很复杂,除了并行执行,还有同步互斥执行、各种I/O操作等,这些都会对调度策略产生影响。 + +## 小结 + +本节对多种类型的计算机系统和不同类型的应用特征进行了分析,并给出了不同的性能指标,这些都是设计调度策略/算法的前提条件。我们给出了从简单到复杂的多种调度策略和算法,这些策略和方法相对于实际的操作系统而言,还很简单,不够实用,但其中的基本思想是一致的。如果同学们需要了解实际的操作系统调度策略和算法,建议阅读关于UNIX、Linux、Windows和各种RTOS等操作系统内核的书籍和文章,其中有关于这些操作系统的调度策略和算法的深入讲解。 diff --git a/ch5/book/5exercise.md b/ch5/book/5exercise.md new file mode 100644 index 00000000..16e0ba88 --- /dev/null +++ b/ch5/book/5exercise.md @@ -0,0 +1,251 @@ +# 练习 + +## 课后练习 + +### 编程题 + +1. \* 实现一个使用nice,fork,exec,spawn等与进程管理相关的系统调用的linux应用程序。 +2. \* 扩展操作系统内核,能够显示操作系统切换进程的过程。 +3. \* 请阅读下列代码,分析程序的输出 `A` 的数量:( 已知 `&&` 的优先级比 `||` 高) + > ```c + > int main() { + > fork() && fork() && fork() || fork() && fork() || fork() && fork(); + > printf("A"); + > return 0; + > } + > ``` + + > 如果给出一个 `&&` `||` 的序列,如何设计一个程序来得到答案? +4. \*\* 在本章操作系统中实现本章提出的某一种调度算法(RR调度除外)。 +5. \*\*\* 扩展操作系统内核,支持多核处理器。 +6. \*\*\* 扩展操作系统内核,支持在内核态响应并处理中断。 + +### 问答题 + +1. \* 如何查看Linux操作系统中的进程? +2. \* 简单描述一下进程的地址空间中有哪些数据和代码。 +3. \* 进程控制块保存哪些内容? +4. \* 进程上下文切换需要保存哪些内容? +5. \*\* fork 为什么需要在父进程和子进程提供不同的返回值? +6. \*\* fork + exec 的一个比较大的问题是 fork 之后的内存页/文件等资源完全没有使用就废弃了,针对这一点,有什么改进策略? +7. \*\* 其实使用了6的策略之后,fork + exec 所带来的无效资源的问题已经基本被解决了,但是近年来fork 还是在被不断的批判,那么到底是什么正在"杀死"fork?可以参考 [论文](https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf) 。 +8. \*\* 请阅读下列代码,并分析程序的输出,假定不发生运行错误,不考虑行缓冲,不考虑中断: + > ```c + > int main(){ + > int val = 2; + + > printf("%d", 0); + > int pid = fork(); + > if (pid == 0) { + > val++; + > printf("%d", val); + > } else { + > val--; + > printf("%d", val); + > wait(NULL); + > } + > val++; + > printf("%d", val); + > return 0; + > } + > ``` + + > 如果 fork() 之后主程序先运行,则结果如何?如果 fork() 之后 child 先运行,则结果如何? +9. \*\* 为什么子进程退出后需要父进程对它进行 wait,它才能被完全回收? +10. \*\* 有哪些可能的时机导致进程切换? +11. \*\* 请描述在本章操作系统中实现本章提出的某一种调度算法(RR调度除外)的简要实现步骤。 +12. \* 非抢占式的调度算法,以及抢占式的调度算法,他们的优点各是什么? +13. \*\* 假设我们简单的将进程分为两种:前台交互(要求短时延)、后台计算(计算量大)。下列进程/或进程组分别是前台还是后台?a) make 编译 linux; b) vim 光标移动; c) firefox 下载影片; d) 某游戏处理玩家点击鼠标开枪; e) 播放交响乐歌曲; f) 转码一个电影视频。除此以外,想想你日常应用程序的运行,它们哪些是前台,哪些是后台的? +14. \*\* RR 算法的时间片长短对系统性能指标有什么影响? +15. \*\* MLFQ 算法并不公平,恶意的用户程序可以愚弄 MLFQ 算法,大幅挤占其他进程的时间。(MLFQ 的规则:“如果一个进程,时间片用完了它还在执行用户计算,那么 MLFQ 下调它的优先级”)你能举出一个例子,使得你的用户程序能够挤占其他进程的时间吗? +16. \*\*\* 多核执行和调度引入了哪些新的问题和挑战? + +## 实验练习1 + +实验练习包括实践作业和问答作业两部分。实验练习1和实验练习2可以选一个完成。 + +### 实践作业 + +#### 进程创建 + +大家一定好奇过为啥进程创建要用 fork + execve 这么一个奇怪的系统调用,就不能直接搞一个新进程吗?思而不学则殆,我们就来试一试!这章的编程练习请大家实现一个完全 DIY 的系统调用 spawn,用以创建一个新进程。 + +spawn 系统调用定义( [标准spawn看这里](https://man7.org/linux/man-pages/man3/posix_spawn.3.html) ): + +```rust +fn sys_spawn(path: *const u8) -> isize +``` + +- syscall ID: 400 +- 功能:新建子进程,使其执行目标程序。 +- 说明:成功返回子进程id,否则返回 -1。 +- 可能的错误: + : - 无效的文件名。 + - 进程池满/内存不足等资源错误。 + +TIPS:虽然测例很简单,但提醒读者 spawn **不必** 像 fork 一样复制父进程的地址空间。 + +#### 实验要求 + +- 实现分支:ch5-lab +- 实验目录要求不变 +- 通过所有测例 + + 在 os 目录下 `make run TEST=1` 加载所有测例, `test_usertest` 打包了所有你需要通过的测例,你也可以通过修改这个文件调整本地测试的内容。 + +challenge: 支持多核。 + +### 问答作业 + +1. fork + exec 的一个比较大的问题是 fork 之后的内存页/文件等资源完全没有使用就废弃了,针对这一点,有什么改进策略? +2. [选做,不占分]其实使用了题(1)的策略之后,fork + exec 所带来的无效资源的问题已经基本被解决了,但是近年来 fork 还是在被不断的批判,那么到底是什么正在"杀死"fork?可以参考 [论文](https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf) 。 +3. 请阅读下列代码,并分析程序的输出,假定不发生运行错误,不考虑行缓冲: + ```c + int main(){ + int val = 2; + + printf("%d", 0); + int pid = fork(); + if (pid == 0) { + val++; + printf("%d", val); + } else { + val--; + printf("%d", val); + wait(NULL); + } + val++; + printf("%d", val); + return 0; + } + ``` + + 如果 fork() 之后主程序先运行,则结果如何?如果 fork() 之后 child 先运行,则结果如何? +4. 请阅读下列代码,分析程序的输出 `A` 的数量:( 已知 `&&` 的优先级比 `||` 高) + ```c + int main() { + fork() && fork() && fork() || fork() && fork() || fork() && fork(); + printf("A"); + return 0; + } + ``` + + [选做,不占分] 更进一步,如果给出一个 `&&` `||` 的序列,如何设计一个程序来得到答案? + +### 实验练习的提交报告要求 + +* 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) +* 完成问答问题 +* (optional) 你对本次实验设计及难度的看法。 + +## 实验练习2 + +### 实践作业 + +#### stride 调度算法 + +ch3 中我们实现的调度算法十分简单。现在我们要为我们的 os 实现一种带优先级的调度算法:stride 调度算法。 + +算法描述如下: + +1. 为每个进程设置一个当前 stride,表示该进程当前已经运行的“长度”。另外设置其对应的 pass 值(只与进程的优先权有关系),表示对应进程在调度后,stride 需要进行的累加值。 +2. 每次需要调度时,从当前 runnable 态的进程中选择 stride 最小的进程调度。对于获得调度的进程 P,将对应的 stride 加上其对应的步长 pass。 +3. 一个时间片后,回到上一步骤,重新调度当前 stride 最小的进程。 + +可以证明,如果令 P.pass = BigStride / P.priority 其中 P.priority 表示进程的优先权(大于 1),而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。证明过程我们在这里略去,有兴趣的同学可以在网上查找相关资料。 + +其他实验细节: + +- stride 调度要求进程优先级 $\geq 2$,所以设定进程优先级 $\leq 1$ 会导致错误。 +- 进程初始 stride 设置为 0 即可。 +- 进程初始优先级设置为 16。 + +为了实现该调度算法,内核还要增加 set_prio 系统调用 + +```rust +// syscall ID:140 +// 设置当前进程优先级为 prio +// 参数:prio 进程优先级,要求 prio >= 2 +// 返回值:如果输入合法则返回 prio,否则返回 -1 +fn sys_set_priority(prio: isize) -> isize; +``` + +tips: 可以使用优先级队列比较方便的实现 stride 算法,但是我们的实验不考察效率,所以手写一个简单粗暴的也完全没问题。 + +#### 实验要求 + +- 完成分支: ch3-lab +- 实验目录要求不变 +- 通过所有测例 + + lab3 有 3 类测例,在 os 目录下执行 `make run TEST=1` 检查基本 `sys_write` 安全检查的实现, `make run TEST=2` 检查 `set_priority` 语义的正确性, `make run TEST=3` 检查 stride 调度算法是否满足公平性要求, + 六个子程序运行的次数应该大致与其优先级呈正比,测试通过标准是 $\max{\frac{runtimes}{prio}}/ \min{\frac{runtimes}{prio}} < 1.5$. + +challenge: 实现多核,可以并行调度。 + +#### 实验约定 + +在第三章的测试中,我们对于内核有如下仅仅为了测试方便的要求,请调整你的内核代码来符合这些要求: + +- 用户栈大小必须为 4096,且按照 4096 字节对齐。这一规定可以在实验4开始删除,仅仅为通过 lab2 测例设置。 + +### 问答作业 + +stride 算法深入 + +> stride 算法原理非常简单,但是有一个比较大的问题。例如两个 pass = 10 的进程,使用 8bit 无符号整形储存 stride, p1.stride = 255, p2.stride = 250,在 p2 执行一个时间片后,理论上下一次应该 p1 执行。 + +> - 实际情况是轮到 p1 执行吗?为什么? + +> 我们之前要求进程优先级 >= 2 其实就是为了解决这个问题。可以证明,**在不考虑溢出的情况下**, 在进程优先级全部 >= 2 的情况下,如果严格按照算法执行,那么 STRIDE_MAX – STRIDE_MIN <= BigStride / 2。 + +> - 为什么?尝试简单说明(传达思想即可,不要求严格证明)。 + +> 已知以上结论,**考虑溢出的情况下**,我们可以通过设计 Stride 的比较接口,结合 BinaryHeap 的 pop 接口可以很容易的找到真正最小的 Stride。 + +> - 请补全如下 `partial_cmp` 函数(假设永远不会相等)。 + +> ```rust +> use core::cmp::Ordering; + +> struct Stride(u64); + +> impl PartialOrd for Stride { +> fn partial_cmp(&self, other: &Self) -> Option { +> // ... +> } +> } + +> impl PartialEq for Person { +> fn eq(&self, other: &Self) -> bool { +> false +> } +> } +> ``` + +> 例如使用 8 bits 存储 stride, BigStride = 255, 则: + +> - (125 < 255) == false +> - (129 < 255) == true + +### 实验练习的提交报告要求 + +- 简单总结与上次实验相比本次实验你增加的东西(控制在5行以内,不要贴代码)。 +- 完成问答问题。 +- (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) + +如果有兴趣进一步了解用户态线程实现的相关内容,可以尝试看看: + +- [user-multitask in rv64](https://github.com/chyyuu/os_kernel_lab/tree/v4-user-std-multitask) +- [绿色线程 in x86](https://github.com/cfsamson/example-greenthreads) +- [x86版绿色线程的设计实现](https://cfsamson.gitbook.io/green-threads-explained-in-200-lines-of-rust/) +- [用户级多线程的切换原理](https://blog.csdn.net/qq_31601743/article/details/97514081?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.control&dist_request_id=&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.control) diff --git a/ch5/book/6answer.md b/ch5/book/6answer.md new file mode 100644 index 00000000..9098339f --- /dev/null +++ b/ch5/book/6answer.md @@ -0,0 +1,151 @@ +# 练习参考答案 + +## 课后练习 + +### 编程题 + +1. \* 实现一个使用nice,fork,exec,spawn等与进程管理相关的系统调用的linux应用程序。 + + 参考实现: + ```c + #include + #include + #include + int main(void) + { + int childpid; + int i; + + if (fork() == 0){ + //child process + char * execv_str[] = {"echo", "child process, executed by execv",NULL}; + if (execv("/usr/bin/echo",execv_str) <0 ){ + perror("error on exec\n"); + exit(0); + } + }else{ + //parent process + wait(&childpid); + printf("parent process, execv done\n"); + } + return 0; + } + ``` +2. \* 扩展操作系统内核,能够显示操作系统切换进程的过程。 + > 体现调度的过程十分简单,只需要在调度器部分,在寻找或运行下一任务的函数中加入一些输出调试信息就可以看到效果了,但切换可能会比较频繁,因此输出会很多。 +3. \* 请阅读下列代码,分析程序的输出 `A` 的数量:( 已知 `&&` 的优先级比 `||` 高) + > ```c + > int main() { + > fork() && fork() && fork() || fork() && fork() || fork() && fork(); + > printf("A"); + > return 0; + > } + > ``` + + > 如果给出一个 `&&` `||` 的序列,如何设计一个程序来得到答案? + + > 22个。&&优先级高于||,根据fork子进程返回值为0父进程返回pid和逻辑运算符的短路现象(&&左边为F即短路,||左边为T即短路), + > 可以按||分割来进行判断,共1+1\*3+3\*2+3\*2\*2=22. + > > ```python + > > def count_fork(seq): + > > counts = [1] + [i.count("&&") + 1 for i in seq.split("||")] + > > total = sum([np.prod(counts[:i + 1]) for i in range(len(counts))]) + > > return total + > > ``` +4. \*\* 在本章操作系统中实现本章提出的某一种调度算法(RR调度除外)。 + > 先来先服务调度算法FCFS:它与 RR 调度的区别在于没有时钟中断导致的任务切换,其他细节上相似。 + > 因此基于已有的 RR 调度,删除对 Timer 中断的相关处理即可得到一个 FCFS 调度。 + + > 以 ucore 本章节代码为例,一种处理方式是将 trap.c 的 usertrap 函数中 case SupervisorTimer 部分的 yield(); 一句删除 + > 即可去掉 RR 特性,得到一个 FCFS 调度。 + + > 代码略。 +5. \*\*\* 扩展操作系统内核,支持多核处理器。 + > 题目编程内容过于复杂,不建议作为练习题。 +6. \*\*\* 扩展操作系统内核,支持在内核态响应并处理中断。 + > 题目编程内容过于复杂,不建议作为练习题。 + +### 问答题 + +1. \* 如何查看Linux操作系统中的进程? + > 使用ps命令,常用方法: + > ```bash + > $ ps aux + > ``` +2. \* 简单描述一下进程的地址空间中有哪些数据和代码。 + > 代码(text)段,数据(data)段: 已初始化的全局变量的内存映射,bss段:未初始化或默认初始化为0的全局变量,堆(heap),用户栈(stack),共享内存段 +3. \* 进程控制块保存哪些内容? + > 进程标识符、进程调度信息(进程状态,进程的优先级,进程调度所需的其它信息)、进程间通信信息、内存管理信息(基地址、页表或段表等存储空间结构)、进程所用资源( I/O 设备列表、打开文件列表等)、处理机信息(通用寄存器、指令计数器、用户的栈指针) +4. \* 进程上下文切换需要保存哪些内容? + > 页全局目录、部分寄存器、内核栈、当前运行位置 +5. \*\* fork 为什么需要在父进程和子进程提供不同的返回值? + > 可以根据返回值区分父子进程,明确进程之间的关系,方便用户为不同进程执行不同的操作。 +6. \*\* fork + exec 的一个比较大的问题是 fork 之后的内存页/文件等资源完全没有使用就废弃了,针对这一点,有什么改进策略? + > 采用COW(copy on write),或使用使⽤vfork等。 +7. \*\* 其实使用了6的策略之后,fork + exec 所带来的无效资源的问题已经基本被解决了,但是近年来fork 还是在被不断的批判,那么到底是什么正在"杀死"fork?可以参考 [论文](https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf) 。 + > fork 和其他的操作不正交,也就是 os 每增加一个功能,都要改 fork, 这导致新功能开发困难,设计受限.有些和硬件相关的甚至根本无法支持 fork. + + > fork 得到的父子进程可能产生共享资源的冲突; + + > 子进程继承父进程,如果父进程处理不当,子进程可以找到父进程的安全漏洞进而威胁父进程; + + > 还有比如 fork 必须要虚存, SAS 无法支持等等. +8. \*\* 请阅读下列代码,并分析程序的输出,假定不发生运行错误,不考虑行缓冲,不考虑中断: + > ```c + > int main(){ + > int val = 2; + + > printf("%d", 0); + > int pid = fork(); + > if (pid == 0) { + > val++; + > printf("%d", val); + > } else { + > val--; + > printf("%d", val); + > wait(NULL); + > } + > val++; + > printf("%d", val); + > return 0; + > } + > ``` + + > 如果 fork() 之后主程序先运行,则结果如何?如果 fork() 之后 child 先运行,则结果如何? + > > 01342 03412 +9. \*\* 为什么子进程退出后需要父进程对它进行 wait,它才能被完全回收? + > 当一个进程通过exit系统调用退出之后,它所占用的资源并不能够立即全部回收,需要由该进程的父进程通过wait收集该进程的返回状态并回收掉它所占据的全部资源,防止子进程变为僵尸进程造成内存泄漏。同时父进程通过wait可以获取子进程执行结果,判断运行是否达到预期,进行管理。 +10. \*\* 有哪些可能的时机导致进程切换? + > 进程主动放弃cpu:运行结束、调用yield/sleep等、运行发生异常中断 + + > 进程被动失去cpu:时间片用完、新进程到达、发生I/O中断等 +11. \*\* 请描述在本章操作系统中实现本章提出的某一种调度算法(RR调度除外)的简要实现步骤。 + > 可降低优先级的MLFQ:将manager的进程就绪队列变为数个,初始进程进入第一队列,调度器每次选择第一队列的队首进程执行,当一个进程用完时间片而未执行完,就在将它重新添加至就绪队列时添加到下一队列,直到进程位于底部队列。 +12. \* 非抢占式的调度算法,以及抢占式的调度算法,他们的优点各是什么? + > 非抢占式:中断响应性能好、进程执行连续,便于分析管理 + + > 抢占式:任务级响应时间最优,更能满足紧迫作业要求 +13. \*\* 假设我们简单的将进程分为两种:前台交互(要求短时延)、后台计算(计算量大)。下列进程/或进程组分别是前台还是后台?a) make 编译 linux; b) vim 光标移动; c) firefox 下载影片; d) 某游戏处理玩家点击鼠标开枪; e) 播放交响乐歌曲; f) 转码一个电影视频。除此以外,想想你日常应用程序的运行,它们哪些是前台,哪些是后台的? + > 前台:b,d,e + + > 后台:a,c,f +14. \*\* RR 算法的时间片长短对系统性能指标有什么影响? + > 时间片太大,可以让每个任务都在时间片内完成,但进程平均周转时间会比较长,极限情况下甚至退化为FCFS; + + > 时间片过小,反应迅速,响应时间会比较短,可以提高批量短任务的完成速度。但产生大量上下文切换开销,使进程的实际执行时间受到挤占。 + + > 因此需要在响应时间和进程切换开销之间进行权衡,合理设定时间片大小。 +15. \*\* MLFQ 算法并不公平,恶意的用户程序可以愚弄 MLFQ 算法,大幅挤占其他进程的时间。(MLFQ 的规则:“如果一个进程,时间片用完了它还在执行用户计算,那么 MLFQ 下调它的优先级”)你能举出一个例子,使得你的用户程序能够挤占其他进程的时间吗? + > 每次连续执行只进行大半个时间片长度即通过执行一个IO操作等让出cpu,这样优先级不会下降,仍能很快得到下一次调度。 +16. \*\*\* 多核执行和调度引入了哪些新的问题和挑战? + > 多处理机之间的负载不均问题:在调度时,如何保证每一个处理机的就绪队列保证优先级、性能指标的同时负载均衡 + + > 数据在不同处理机之间的共享与同步问题:除了Cache一致性的问题,在不同处理机上同时运行的进程可能对共享的数据区域产生相同的数据要求,这时就需要避免数据冲突,采用同步互斥机制处理资源竞争; + + > 线程化问题:如何将单个进程分为多线程放在多个处理机上 + + > Cache一致性问题:由于各个处理机有自己的私有Cache,需要保证不同处理机下的Cache之中的数据一致性 + + > 处理器亲和性问题:在单一处理机上运行的进程可以利用Cache实现内存访问的优化与加速,这就需要我们规划调度策略,尽量使一个进程在它前一次运行过的同一个CPU上运行,也即满足处理器亲和性。 + + > 通信问题:类似同步问题,如何降低核间的通信代价 diff --git a/ch5/book/deque-schedule.png b/ch5/book/deque-schedule.png new file mode 100644 index 00000000..a4b1e982 Binary files /dev/null and b/ch5/book/deque-schedule.png differ diff --git a/ch5/book/edf1.png b/ch5/book/edf1.png new file mode 100644 index 00000000..9121bed4 Binary files /dev/null and b/ch5/book/edf1.png differ diff --git a/ch5/book/edf2.png b/ch5/book/edf2.png new file mode 100644 index 00000000..9ac061db Binary files /dev/null and b/ch5/book/edf2.png differ diff --git a/ch5/book/fcome-fserverd.png b/ch5/book/fcome-fserverd.png new file mode 100644 index 00000000..e7c969bd Binary files /dev/null and b/ch5/book/fcome-fserverd.png differ diff --git a/ch5/book/index.md b/ch5/book/index.md new file mode 100644 index 00000000..d279248a --- /dev/null +++ b/ch5/book/index.md @@ -0,0 +1,104 @@ + + +# 第五章:进程 + +* [引言](0intro.md) + * [本章导读](0intro.md#id2) + * [实践体验](0intro.md#id6) + * [本章代码树](0intro.md#id7) + * [本章代码导读](0intro.md#id8) +* [进程概念及重要系统调用](1process.md) + * [本节导读](1process.md#id2) + * [进程概念](1process.md#id3) + * [进程模型与重要系统调用](1process.md#id4) + * [fork 系统调用](1process.md#fork) + * [waitpid 系统调用](1process.md#waitpid) + * [exec 系统调用](1process.md#exec) + * [应用程序示例](1process.md#id7) + * [系统调用封装](1process.md#id8) + * [用户初始程序 initproc](1process.md#initproc) + * [shell程序 user_shell](1process.md#shell-user-shell) +* [进程管理的核心数据结构](2core-data-structures.md) + * [本节导读](2core-data-structures.md#id2) + * [应用的链接与加载支持](2core-data-structures.md#id3) + * [基于应用名的应用链接](2core-data-structures.md#id4) + * [基于应用名的应用加载器](2core-data-structures.md#id5) + * [进程标识符和内核栈](2core-data-structures.md#id6) + * [进程标识符](2core-data-structures.md#id7) + * [内核栈](2core-data-structures.md#id8) + * [进程控制块](2core-data-structures.md#id9) + * [任务管理器](2core-data-structures.md#id10) + * [处理器管理结构](2core-data-structures.md#id11) + * [正在执行的任务](2core-data-structures.md#id12) + * [任务调度的 idle 控制流](2core-data-structures.md#idle) +* [进程管理机制的设计实现](3implement-process-mechanism.md) + * [本节导读](3implement-process-mechanism.md#id2) + * [初始进程的创建](3implement-process-mechanism.md#id3) + * [进程调度机制](3implement-process-mechanism.md#id4) + * [进程的生成机制](3implement-process-mechanism.md#id5) + * [fork 系统调用的实现](3implement-process-mechanism.md#fork) + * [exec 系统调用的实现](3implement-process-mechanism.md#exec) + * [系统调用后重新获取 Trap 上下文](3implement-process-mechanism.md#trap) + * [shell程序 user_shell 的输入机制](3implement-process-mechanism.md#shell-user-shell) + * [进程资源回收机制](3implement-process-mechanism.md#id6) + * [进程的退出](3implement-process-mechanism.md#id7) + * [父进程回收子进程资源](3implement-process-mechanism.md#id8) +* [进程调度](4scheduling.md) + * [本节导读](4scheduling.md#id2) + * [回顾历史](4scheduling.md#id3) + * [本章之前的操作系统实例](4scheduling.md#id4) + * [计算机发展历史中的调度](4scheduling.md#id5) + * [批处理系统的调度](4scheduling.md#id6) + * [约束条件](4scheduling.md#id7) + * [性能指标](4scheduling.md#id8) + * [先来先服务](4scheduling.md#id9) + * [最短作业优先](4scheduling.md#id10) + * [交互式系统的调度](4scheduling.md#id11) + * [约束条件](4scheduling.md#id12) + * [性能指标](4scheduling.md#id13) + * [最短完成时间优先(STCF)](4scheduling.md#stcf) + * [基于时间片的轮转](4scheduling.md#id14) + * [通用计算机系统的调度](4scheduling.md#id15) + * [约束条件](4scheduling.md#id16) + * [性能指标](4scheduling.md#id17) + * [多级反馈队列调度](4scheduling.md#id18) + * [固定优先级的多级无反馈队列](4scheduling.md#id19) + * [可降低优先级的多级反馈队列](4scheduling.md#id20) + * [可提升/降低优先级的多级反馈队列](4scheduling.md#id21) + * [公平份额调度](4scheduling.md#id22) + * [实时计算机系统的调度](4scheduling.md#id23) + * [约束条件](4scheduling.md#id24) + * [性能指标](4scheduling.md#id25) + * [速率单调调度](4scheduling.md#id26) + * [EDF调度](4scheduling.md#edf) + * [实时调度实例](4scheduling.md#id27) + * [多处理器计算机系统的调度](4scheduling.md#id28) + * [约束条件](4scheduling.md#id29) + * [性能指标](4scheduling.md#id30) + * [单队列调度](4scheduling.md#id31) + * [多队列调度](4scheduling.md#id32) + * [小结](4scheduling.md#id33) +* [练习](5exercise.md) + * [课后练习](5exercise.md#id2) + * [编程题](5exercise.md#id3) + * [问答题](5exercise.md#id4) + * [实验练习1](5exercise.md#id6) + * [实践作业](5exercise.md#id7) + * [进程创建](5exercise.md#id8) + * [实验要求](5exercise.md#id9) + * [问答作业](5exercise.md#id10) + * [实验练习的提交报告要求](5exercise.md#id12) + * [实验练习2](5exercise.md#id13) + * [实践作业](5exercise.md#id14) + * [stride 调度算法](5exercise.md#stride) + * [实验要求](5exercise.md#id15) + * [实验约定](5exercise.md#id16) + * [问答作业](5exercise.md#id17) + * [实验练习的提交报告要求](5exercise.md#id18) + * [参考信息](5exercise.md#id19) +* [练习参考答案](6answer.md) + * [课后练习](6answer.md#id2) + * [编程题](6answer.md#id3) + * [问答题](6answer.md#id4) + + diff --git a/ch5/book/multi-core-schedule.png b/ch5/book/multi-core-schedule.png new file mode 100644 index 00000000..45fdcf27 Binary files /dev/null and b/ch5/book/multi-core-schedule.png differ diff --git a/ch5/book/pcb.png b/ch5/book/pcb.png new file mode 100644 index 00000000..14460711 Binary files /dev/null and b/ch5/book/pcb.png differ diff --git a/ch5/book/process-os-detail.png b/ch5/book/process-os-detail.png new file mode 100644 index 00000000..20c97bf8 Binary files /dev/null and b/ch5/book/process-os-detail.png differ diff --git a/ch5/book/process-os-key-structures.png b/ch5/book/process-os-key-structures.png new file mode 100644 index 00000000..cc25de03 Binary files /dev/null and b/ch5/book/process-os-key-structures.png differ diff --git a/ch5/book/queue-schedule.png b/ch5/book/queue-schedule.png new file mode 100644 index 00000000..7c0e51da Binary files /dev/null and b/ch5/book/queue-schedule.png differ diff --git a/ch5/book/real-time-schedule.png b/ch5/book/real-time-schedule.png new file mode 100644 index 00000000..fb8237af Binary files /dev/null and b/ch5/book/real-time-schedule.png differ diff --git a/ch5/book/rms.png b/ch5/book/rms.png new file mode 100644 index 00000000..db618115 Binary files /dev/null and b/ch5/book/rms.png differ diff --git a/ch5/book/round-robin-theory.png b/ch5/book/round-robin-theory.png new file mode 100644 index 00000000..7ff01ae9 Binary files /dev/null and b/ch5/book/round-robin-theory.png differ diff --git a/ch5/book/sjf1.png b/ch5/book/sjf1.png new file mode 100644 index 00000000..f5f9f5d3 Binary files /dev/null and b/ch5/book/sjf1.png differ diff --git a/ch5/book/sjf2.png b/ch5/book/sjf2.png new file mode 100644 index 00000000..2173427a Binary files /dev/null and b/ch5/book/sjf2.png differ diff --git a/ch6/book/0intro.md b/ch6/book/0intro.md new file mode 100644 index 00000000..635ecad9 --- /dev/null +++ b/ch6/book/0intro.md @@ -0,0 +1,280 @@ +# 引言 + +## 本章导读 + + + +文件最早来自于计算机用户需要把数据持久保存在 **持久存储设备** 上的需求。由于放在内存中的数据在计算机关机或掉电后就会消失,所以应用程序要把内存中需要保存的数据放到 持久存储设备的数据块(比如磁盘的扇区等)中存起来。随着操作系统功能的增强,在操作系统的管理下,应用程序不用理解持久存储设备的硬件细节,而只需对 **文件** 这种持久存储数据的抽象进行读写就可以了,由操作系统中的文件系统和存储设备驱动程序一起来完成繁琐的持久存储设备的管理与读写。所以本章要完成的操作系统的第一个核心目标是: **让应用能够方便地把数据持久保存起来** 。 + +大家不要被 **持久存储设备** 这个词给吓住了,这就是指计算机远古时代的卡片、纸带、磁芯、磁鼓、汞延迟线存储器等,以及到现在还在用的磁带、磁盘、硬盘、光盘、闪存、固态硬盘 (SSD, Solid-State Drive)等存储设备。我们可以把这些设备叫做 **外存** 。在本章之前,我们仅使用一种存储,就是内存(或称 RAM),内存是一种易失性存储。相比内存,外存的读写速度较慢,容量较大。但内存掉电后信息会丢失,而外存在掉电之后并不会丢失数据。因此,将需要持久保存的数据从内存写入到外存,或是从外存读入到内存是应用的一种重要需求。 + +#### NOTE + +文件系统在UNIX操作系统有着特殊的地位,根据史料《UNIX: A History and a Memoir》记载,1969年,Ken Thompson(UNIX的作者)在贝尔实验室比较闲,写了PDP-7计算机的磁盘调度算法来提高磁盘的吞吐量。为了测试这个算法,他本来想写一个批量读写数据的测试程序。但写着写着,他在某一时刻发现,这个测试程序再扩展一下,就是一个文件,再扩展一下,就是一个操作系统了。他的直觉告诉他,他离实现一个操作系统仅有 **三周之遥** 。一周:写代码编辑器;一周:写汇编器;一周写shell程序,在写这些程序的同时,需要添加操作系统的功能(如 exec等系统调用)以支持这些应用。结果三周后,为测试磁盘调度算法性能的UNIX雏形诞生了。 + + + +#### NOTE + +指明方向的舵手:Multics文件系统 + +计算机的第一种存储方式是图灵设计的图灵机中的纸带。在计算机最早出现的年代,纸质的穿孔卡成为了第一代的数据物理存储介质。 +随着各种应用对持久存储大容量数据的需求,纸带和穿孔卡很快就被放弃,在计算机发展历史依次出现了磁带、磁盘、光盘、闪存等各种各样的外部存储器(也称外存、辅助存储器、辅存等)。与处理器可直接寻址访问的主存(也称内存)相比,处理器不能直接访问,速度慢1~2个数量级,容量多两个数量级以上,且便宜。应用软件访问这些存储设备上的数据很繁琐,效率也低,于是文件系统就登场了。这里介绍一下顺序存储介质(以磁带为代表)的文件系统和随机存储介质(以磁盘为代表)的文件系统。 + +磁带是一种顺序存储介质,磁带的历史早于计算机,它始于 1928 年,当时它被开发用于音频存储(就是录音带)。在1951 年,磁带首次用于在UNIVAC I计算机上存储数据。磁带的串行顺序访问特征对通用文件系统的创建和高效管理提出了挑战,磁带需要线性运动来缠绕和展开可能很长的介质卷轴。磁带的这种顺序运动可能需要几秒钟到几分钟才能将读/写磁头从磁带的一端移动到另一端。磁带文件系统用于存储在磁带上的文件目录和文件,为提高效率,它通常允许将文件目录与文件数据分布在一起,因此不需要耗时且重复的磁带往返线性运动来写入新数据。由于磁带容量很大,保存方便,且很便宜(磁带的成本比磁盘低一个数量级),所以到现在为止,磁带文件系统还在被需要存储大量数据的单位(如数据中心)使用。 + +1956 年,IBM发布了第一款硬盘驱动器,硬盘高速随机访问数据的能力使得它成为替代磁带的合理选择。在 Multics 之前,大多数操作系统一般提供特殊且复杂的文件系统来存储信息。这里的特殊和复杂性主要体现在操作系统对面向不同应用的文件数据格式的直接支持上。与当时的其他文件系统相比,Multics 文件系统不需要支持各种具体的文件数据格式,而是把文件数据看成是一个无格式的字节流,这样在一定程度上就简化了文件系统的设计。Multics操作系统的存储管理主要面向磁盘这种辅助存储器,文件只是一个字节序列。Multics操作系统第一次引入了层次文件系统的概念,即文件系统中的目录可以包含其他子目录,从而在理论和概念上描述了无限大的文件系统,并使得所有用户能够访问私人和共享文件。用户通过文件名来寻址文件并访问文件内容,这使得文件系统的基本结构独立于物理存储介质。文件系统可以动态加载和卸载,以便于数据存储备份等操作。可以说,Multics的这些设计理念(提出这些设计理念的论文出现在Multics操作系统完成的四年前)为UNIX和后续操作系统中基于文件的存储管理指明了方向。 + +眼中一切皆文件的UNIX文件系统 + +而Ken Thompson 在UNIX文件系统的设计和实现方面,采纳了Multics文件系统中的很多设计理念。UNIX 文件只是一个字节序列。文件内容的任何结构或组织仅由处理它的程序决定。UNIX文件系统本身并不关心文件的具体内容,这意味着任何程序都可以读写任何文件。这样就避免了操作系统对各种文件内容的解析,极大地简化了操作系统的设计与实现。同时UNIX提出了“一切皆文件”的设计理念,这使得你几乎可以想到的各种操作系统组件都可以通过文件系统中的文件来命名。除了文件自身外,设备、管道、甚至网络、进程、内存空间都可以用文件来表示和访问。这种命名的一致性简化了操作系统的概念模型,使操作系统对外的接口组织更简单、更模块化。基本的文件访问操作包括 `open,read, write, close` ,表示了访问一个文件最核心和基础的操作:打开文件、读文件内容、写文件内容和关闭文件。直到今天,原始 UNIX 文件系统中文件访问操作的语义几乎没有变化。 + +本章我们将实现一个简单的文件系统 -- easyfs,能够对 **持久存储设备** (Persistent Storage) 这种 I/O 资源进行管理。对于应用程序访问持久存储设备的需求,内核需要新增两种文件:常规文件和目录文件,它们均以文件系统所维护的 **磁盘文件** 形式被组织并保存在持久存储设备上。这样,就形成了具有强大UNIX操作系统基本功能的 “霸王龙” [1](#rex) 操作系统。 + +![文件系统示意图](./filesystem-general.png) + + +## 实践体验 + +获取本章代码: + +```console +$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git +$ cd rCore-Tutorial-v3 +$ git checkout ch6 +``` + +在 qemu 模拟器上运行本章代码: + +```console +$ cd os +$ make run # 编译后,最终执行如下命令模拟rv64 virt计算机运行: +...... +$ qemu-system-riscv64 \ +-machine virt \ +-nographic \ +-bios ../bootloader/rustsbi-qemu.bin \ +-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 \ +-drive file=../user/target/riscv64gc-unknown-none-elf/release/fs.img,if=none,format=raw,id=x0 \ + -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 +``` + +在执行 `qemu-system-riscv64` 的参数中,`../user/target/riscv64gc-unknown-none-elf/release/fs.img` 是包含应用程序集合的文件系统镜像,这个镜像是放在虚拟硬盘块设备 `virtio-blk-device` (在下一章会进一步介绍这种存储设备)中的。 + +内核初始化完成之后就会进入shell程序,在这里我们运行一下本章的测例 `filetest_simple` : + +```default +>> filetest_simple +file_test passed! +Shell: Process 2 exited with code 0 +>> +``` + +它会将 `Hello, world!` 输出到另一个文件 `filea` ,并读取里面的内容确认输出正确。我们也可以通过命令行工具 `cat_filea` 来更直观的查看 `filea` 中的内容: + +```default +>> cat_filea +Hello, world! +Shell: Process 2 exited with code 0 +>> +``` + +## 本章代码树 + +霸王龙操作系统 -- FilesystemOS的总体结构如下图所示: + +![霸王龙操作系统 - Address Space OS总体结构](./fsos-fsdisk.png) + +通过上图,大致可以看出霸王龙操作系统 -- FilesystemOS增加了对文件系统的支持,并对应用程序提供了文件访问相关的系统调用服务。在进程管理上,进一步扩展资源管理的范围,把打开的文件相关信息放到 fd table 数据结构中,纳入进程的管辖中,并以此为基础,提供 sys_open、sys_close、sys_read、sys_write 与访问文件相关的系统调用服务。在设备管理层面,增加了块设备驱动 -- BlockDrv ,通过访问块设备数据来读写文件系统与文件的各种数据。文件系统 -- EasyFS 成为 FilesystemOS的核心内核模块,完成文件与存储块之间的数据/地址映射关系,通过块设备驱动 BlockDrv 进行基于存储块的读写。其核心数据结构包括: Superblock(表示整个文件系统结构)、inode bitmap(表示存放inode磁盘块空闲情况的位图)、data bitmap(表示存放文件数据磁盘块空闲情况的位图)、inode blks(存放文件元数据的磁盘块)和data blks(存放文件数据的磁盘块)。EasyFS中的块缓存管理器 `BlockManager` 在内存中管理有限个 `BlockCache` 磁盘块缓存,并通过Blk Interface(与块设备驱动对接的读写操作接口)与BlockDrv 块设备驱动程序进行互操作。 + +位于 `ch6` 分支上的霸王龙操作系统 - FilesystemOS的源代码如下所示: + +```default +./os/src +Rust 32 Files 2893 Lines +Assembly 3 Files 88 Lines +./easyfs/src +Rust 7 Files 908 Lines +├── bootloader +│   └── 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 块设备, Qemu不会用) +│   │   │   └── 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-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(新增:应用地址空间的缓冲区抽象 UserBuffer 及其迭代器实现) +│   ├── sbi.rs +│   ├── syscall +│   │   ├── fs.rs(修改:新增 sys_open) +│   │   ├── 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 +└── user + ├── Cargo.lock + ├── Cargo.toml + ├── Makefile + └── src + ├── bin + │   ├── cat_filea.rs(新增:显示文件filea的内容) + │   ├── cmdline_args.rs(新增) + │   ├── exit.rs + │   ├── fantastic_text.rs + │   ├── filetest_simple.rs(新增:创建文件filea并读取它的内容 ) + │   ├── 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) +``` + +## 本章代码导读 + +本章涉及的代码量相对较多,且与进程执行相关的管理还有直接的关系。其实我们是参考经典的UNIX基于索引结构的文件系统,设计了一个简化的有一级目录并支持 `open,read, write, close` ,即创建/打开/读写/关闭文件一系列操作的文件系统。这里简要介绍一下在内核中添加文件系统的大致开发过程。 + +**第一步:是能够写出与文件访问相关的应用** + +这里是参考了Linux的创建/打开/读写/关闭文件的系统调用接口,力图实现一个 [简化版的文件系统模型](1fs-interface.md#fs-simplification) 。在用户态我们只需要遵从相关系统调用的接口约定,在用户库里完成对应的封装即可。这一过程我们在前面的章节中已经重复过多次,同学应当对其比较熟悉。其中最为关键的是系统调用可以参考 [sys_open 语义介绍](1fs-interface.md#sys-open) ,此外我们还给出了 [测例代码解读](1fs-interface.md#filetest-simple) 。 + +**第二步:就是要实现 easyfs 文件系统** + +由于 Rust 语言的特点,我们可以在用户态实现 easyfs 文件系统,并在用户态完成文件系统功能的基本测试并基本验证其实现正确性之后,就可以放心的将该模块嵌入到操作系统内核中。当然,有了文件系统的具体实现,还需要对上一章的操作系统内核进行扩展,实现与 easyfs 文件系统对接的接口,这样才可以让操作系统拥有一个简单可用的文件系统。这样内核就可以支持具有文件读写功能的复杂应用。当内核进一步支持应用的命令行参数后,就可以进一步提升应用程序的灵活性,让应用的开发和调试变得更为轻松。 + +easyfs 文件系统的整体架构自下而上可分为五层: + +1. 磁盘块设备接口层:读写磁盘块设备的trait接口 +2. 块缓存层:位于内存的磁盘块数据缓存 +3. 磁盘数据结构层:表示磁盘文件系统的数据结构 +4. 磁盘块管理器层:实现对磁盘文件系统的管理 +5. 索引节点层:实现文件创建/文件打开/文件读写等操作 + +它的最底层就是对块设备的访问操作接口。在 `easy-fs/src/block_dev.rs` 中,可以看到 `BlockDevice` trait ,它代表了一个抽象块设备的接口,该 trait 仅需求两个函数 `read_block` 和 `write_block` ,分别代表将数据从块设备读到内存缓冲区中,或者将数据从内存缓冲区写回到块设备中,数据需要以块为单位进行读写。easy-fs 库的使用者(如操作系统内核)需要实现块设备驱动程序,并实现 `BlockDevice` trait 以提供给 easy-fs 库使用,这样 easy-fs 库就与一个具体的执行环境对接起来了。至于为什么块设备层位于 easy-fs 的最底层,那是因为文件系统仅仅是在块设备上存储的稍微复杂一点的数据。无论对文件系统的操作如何复杂,从块设备的角度看,这些操作终究可以被分解成若干次基本的块读写操作。 + +尽管在操作系统的最底层(即块设备驱动程序)已经有了对块设备的读写能力,但从编程方便/正确性和读写性能的角度来看,仅有块读写这么基础的底层接口是不足以实现高效的文件系统。比如,某应用将一个块的内容读到内存缓冲区,对缓冲区进行修改,并尚未写回块设备时,如果另外一个应用再次将该块的内容读到另一个缓冲区,而不是使用已有的缓冲区,这将会造成数据不一致问题。此外还有可能增加很多不必要的块读写次数,大幅降低文件系统的性能。因此,通过程序自动而非程序员手动地对块缓冲区进行统一管理也就很必要了,该机制被我们抽象为 easy-fs 自底向上的第二层,即块缓存层。在 `easy-fs/src/block_cache.rs` 中, `BlockCache` 代表一个被我们管理起来的块缓冲区,它包含块数据内容以及块的编号等信息。当它被创建的时候,将触发一次 `read_block` 将数据从块设备读到它的缓冲区中。接下来只要它驻留在内存中,便可保证对于同一个块的所有操作都会直接在它的缓冲区中进行而无需额外的 `read_block` 。块缓存管理器 `BlockManager` 在内存中管理有限个 `BlockCache` 并实现了类似 FIFO 的缓存替换算法,当一个块缓存被换出的时候视情况可能调用 `write_block` 将缓冲区数据写回块设备。总之,块缓存层对上提供 `get_block_cache` 接口来屏蔽掉相关细节,从而可以向上层子模块提供透明读写数据块的服务。 + +有了块缓存,我们就可以在内存中方便地处理easyfs文件系统在磁盘上的各种数据了,这就是第三层文件系统的磁盘数据结构。easyfs文件系统中的所有需要持久保存的数据都会放到磁盘上,这包括了管理这个文件系统的 **超级块 (Super Block)**,管理空闲磁盘块的 **索引节点位图区** 和 **数据块位图区** ,以及管理文件的 **索引节点区** 和 放置文件数据的 **数据块区** 组成。 + +easyfs文件系统中管理这些磁盘数据的控制逻辑主要集中在 **磁盘块管理器** 中,这是文件系统的第四层。对于文件系统管理而言,其核心是 `EasyFileSystem` 数据结构及其关键成员函数: + +> - EasyFileSystem.create:创建文件系统 +> - EasyFileSystem.open:打开文件系统 +> - EasyFileSystem.alloc_inode:分配inode (dealloc_inode未实现,所以还不能删除文件) +> - EasyFileSystem.alloc_data:分配数据块 +> - EasyFileSystem.dealloc_data:回收数据块 + +对于单个文件的管理和读写的控制逻辑主要是 **索引节点(文件控制块)** 来完成,这是文件系统的第五层,其核心是 `Inode` 数据结构及其关键成员函数: + +> - Inode.new:在磁盘上的文件系统中创建一个inode +> - Inode.find:根据文件名查找对应的磁盘上的inode +> - Inode.create:在根目录下创建一个文件 +> - Inode.read_at:根据inode找到文件数据所在的磁盘数据块,并读到内存中 +> - Inode.write_at:根据inode找到文件数据所在的磁盘数据块,把内存中数据写入到磁盘数据块中 + +上述五层就构成了easyfs文件系统的整个内容。我们可以把easyfs文件系统看成是一个库,被应用程序调用。而 `easy-fs-fuse` 这个应用就通过调用easyfs文件系统库中各种函数,并作用在用Linux上的文件模拟的一个虚拟块设备,就可以在这个虚拟块设备上进行各种文件操作和文件系统操作,从而创建一个easyfs文件系统。 + +**第三步:把easyfs文件系统加入内核中** + +这还需要做两件事情,第一件是在Qemu模拟的 `virtio` 块设备上实现块设备驱动程序 `os/src/drivers/block/virtio_blk.rs` 。由于我们可以直接使用 `virtio-drivers` crate中的块设备驱动,所以只要提供这个块设备驱动所需要的内存申请与释放以及虚实地址转换的4个函数就可以了。而我们之前操作系统中的虚存管理实现中,已经有这些函数,这使得块设备驱动程序很简单,且具体实现细节都被 `virtio-drivers` crate封装好了。当然,我们也可把easfys文件系统烧写到K210开发板的存储卡中。 + +第二件事情是把文件访问相关的系统调用与easyfs文件系统连接起来。在easfs文件系统中是没有进程的概念的。而进程是程序运行过程中访问资源的管理实体,而之前的进程没有管理文件这种资源。 +为此我们需要扩展进程的管理范围,把文件也纳入到进程的管理之中。 +由于我们希望多个进程都能访问文件,这意味着文件有着共享的天然属性,这样自然就有了 `open/close/read/write` 这样的系统调用,便于进程通过互斥或共享方式访问文件。 + +内核中的进程看到的文件应该是一个便于访问的Inode,这就要对 `easy-fs` crate 提供的 `Inode` 结构进一步封装,形成 `OSInode` 结构,以表示进程中一个打开的常规文件。文件的抽象 Trait `File` 声明在 `os/src/fs/mod.rs` 中,它提供了 `read/write` 两个接口,可以将数据写入应用缓冲区抽象 `UserBuffer` ,或者从应用缓冲区读取数据。应用缓冲区抽象类型 `UserBuffer` 来自 `os/src/mm/page_table.rs` 中,它将 `translated_byte_buffer` 得到的 `Vec<&'static mut [u8]>` 进一步包装,不仅保留了原有的分段读写能力,还可以将其转化为一个迭代器逐字节进行读写。 + +而进程为了进一步管理多个文件,需要扩展文件描述符表。这样进程通过系统调用打开一个文件后,会将文件加入到自身的文件描述符表中,并进一步通过文件描述符(也就是某个特定文件在自身文件描述符表中的下标)来读写该文件( 即 `OSInode` 结构)。 + +在具体实现上,在进程控制块 `TaskControlBlock` 中需要加入文件描述符表字段 `fd_table` ,可以看到它是一个向量,里面保存了若干实现了 `File` Trait 的文件,由于采用Rust的 `Trait Object` 动态分发,文件的类型可能各不相同。 `os/src/syscall/fs.rs` 的 `sys_read/write` 两个读写文件的系统调用需要访问当前进程的文件描述符表,用应用传入内核的文件描述符来索引对应的已打开文件,并调用 `File` Trait 的 `read/write` 接口; `sys_close` 这可以关闭一个文件。调用 `TaskControlBlock` 的 `alloc_fd` 方法可以在文件描述符表中分配一个文件描述符。进程控制块的其他操作也需要考虑到新增的文件描述符表字段的影响,如 `TaskControlBlock::new` 的时候需要对 `fd_table` 进行初始化, `TaskControlBlock::fork` 中则需要将父进程的 `fd_table` 复制一份给子进程。 + +对于应用程序而言,它理解的磁盘数据是常规的文件和目录,不是 `OSInode` 这样相对复杂的结构。其实常规文件对应的 OSInode 是操作系统内核中的文件控制块数据结构的实例,它实现了 File Trait 定义的函数接口。这些 OSInode 实例会放入到进程文件描述符表中,并通过 sys_read/write 系统调用来完成读写文件的服务。这样就建立了文件与 `OSInode` 的对应关系,通过上面描述的三个开发步骤将形成包含文件系统的操作系统内核,可给应用提供基于文件的系统调用服务。 + +* **[1]** 霸王龙是最广为人知的恐龙,生存于约6850万年到6500万年的白垩纪最末期, 位于白垩纪晚期的食物链顶端。 diff --git a/ch6/book/1fs-interface.md b/ch6/book/1fs-interface.md new file mode 100644 index 00000000..17986d9b --- /dev/null +++ b/ch6/book/1fs-interface.md @@ -0,0 +1,267 @@ +# 文件系统接口 + +## 本节导读 + +本节首先以Linux 上的常规文件和目录为例,站在访问文件的应用的角度,介绍文件中值得注意的地方及文件使用方法。由于 Linux 上的文件系统模型还是比较复杂,在内核实现中对它进行了很大程度的简化,我们会对简化的具体情形进行介绍。最后介绍内核上应用的开发者应该如何使用我们简化后的文件系统和一些相关知识。 + +## 文件和目录 + +### 常规文件 + +在操作系统的用户看来,常规文件是保存在持久存储设备上的一个字节序列,每个常规文件都有一个 **文件名** (Filename) ,用户需要通过它来区分不同的常规文件。方便起见,在下面的描述中,“文件”有可能指的是常规文件、目录,也可能是之前提到的若干种进程可以读写的 标准输出、标准输入、管道等I/O 资源,请同学自行根据上下文判断取哪种含义。 + +![文件和目录示意图](./file-and-dir.png) + +在 Linux 系统上, `stat` 工具可以获取文件的一些信息。下面以我们项目中的一个源代码文件 `os/src/main.rs` 为例: + +```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 可以看出,在 Linux操作系统中的Ext4文件系统的每个块的大小为 4096 字节。 +- regular file 表明这个文件是一个常规文件。事实上,其他类型的文件也可以通过文件名来进行访问。 +- 当文件是一个特殊文件(如块设备文件或者字符设备文件)的时候,Device 将指出该特殊文件的 major/minor ID 。对于一个常规文件,我们无需关心它。 +- Inode 表示文件的底层编号。在文件系统的底层实现中,并不是直接通过文件名来索引文件,而是首先需要将文件名转化为文件的底层编号,再根据这个编号去索引文件。目前我们无需关心这一信息。 +- Links 给出文件的硬链接数。同一个文件系统中如果两个文件(目录也是文件)具有相同的inode号码,那么就称它们是“硬链接”关系。这样links的值其实是一个文件的不同文件名的数量。(本章的练习需要你在文件系统中实现硬链接!) +- Uid 给出该文件的所属的用户 ID , Gid 给出该文件所属的用户组 ID 。Access 的其中一种表示是一个长度为 10 的字符串(这里是 `-rw-r--r--` ),其中第 1 位给出该文件的类型,这个文件是一个常规文件,因此这第 1 位为 `-` 。后面的 9 位可以分为三组,分别表示该文件的所有者/在该文件所属的用户组内的其他用户以及剩下的所有用户能够读取/写入/将该文件作为一个可执行文件来执行。 +- Access/Modify 分别给出该文件的最近一次访问/最近一次修改时间。 + +如果我们使用 `stat` 工具查看一个能在我们内核上执行的 ELF 可执行文件: + +```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` 工具获取目录的一些信息: + +```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) 而非绝对路径。每个进程都会记录自己当前所在的工作目录(Current Working Directory, CWD),当它在索引文件或目录的时候,如果传给它的路径并未以 `/` 开头,则会被内核认为是一个相对于进程当前工作目录的相对路径。这个路径会被拼接在进程当前路径的后面组成一个绝对路径,实际索引的是这个绝对路径对应的文件或目录。其中, `./` 表示当前目录,而 `../` 表示当前目录的父目录,这在通过相对路径进行索引的时候非常实用。在使用终端的时候,执行 `pwd` 命令可以打印终端进程当前所在的目录,而通过 `cd` 可以切换终端进程的工作目录。 + +一旦引入目录之后,我们就不再单纯的通过文件名来索引文件,而是通过路径(绝对或相对)进行索引。在文件系统的底层实现中,也是对应的先将路径转化为一个文件或目录的底层编号,然后再通过这个编号具体索引文件或目录。将路径转化为底层编号的过程是逐级进行的,对于绝对路径的情况,需要从根目录出发,每次根据当前目录底层编号获取到它的内容,根据下一级子目录的目录名查到该子目录的底层编号,然后从该子目录继续向下遍历,依此类推。在这个过程目录的权限控制位将会起到保护作用,阻止无权限用户进行访问。 + +#### NOTE + +**目录是否有必要存在** + +基于路径的索引难以并行或分布式化,因为我们总是需要查到一级目录的底层编号才能查到下一级,这是一个天然串行的过程。在一些性能需求极高的环境中,可以考虑弱化目录的权限控制职能,将目录树结构扁平化,将文件系统的磁盘布局变为类键值对存储。 + +### 文件系统 + +常规文件和目录都是实际保存在持久存储设备中的。持久存储设备仅支持以扇区(或块)为单位的随机读写,这和上面介绍的通过路径即可索引到文件并以字节流进行读写的用户视角有很大的不同。负责中间转换的便是 **文件系统** (File System) 。具体而言,文件系统负责将逻辑上的目录树结构(包括其中每个文件或目录的数据和其他信息)映射到持久存储设备上,决定设备上的每个扇区应存储哪些内容。反过来,文件系统也可以从持久存储设备还原出逻辑上的目录树结构。 + +文件系统有很多种不同的实现,每一种都能将同一个逻辑上目录树结构转化为一个不同的持久存储设备上的扇区布局。最著名的文件系统有 Windows 上的 FAT/NTFS 和 Linux 上的 Ext3/Ext4/Btrfs 等。 + +在一个计算机系统中,可以同时包含多个持久存储设备,它们上面的数据可能是以不同文件系统格式存储的。为了能够对它们进行统一管理,在内核中有一层 **虚拟文件系统** (VFS, Virtual File System) ,它规定了逻辑上目录树结构的通用格式及相关操作的抽象接口,只要不同的底层文件系统均实现虚拟文件系统要求的那些抽象接口,再加上 **挂载** (Mount) 等方式,这些持久存储设备上的不同文件系统便可以用一个统一的逻辑目录树结构一并进行管理。 + + + +## 简化的文件与目录抽象 + +我们的内核实现对于目录树结构进行了很大程度上的简化,这样做的目的是为了能够完整地展示文件系统的工作原理,但代码量又不至于太多。我们进行的简化如下: + +- 扁平化:仅存在根目录 `/` 一个目录,剩下所有的文件都放在根目录内。在索引一个文件的时候,我们直接使用文件的文件名而不是它含有 `/` 的绝对路径。 +- 权限控制:我们不设置用户和用户组概念,全程只有单用户。同时根目录和其他文件也都没有权限控制位,即完全不限制文件的访问方式,不会区分文件是否可执行。 +- 不记录文件访问/修改的任何时间戳。 +- 不支持软硬链接。 +- 除了下面即将介绍的系统调用之外,其他的很多文件系统相关系统调用均未实现。 + +![简化的文件和目录示意图](./simple-file-and-dir.png) + +## 打开、关闭与读写文件的系统调用 + + + +### 文件打开 + +在读写一个常规文件之前,应用首先需要通过内核提供的 `sys_open` 系统调用让该文件在进程的文件描述符表中占一项,并得到操作系统的返回值--文件描述符,即文件关联的表项在文件描述表中的索引值: + +```rust +/// 功能:打开一个常规文件,并返回可以访问它的文件描述符。 +/// 参数:path 描述要打开的文件的文件名(简单起见,文件系统不需要支持目录,所有的文件都放在根目录 / 下), +/// flags 描述打开文件的标志,具体含义下面给出。 +/// 返回值:如果出现了错误则返回 -1,否则返回打开常规文件的文件描述符。可能的错误原因是:文件不存在。 +/// syscall ID:56 +fn sys_open(path: &str, flags: u32) -> isize +``` + +![文件打开示意图](./file-open.png) + +目前我们的内核支持以下几种标志(多种不同标志可能共存): + +- 如果 `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` 接口: + +```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`。 + +```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` 。 + + + +### 文件关闭 + +在打开文件,对文件完成了读写操作后,还需要关闭文件,这样才让进程释放被这个文件占用的内核资源。 `close` 的调用参数是文件描述符,当文件被关闭后,该文件在内核中的资源会被释放,文件描述符会被回收。这样,进程就不能继续使用该文件描述符进行文件读写了。 + +```rust +/// 功能:当前进程关闭一个文件。 +/// 参数:fd 表示要关闭的文件的文件描述符。 +/// 返回值:如果成功关闭则返回 0 ,否则返回 -1 。可能的出错原因:传入的文件描述符并不对应一个打开的文件。 + +// usr/src/lib.rs +pub fn close(fd: usize) -> isize { sys_close(fd) } + +// user/src/syscall.rs +const SYSCALL_CLOSE: usize = 57; + +pub fn sys_close(fd: usize) -> isize { + syscall(SYSCALL_CLOSE, [fd, 0, 0]) +} +``` + +### 文件的顺序读写 + +在打开一个文件之后,我们就可以用之前的 `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` 来介绍文件系统接口的使用方法: + +```rust +// 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` 读取到的内容和之前写入的一致,则测试通过。 + + diff --git a/ch6/book/2fs-implementation.md b/ch6/book/2fs-implementation.md new file mode 100644 index 00000000..b516d452 --- /dev/null +++ b/ch6/book/2fs-implementation.md @@ -0,0 +1,1512 @@ +# 简易文件系统 easy-fs + +## 本节导读 + +本节我们介绍一个简易文件系统的实现 -- easy-fs。作为一个文件系统而言,它的磁盘布局(为了叙述方便,我们用磁盘来指代一系列持久存储设备)体现在磁盘上各扇区的内容上,而它解析磁盘布局得到的逻辑目录树结构则是通过内存上的数据结构来访问的,这意味着它要同时涉及到对磁盘和对内存的访问。它们的访问方式是不同的,对于内存直接通过一条指令即可直接读写内存相应的位置,而磁盘的话需要用软件的方式向磁盘发出请求来间接进行读写。因此,我们也要特别注意哪些数据结构是存储在磁盘上,哪些数据结构是存储在内存中的,这样在实现的时候才不会引起混乱。 + +## 松耦合模块化设计思路 + +大家可以看到,本章的内核功能越来越多,代码量也越来越大(但仅仅是Linux代码量的万分之一左右)。为了减少同学学习内核的分析理解成本,我们需要让内核的各个部分之间尽量松耦合,所以easy-fs 被从内核中分离出来,它的实现分成两个不同的 crate : + +- `easy-fs` 为简易文件系统的核心部分,它是一个库形式 crate,实现一种简单的文件系统磁盘布局; +- `easy-fs-fuse` 是一个能在开发环境(如 Ubuntu)中运行的应用程序,它可以对 `easy-fs` 进行测试,或者将为我们内核开发的应用打包为一个 easy-fs 格式的文件系统镜像。 + +这样,整个easy-fs文件系统的设计开发可以按照应用程序库的开发过程来完成。而且在开发完毕后,可直接放到内核中,形成有文件系统支持的新内核。 + +能做到这一点,是由于我们在easy-fs设计上,采用了松耦合模块化设计思路。easy-fs与底层设备驱动之间通过抽象接口 `BlockDevice` 来连接,避免了与设备驱动的绑定。easy-fs通过Rust提供的alloc crate来隔离了操作系统内核的内存管理,避免了直接调用内存管理的内核函数。在底层驱动上,采用的是轮询的方式访问 `virtio_blk` 虚拟磁盘设备,从而避免了访问外设中断的相关内核函数。easy-fs在设计中避免了直接访问进程相关的数据和函数,从而隔离了操作系统内核的进程管理。 + +同时,easy-fs本身也划分成不同的层次,形成层次化和模块化的设计架构。`easy-fs` crate 自下而上大致可以分成五个不同的层次: + +1. 磁盘块设备接口层:定义了以块大小为单位对磁盘块设备进行读写的trait接口 +2. 块缓存层:在内存中缓存磁盘块的数据,避免频繁读写磁盘 +3. 磁盘数据结构层:磁盘上的超级块、位图、索引节点、数据块、目录项等核心数据结构和相关处理 +4. 磁盘块管理器层:合并了上述核心数据结构和磁盘布局所形成的磁盘文件系统数据结构,以及基于这些结构的创建/打开文件系统的相关处理和磁盘块的分配和回收处理 +5. 索引节点层:管理索引节点(即文件控制块)数据结构,并实现文件创建/文件打开/文件读写等成员函数来向上支持文件操作相关的系统调用 + +大家也许觉得有五层架构的文件系统是一个很复杂的软件。其实,相对于面向Qemu模拟器的操作系统内核源码所占的2400行左右代码,它只有900行左右的代码,占总代码量的27%。且由于其代码逻辑其实是一种自下而上的线性思维,属于传统的常规编程。相对于异常/中断/系统调用的特权级切换,进程管理中的进程上下文切换,内存管理中的页表地址映射等涉及异常控制流和硬件访问的非常规编程,文件系统的设计实现其实更容易理解。 + +## 块设备接口层 + +定义设备驱动需要实现的块读写接口 `BlockDevice` trait的块设备接口层代码在 `block_dev.rs` 中。 + +在 `easy-fs` 库的最底层声明了一个块设备的抽象接口 `BlockDevice` : + +```rust +// easy-fs/src/block_dev.rs + +pub trait BlockDevice : Send + Sync + Any { + fn read_block(&self, block_id: usize, buf: &mut [u8]); + fn write_block(&self, block_id: usize, buf: &[u8]); +} +``` + +它需要实现两个抽象方法: + +- `read_block` 将编号为 `block_id` 的块从磁盘读入内存中的缓冲区 `buf` ; +- `write_block` 将内存中的缓冲区 `buf` 中的数据写入磁盘编号为 `block_id` 的块。 + +在 `easy-fs` 中并没有一个实现了 `BlockDevice` Trait 的具体类型。因为块设备仅支持以块为单位进行随机读写,所以需要由具体的块设备驱动来实现这两个方法,实际上这是需要由文件系统的使用者(比如操作系统内核或直接测试 `easy-fs` 文件系统的 `easy-fs-fuse` 应用程序)提供并接入到 `easy-fs` 库的。 `easy-fs` 库的块缓存层会调用这两个方法,进行块缓存的管理。这也体现了 `easy-fs` 的泛用性:它可以访问实现了 `BlockDevice` Trait 的块设备驱动程序。 + +#### NOTE + +**块与扇区** + +实际上,块和扇区是两个不同的概念。 **扇区** (Sector) 是块设备随机读写的数据单位,通常每个扇区为 512 字节。而块是文件系统存储文件时的数据单位,每个块的大小等同于一个或多个扇区。之前提到过 Linux 的Ext4文件系统的单个块大小默认为 4096 字节。在我们的 easy-fs 实现中一个块和一个扇区同为 512 字节,因此在后面的讲解中我们不再区分扇区和块的概念。 + +## 块缓存层 + +实现磁盘块缓存功能的块缓存层的代码在 `block_cache.rs` 中。 + +由于操作系统频繁读写速度缓慢的磁盘块会极大降低系统性能,因此常见的手段是先通过 `read_block` 将一个块上的数据从磁盘读到内存中的一个缓冲区中,这个缓冲区中的内容是可以直接读写的,那么后续对这个数据块的大部分访问就可以在内存中完成了。如果缓冲区中的内容被修改了,那么后续还需要通过 `write_block` 将缓冲区中的内容写回到磁盘块中。 + +事实上,无论站在代码实现鲁棒性还是性能的角度,将这些缓冲区合理的管理起来都是很有必要的。一种完全不进行任何管理的模式可能是:每当要对一个磁盘块进行读写的时候,都通过 `read_block` 将块数据读取到一个 *临时* 创建的缓冲区,并在进行一些操作之后(可选地)将缓冲区的内容写回到磁盘块。从性能上考虑,我们需要尽可能降低实际块读写(即 `read/write_block` )的次数,因为每一次调用它们都会产生大量开销。要做到这一点,关键就在于对块读写操作进行 **合并** 。例如,如果一个块已经被读到缓冲区中了,那么我们就没有必要再读一遍,直接用已有的缓冲区就行了;同时,对于缓冲区中的同一个块的多次修改没有必要每次都写回磁盘,只需等所有的修改都结束之后统一写回磁盘即可。 + +当磁盘上的数据结构比较复杂的时候,很难通过应用来合理地规划块读取/写入的时机。这不仅可能涉及到复杂的参数传递,稍有不慎还有可能引入同步性问题(目前可以暂时忽略):即一个块缓冲区修改后的内容在后续的同一个块读操作中不可见,这很致命但又难以调试。 + +因此,我们的做法是将缓冲区统一管理起来。当我们要读写一个块的时候,首先就是去全局管理器中查看这个块是否已被缓存到内存缓冲区中。如果是这样,则在一段连续时间内对于一个块进行的所有操作均是在同一个固定的缓冲区中进行的,这解决了同步性问题。此外,通过 `read/write_block` 进行块实际读写的时机完全交给块缓存层的全局管理器处理,上层子系统无需操心。全局管理器会尽可能将更多的块操作合并起来,并在必要的时机发起真正的块实际读写。 + +### 块缓存 + +块缓存 `BlockCache` 的定义如下: + +```rust +// easy-fs/src/lib.rs + +pub const BLOCK_SZ: usize = 512; + +// easy-fs/src/block_cache.rs + +pub struct BlockCache { + cache: [u8; BLOCK_SZ], + block_id: usize, + block_device: Arc, + modified: bool, +} +``` + +其中: + +- `cache` 是一个 512 字节的数组,表示位于内存中的缓冲区; +- `block_id` 记录了这个块缓存来自于磁盘中的块的编号; +- `block_device` 是一个底层块设备的引用,可通过它进行块读写; +- `modified` 记录这个块从磁盘载入内存缓存之后,它有没有被修改过。 + +当我们创建一个 `BlockCache` 的时候,这将触发一次 `read_block` 将一个块上的数据从磁盘读到缓冲区 `cache` : + +```rust +// easy-fs/src/block_cache.rs + +impl BlockCache { + /// Load a new BlockCache from disk. + pub fn new( + block_id: usize, + block_device: Arc + ) -> Self { + let mut cache = [0u8; BLOCK_SZ]; + block_device.read_block(block_id, &mut cache); + Self { + cache, + block_id, + block_device, + modified: false, + } + } +} +``` + +一旦磁盘块已经存在于内存缓存中,CPU 就可以直接访问磁盘块数据了: + +```rust +// easy-fs/src/block_cache.rs + +impl BlockCache { + fn addr_of_offset(&self, offset: usize) -> usize { + &self.cache[offset] as *const _ as usize + } + + pub fn get_ref(&self, offset: usize) -> &T where T: Sized { + let type_size = core::mem::size_of::(); + assert!(offset + type_size <= BLOCK_SZ); + let addr = self.addr_of_offset(offset); + unsafe { &*(addr as *const T) } + } + + pub fn get_mut(&mut self, offset: usize) -> &mut T where T: Sized { + let type_size = core::mem::size_of::(); + assert!(offset + type_size <= BLOCK_SZ); + self.modified = true; + let addr = self.addr_of_offset(offset); + unsafe { &mut *(addr as *mut T) } + } +} +``` + +- `addr_of_offset` 可以得到一个 `BlockCache` 内部的缓冲区中指定偏移量 `offset` 的字节地址; +- `get_ref` 是一个泛型方法,它可以获取缓冲区中的位于偏移量 `offset` 的一个类型为 `T` 的磁盘上数据结构的不可变引用。该泛型方法的 Trait Bound 限制类型 `T` 必须是一个编译时已知大小的类型,我们通过 `core::mem::size_of::()` 在编译时获取类型 `T` 的大小,并确认该数据结构被整个包含在磁盘块及其缓冲区之内。这里编译器会自动进行生命周期标注,约束返回的引用的生命周期不超过 `BlockCache` 自身,在使用的时候我们会保证这一点。 +- `get_mut` 与 `get_ref` 的不同之处在于, `get_mut` 会获取磁盘上数据结构的可变引用,由此可以对数据结构进行修改。由于这些数据结构目前位于内存中的缓冲区中,我们需要将 `BlockCache` 的 `modified` 标记为 true 表示该缓冲区已经被修改,之后需要将数据写回磁盘块才能真正将修改同步到磁盘。 + +`BlockCache` 的设计也体现了 RAII 思想, 它管理着一个缓冲区的生命周期。当 `BlockCache` 的生命周期结束之后缓冲区也会被从内存中回收,这个时候 `modified` 标记将会决定数据是否需要写回磁盘: + +```rust +// easy-fs/src/block_cache.rs + +impl BlockCache { + pub fn sync(&mut self) { + if self.modified { + self.modified = false; + self.block_device.write_block(self.block_id, &self.cache); + } + } +} + +impl Drop for BlockCache { + fn drop(&mut self) { + self.sync() + } +} +``` + +在 `BlockCache` 被 `drop` 的时候,它会首先调用 `sync` 方法,如果自身确实被修改过的话才会将缓冲区的内容写回磁盘。事实上, `sync` 并不是只有在 `drop` 的时候才会被调用。在 Linux 中,通常有一个后台进程负责定期将内存中缓冲区的内容写回磁盘。另外有一个 `sys_fsync` 系统调用可以让应用主动通知内核将一个文件的修改同步回磁盘。由于我们的实现比较简单, `sync` 仅会在 `BlockCache` 被 `drop` 时才会被调用。 + +我们可以将 `get_ref/get_mut` 进一步封装为更为易用的形式: + +```rust +// easy-fs/src/block_cache.rs + +impl BlockCache { + pub fn read(&self, offset: usize, f: impl FnOnce(&T) -> V) -> V { + f(self.get_ref(offset)) + } + + pub fn modify(&mut self, offset:usize, f: impl FnOnce(&mut T) -> V) -> V { + f(self.get_mut(offset)) + } +} +``` + +它们的含义是:在 `BlockCache` 缓冲区偏移量为 `offset` 的位置获取一个类型为 `T` 的磁盘上数据结构的不可变/可变引用(分别对应 `read/modify` ),并让它执行传入的闭包 `f` 中所定义的操作。注意 `read/modify` 的返回值是和传入闭包的返回值相同的,因此相当于 `read/modify` 构成了传入闭包 `f` 的一层执行环境,让它能够绑定到一个缓冲区上执行。 + +这里我们传入闭包的类型为 `FnOnce` ,这是因为闭包里面的变量被捕获的方式涵盖了不可变引用/可变引用/和 move 三种可能性,故而我们需要选取范围最广的 `FnOnce` 。参数中的 `impl` 关键字体现了一种类似泛型的静态分发功能。 + +我们很快将展示 `read/modify` 接口如何在后续的开发中提供便利。 + +### 块缓存全局管理器 + +为了避免在块缓存上浪费过多内存,我们希望内存中同时只能驻留有限个磁盘块的缓冲区: + +```rust +// easy-fs/src/block_cache.rs + +const BLOCK_CACHE_SIZE: usize = 16; +``` + +块缓存全局管理器的功能是:当我们要对一个磁盘块进行读写时,首先看它是否已经被载入到内存缓存中了,如果已经被载入的话则直接返回,否则需要先读取磁盘块的数据到内存缓存中。此时,如果内存中驻留的磁盘块缓冲区的数量已满,则需要遵循某种缓存替换算法将某个块的缓存从内存中移除,再将刚刚读到的块数据加入到内存缓存中。我们这里使用一种类 FIFO 的简单缓存替换算法,因此在管理器中只需维护一个队列: + +```rust +// easy-fs/src/block_cache.rs + +use alloc::collections::VecDeque; + +pub struct BlockCacheManager { + queue: VecDeque<(usize, Arc>)>, +} + +impl BlockCacheManager { + pub fn new() -> Self { + Self { queue: VecDeque::new() } + } +} +``` + +队列 `queue` 中管理的是块编号和块缓存的二元组。块编号的类型为 `usize` ,而块缓存的类型则是一个 `Arc>` 。这是一个此前频频提及到的 Rust 中的经典组合,它可以同时提供共享引用和互斥访问。这里的共享引用意义在于块缓存既需要在管理器 `BlockCacheManager` 保留一个引用,还需要以引用的形式返回给块缓存的请求者让它可以对块缓存进行访问。而互斥访问在单核上的意义在于提供内部可变性通过编译,在多核环境下则可以帮助我们避免可能的并发冲突。事实上,一般情况下我们需要在更上层提供保护措施避免两个线程同时对一个块缓存进行读写,因此这里只是比较谨慎的留下一层保险。 + +#### WARNING + +Rust Pattern卡片: `Arc>` + +先看下Arc和Mutex的正确配合可以达到支持多线程安全读写数据对象。如果需要多线程共享所有权的数据对象,则只用Arc即可。如果需要修改 `T` 类型中某些成员变量 `member` ,那直接采用 `Arc>` ,并在修改的时候通过 `obj.lock().unwrap().member = xxx` 的方式是可行的,但这种编程模式的同步互斥的粒度太大,可能对互斥性能的影响比较大。为了减少互斥性能开销,其实只需要在 `T` 类型中的需要被修改的成员变量上加 `Mutex<_>` 即可。如果成员变量也是一个数据结构,还包含更深层次的成员变量,那应该继续下推到最终需要修改的成员变量上去添加 `Mutex` 。 + +`get_block_cache` 方法尝试从块缓存管理器中获取一个编号为 `block_id` 的块的块缓存,如果找不到,会从磁盘读取到内存中,还有可能会发生缓存替换: + +```rust +// easy-fs/src/block_cache.rs + +impl BlockCacheManager { + pub fn get_block_cache( + &mut self, + block_id: usize, + block_device: Arc, + ) -> Arc> { + if let Some(pair) = self.queue + .iter() + .find(|pair| pair.0 == block_id) { + Arc::clone(&pair.1) + } else { + // substitute + if self.queue.len() == BLOCK_CACHE_SIZE { + // from front to tail + if let Some((idx, _)) = self.queue + .iter() + .enumerate() + .find(|(_, pair)| Arc::strong_count(&pair.1) == 1) { + self.queue.drain(idx..=idx); + } else { + panic!("Run out of BlockCache!"); + } + } + // load block into mem and push back + let block_cache = Arc::new(Mutex::new( + BlockCache::new(block_id, Arc::clone(&block_device)) + )); + self.queue.push_back((block_id, Arc::clone(&block_cache))); + block_cache + } + } +} +``` + +- 第 9 行会遍历整个队列试图找到一个编号相同的块缓存,如果找到了,会将块缓存管理器中保存的块缓存的引用复制一份并返回; +- 第 13 行对应找不到的情况,此时必须将块从磁盘读入内存中的缓冲区。在实际读取之前,需要判断管理器保存的块缓存数量是否已经达到了上限。如果达到了上限(第 15 行)才需要执行缓存替换算法,丢掉某个块缓存并空出一个空位。这里使用一种类 FIFO 算法:每加入一个块缓存时要从队尾加入;要替换时则从队头弹出。但此时队头对应的块缓存可能仍在使用:判断的标志是其强引用计数 $\geq 2$ ,即除了块缓存管理器保留的一份副本之外,在外面还有若干份副本正在使用。因此,我们的做法是从队头遍历到队尾找到第一个强引用计数恰好为 1 的块缓存并将其替换出去。 + + 那么是否有可能出现队列已满且其中所有的块缓存都正在使用的情形呢?事实上,只要我们的上限 `BLOCK_CACHE_SIZE` 设置的足够大,超过所有应用同时访问的块总数上限,那么这种情况永远不会发生。但是,如果我们的上限设置不足,内核将 panic (基于简单内核设计的思路)。 +- 第 27 行开始我们创建一个新的块缓存(会触发 `read_block` 进行块读取)并加入到队尾,最后返回给请求者。 + +接下来需要创建 `BlockCacheManager` 的全局实例: + +```rust +// easy-fs/src/block_cache.rs + +lazy_static! { + pub static ref BLOCK_CACHE_MANAGER: Mutex = Mutex::new( + BlockCacheManager::new() + ); +} + +pub fn get_block_cache( + block_id: usize, + block_device: Arc +) -> Arc> { + BLOCK_CACHE_MANAGER.lock().get_block_cache(block_id, block_device) +} +``` + +这样对于其他模块而言,就可以直接通过 `get_block_cache` 方法来请求块缓存了。这里需要指出的是,它返回的是一个 `Arc>` ,调用者需要通过 `.lock()` 获取里层互斥锁 `Mutex` 才能对最里面的 `BlockCache` 进行操作,比如通过 `read/modify` 访问缓冲区里面的磁盘数据结构。 + +## 磁盘布局及磁盘上数据结构 + +磁盘数据结构层的代码在 `layout.rs` 和 `bitmap.rs` 中。 + +对于一个文件系统而言,最重要的功能是如何将一个逻辑上的文件目录树结构映射到磁盘上,决定磁盘上的每个块应该存储文件相关的哪些数据。为了更容易进行管理和更新,我们需要将磁盘上的数据组织为若干种不同的磁盘上数据结构,并合理安排它们在磁盘中的位置。 + +### easy-fs 磁盘布局概述 + +在 easy-fs 磁盘布局中,按照块编号从小到大顺序地分成 5 个不同属性的连续区域: + +- 最开始的区域的长度为一个块,其内容是 easy-fs **超级块** (Super Block)。超级块内以魔数的形式提供了文件系统合法性检查功能,同时还可以定位其他连续区域的位置。 +- 第二个区域是一个索引节点位图,长度为若干个块。它记录了后面的索引节点区域中有哪些索引节点已经被分配出去使用了,而哪些还尚未被分配出去。 +- 第三个区域是索引节点区域,长度为若干个块。其中的每个块都存储了若干个索引节点。 +- 第四个区域是一个数据块位图,长度为若干个块。它记录了后面的数据块区域中有哪些数据块已经被分配出去使用了,而哪些还尚未被分配出去。 +- 最后的区域则是数据块区域,顾名思义,其中的每一个已经分配出去的块保存了文件或目录中的具体数据内容。 + +easy-fs 的磁盘布局如下图所示: + +![image](./文件系统布局.png) + +**索引节点** (Inode, Index Node) 是文件系统中的一种重要数据结构。逻辑目录树结构中的每个文件和目录都对应一个 inode ,我们前面提到的文件系统实现中,文件/目录的底层编号实际上就是指 inode 编号。在 inode 中不仅包含了我们通过 `stat` 工具能够看到的文件/目录的元数据(大小/访问权限/类型等信息),还包含实际保存对应文件/目录数据的数据块(位于最后的数据块区域中)的索引信息,从而能够找到文件/目录的数据被保存在磁盘的哪些块中。从索引方式上看,同时支持直接索引和间接索引。 + +每个区域中均存储着不同的磁盘数据结构, `easy-fs` 文件系统能够对磁盘中的数据进行解释并将其结构化。下面我们分别对它们进行介绍。 + +### easy-fs 超级块 + +超级块 `SuperBlock` 的内容如下: + +```rust +// easy-fs/src/layout.rs + +#[repr(C)] +pub struct SuperBlock { + magic: u32, + pub total_blocks: u32, + pub inode_bitmap_blocks: u32, + pub inode_area_blocks: u32, + pub data_bitmap_blocks: u32, + pub data_area_blocks: u32, +} +``` + +其中, `magic` 是一个用于文件系统合法性验证的魔数, `total_block` 给出文件系统的总块数。注意这并不等同于所在磁盘的总块数,因为文件系统很可能并没有占据整个磁盘。后面的四个字段则分别给出 easy-fs 布局中后四个连续区域的长度各为多少个块。 + +下面是它实现的方法: + +```rust +// easy-fs/src/layout.rs + +impl SuperBlock { + pub fn initialize( + &mut self, + total_blocks: u32, + inode_bitmap_blocks: u32, + inode_area_blocks: u32, + data_bitmap_blocks: u32, + data_area_blocks: u32, + ) { + *self = Self { + magic: EFS_MAGIC, + total_blocks, + inode_bitmap_blocks, + inode_area_blocks, + data_bitmap_blocks, + data_area_blocks, + } + } + pub fn is_valid(&self) -> bool { + self.magic == EFS_MAGIC + } +} +``` + +- `initialize` 可以在创建一个 easy-fs 的时候对超级块进行初始化,注意各个区域的块数是以参数的形式传入进来的,它们的划分是更上层的磁盘块管理器需要完成的工作。 +- `is_valid` 则可以通过魔数判断超级块所在的文件系统是否合法。 + +`SuperBlock` 是一个磁盘上数据结构,它就存放在磁盘上编号为 0 的块的起始处。 + +### 位图 + +在 easy-fs 布局中存在两类不同的位图,分别对索引节点和数据块进行管理。每个位图都由若干个块组成,每个块大小为 512 bytes,即 4096 bits。每个 bit 都代表一个索引节点/数据块的分配状态, 0 意味着未分配,而 1 则意味着已经分配出去。位图所要做的事情是通过基于 bit 为单位的分配(寻找一个为 0 的bit位并设置为 1)和回收(将bit位清零)来进行索引节点/数据块的分配和回收。 + +```rust +// easy-fs/src/bitmap.rs + +pub struct Bitmap { + start_block_id: usize, + blocks: usize, +} + +impl Bitmap { + pub fn new(start_block_id: usize, blocks: usize) -> Self { + Self { + start_block_id, + blocks, + } + } +} +``` + +位图 `Bitmap` 中仅保存了它所在区域的起始块编号以及区域的长度为多少个块。通过 `new` 方法可以新建一个位图。注意 `Bitmap` 自身是驻留在内存中的,但是它能够表示索引节点/数据块区域中的那些磁盘块的分配情况。磁盘块上位图区域的数据则是要以磁盘数据结构 `BitmapBlock` 的格式进行操作: + +```rust +// easy-fs/src/bitmap.rs + +type BitmapBlock = [u64; 64]; +``` + +`BitmapBlock` 是一个磁盘数据结构,它将位图区域中的一个磁盘块解释为长度为 64 的一个 `u64` 数组, 每个 `u64` 打包了一组 64 bits,于是整个数组包含 $64\times 64=4096$ bits,且可以以组为单位进行操作。 + +首先来看 `Bitmap` 如何分配一个bit: + +```rust +// easy-fs/src/bitmap.rs + +const BLOCK_BITS: usize = BLOCK_SZ * 8; + +impl Bitmap { + pub fn alloc(&self, block_device: &Arc) -> Option { + for block_id in 0..self.blocks { + let pos = get_block_cache( + block_id + self.start_block_id as usize, + Arc::clone(block_device), + ) + .lock() + .modify(0, |bitmap_block: &mut BitmapBlock| { + if let Some((bits64_pos, inner_pos)) = bitmap_block + .iter() + .enumerate() + .find(|(_, bits64)| **bits64 != u64::MAX) + .map(|(bits64_pos, bits64)| { + (bits64_pos, bits64.trailing_ones() as usize) + }) { + // modify cache + bitmap_block[bits64_pos] |= 1u64 << inner_pos; + Some(block_id * BLOCK_BITS + bits64_pos * 64 + inner_pos as usize) + } else { + None + } + }); + if pos.is_some() { + return pos; + } + } + None + } +} +``` + +其主要思路是遍历区域中的每个块,再在每个块中以bit组(每组 64 bits)为单位进行遍历,找到一个尚未被全部分配出去的组,最后在里面分配一个bit。它将会返回分配的bit所在的位置,等同于索引节点/数据块的编号。如果所有bit均已经被分配出去了,则返回 `None` 。 + +第 7 行枚举区域中的每个块(编号为 `block_id` ),在循环内部我们需要读写这个块,在块内尝试找到一个空闲的bit并置 1 。一旦涉及到块的读写,就需要用到块缓存层提供的接口: + +- 第 8 行我们调用 `get_block_cache` 获取块缓存,注意我们传入的块编号是区域起始块编号 `start_block_id` 加上区域内的块编号 `block_id` 得到的块设备上的块编号。 +- 第 12 行我们通过 `.lock()` 获取块缓存的互斥锁从而可以对块缓存进行访问。 +- 第 13 行我们使用到了 `BlockCache::modify` 接口。它传入的偏移量 `offset` 为 0,这是因为整个块上只有一个 `BitmapBlock` ,它的大小恰好为 512 字节。因此我们需要从块的开头开始才能访问到完整的 `BitmapBlock` 。同时,传给它的闭包需要显式声明参数类型为 `&mut BitmapBlock` ,不然的话, `BlockCache` 的泛型方法 `modify/get_mut` 无法得知应该用哪个类型来解析块上的数据。在声明之后,编译器才能在这里将两个方法中的泛型 `T` 实例化为具体类型 `BitmapBlock` 。 + + 总结一下,这里 `modify` 的含义就是:从缓冲区偏移量为 0 的位置开始将一段连续的数据(数据的长度随具体类型而定)解析为一个 `BitmapBlock` 并要对该数据结构进行修改。在闭包内部,我们可以使用这个 `BitmapBlock` 的可变引用 `bitmap_block` 对它进行访问。 `read/get_ref` 的用法完全相同,后面将不再赘述。 +- 闭包的主体位于第 14~26 行。它尝试在 `bitmap_block` 中找到一个空闲的bit并返回其位置,如果不存在的话则返回 `None` 。它的思路是,遍历每 64 bits构成的组(一个 `u64` ),如果它并没有达到 `u64::MAX` (即 $2^{64}-1$ ),则通过 `u64::trailing_ones` 找到最低的一个 0 并置为 1 。如果能够找到的话,bit组的编号将保存在变量 `bits64_pos` 中,而分配的bit在组内的位置将保存在变量 `inner_pos` 中。在返回分配的bit编号的时候,它的计算方式是 `block_id*BLOCK_BITS+bits64_pos*64+inner_pos` 。注意闭包中的 `block_id` 并不在闭包的参数列表中,因此它是从外部环境(即自增 `block_id` 的循环)中捕获到的。 + +我们一旦在某个块中找到一个空闲的bit并成功分配,就不再考虑后续的块。第 28 行体现了提前返回的思路。 + +#### WARNING + +**Rust 语法卡片:闭包** + +闭包是持有外部环境变量的函数。所谓外部环境, 就是指创建闭包时所在的词法作用域。Rust中定义的闭包,按照对外部环境变量的使用方式(借用、复制、转移所有权),分为三个类型: Fn、FnMut、FnOnce。Fn类型的闭包会在闭包内部以共享借用的方式使用环境变量;FnMut类型的闭包会在闭包内部以独占借用的方式使用环境变量;而FnOnce类型的闭包会在闭包内部以所有者的身份使用环境变量。由此可见,根据闭包内使用环境变量的方式,即可判断创建出来的闭包的类型。 + +接下来看 `Bitmap` 如何回收一个bit: + +```rust +// easy-fs/src/bitmap.rs + +/// Return (block_pos, bits64_pos, inner_pos) +fn decomposition(mut bit: usize) -> (usize, usize, usize) { + let block_pos = bit / BLOCK_BITS; + bit = bit % BLOCK_BITS; + (block_pos, bit / 64, bit % 64) +} + +impl Bitmap { + pub fn dealloc(&self, block_device: &Arc, bit: usize) { + let (block_pos, bits64_pos, inner_pos) = decomposition(bit); + get_block_cache( + block_pos + self.start_block_id, + Arc::clone(block_device) + ).lock().modify(0, |bitmap_block: &mut BitmapBlock| { + assert!(bitmap_block[bits64_pos] & (1u64 << inner_pos) > 0); + bitmap_block[bits64_pos] -= 1u64 << inner_pos; + }); + } +} +``` + +`dealloc` 方法首先调用 `decomposition` 函数将bit编号 `bit` 分解为区域中的块编号 `block_pos` 、块内的组编号 `bits64_pos` 以及组内编号 `inner_pos` 的三元组,这样就能精确定位待回收的bit,随后将其清零即可。 + +### 磁盘上索引节点 + +在磁盘上的索引节点区域,每个块上都保存着若干个索引节点 `DiskInode` : + +```rust +// easy-fs/src/layout.rs + +const INODE_DIRECT_COUNT: usize = 28; + +#[repr(C)] +pub struct DiskInode { + pub size: u32, + pub direct: [u32; INODE_DIRECT_COUNT], + pub indirect1: u32, + pub indirect2: u32, + type_: DiskInodeType, +} + +#[derive(PartialEq)] +pub enum DiskInodeType { + File, + Directory, +} +``` + +每个文件/目录在磁盘上均以一个 `DiskInode` 的形式存储。其中包含文件/目录的元数据: `size` 表示文件/目录内容的字节数, `type_` 表示索引节点的类型 `DiskInodeType` ,目前仅支持文件 `File` 和目录 `Directory` 两种类型。其余的 `direct/indirect1/indirect2` 都是存储文件内容/目录内容的数据块的索引,这也是索引节点名字的由来。 + +为了尽可能节约空间,在进行索引的时候,块的编号用一个 `u32` 存储。索引方式分成直接索引和间接索引两种: + +- 当文件很小的时候,只需用到直接索引, `direct` 数组中最多可以指向 `INODE_DIRECT_COUNT` 个数据块,当取值为 28 的时候,通过直接索引可以找到 14KiB 的内容。 +- 当文件比较大的时候,不仅直接索引的 `direct` 数组装满,还需要用到一级间接索引 `indirect1` 。它指向一个一级索引块,这个块也位于磁盘布局的数据块区域中。这个一级索引块中的每个 `u32` 都用来指向数据块区域中一个保存该文件内容的数据块,因此,最多能够索引 $\frac{512}{4}=128$ 个数据块,对应 64KiB 的内容。 +- 当文件大小超过直接索引和一级索引支持的容量上限 78KiB 的时候,就需要用到二级间接索引 `indirect2` 。它指向一个位于数据块区域中的二级索引块。二级索引块中的每个 `u32` 指向一个不同的一级索引块,这些一级索引块也位于数据块区域中。因此,通过二级间接索引最多能够索引 $128\times 64\text{KiB}=8\text{MiB}$ 的内容。 + +为了充分利用空间,我们将 `DiskInode` 的大小设置为 128 字节,每个块正好能够容纳 4 个 `DiskInode` 。在后续需要支持更多类型的元数据的时候,可以适当缩减直接索引 `direct` 的块数,并将节约出来的空间用来存放其他元数据,仍可保证 `DiskInode` 的总大小为 128 字节。 + +通过 `initialize` 方法可以初始化一个 `DiskInode` 为一个文件或目录: + +```rust +// easy-fs/src/layout.rs + +impl DiskInode { + /// indirect1 and indirect2 block are allocated only when they are needed. + pub fn initialize(&mut self, type_: DiskInodeType) { + self.size = 0; + self.direct.iter_mut().for_each(|v| *v = 0); + self.indirect1 = 0; + self.indirect2 = 0; + self.type_ = type_; + } +} +``` + +需要注意的是, `indirect1/2` 均被初始化为 0 。因为最开始文件内容的大小为 0 字节,并不会用到一级/二级索引。为了节约空间,内核会按需分配一级/二级索引块。此外,直接索引 `direct` 也被清零。 + +`is_file` 和 `is_dir` 两个方法可以用来确认 `DiskInode` 的类型为文件还是目录: + +```rust +// easy-fs/src/layout.rs + +impl DiskInode { + pub fn is_dir(&self) -> bool { + self.type_ == DiskInodeType::Directory + } + pub fn is_file(&self) -> bool { + self.type_ == DiskInodeType::File + } +} +``` + +`get_block_id` 方法体现了 `DiskInode` 最重要的数据块索引功能,它可以从索引中查到它自身用于保存文件内容的第 `block_id` 个数据块的块编号,这样后续才能对这个数据块进行访问: + +```rust +// easy-fs/src/layout.rs + +const INODE_INDIRECT1_COUNT: usize = BLOCK_SZ / 4; +const INDIRECT1_BOUND: usize = DIRECT_BOUND + INODE_INDIRECT1_COUNT; +type IndirectBlock = [u32; BLOCK_SZ / 4]; + +impl DiskInode { + pub fn get_block_id(&self, inner_id: u32, block_device: &Arc) -> u32 { + let inner_id = inner_id as usize; + if inner_id < INODE_DIRECT_COUNT { + self.direct[inner_id] + } else if inner_id < INDIRECT1_BOUND { + get_block_cache(self.indirect1 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect_block: &IndirectBlock| { + indirect_block[inner_id - INODE_DIRECT_COUNT] + }) + } else { + let last = inner_id - INDIRECT1_BOUND; + let indirect1 = get_block_cache( + self.indirect2 as usize, + Arc::clone(block_device) + ) + .lock() + .read(0, |indirect2: &IndirectBlock| { + indirect2[last / INODE_INDIRECT1_COUNT] + }); + get_block_cache( + indirect1 as usize, + Arc::clone(block_device) + ) + .lock() + .read(0, |indirect1: &IndirectBlock| { + indirect1[last % INODE_INDIRECT1_COUNT] + }) + } + } +} +``` + +这里需要说明的是: + +- 第 10/12/18 行分别利用直接索引/一级索引和二级索引,具体选用哪种索引方式取决于 `block_id` 所在的区间。 +- 在对一个索引块进行操作的时候,我们将其解析为磁盘数据结构 `IndirectBlock` ,实质上就是一个 `u32` 数组,每个都指向一个下一级索引块或者数据块。 +- 对于二级索引的情况,需要先查二级索引块找到挂在它下面的一级索引块,再通过一级索引块找到数据块。 + +在对文件/目录初始化之后,它的 `size` 均为 0 ,此时并不会索引到任何数据块。它需要通过 `increase_size` 方法逐步扩充容量。在扩充的时候,自然需要一些新的数据块来作为索引块或是保存内容的数据块。我们需要先编写一些辅助方法来确定在容量扩充的时候额外需要多少块: + +```rust +// easy-fs/src/layout.rs + +impl DiskInode { + /// Return block number correspond to size. + pub fn data_blocks(&self) -> u32 { + Self::_data_blocks(self.size) + } + fn _data_blocks(size: u32) -> u32 { + (size + BLOCK_SZ as u32 - 1) / BLOCK_SZ as u32 + } + /// Return number of blocks needed include indirect1/2. + pub fn total_blocks(size: u32) -> u32 { + let data_blocks = Self::_data_blocks(size) as usize; + let mut total = data_blocks as usize; + // indirect1 + if data_blocks > INODE_DIRECT_COUNT { + total += 1; + } + // indirect2 + if data_blocks > INDIRECT1_BOUND { + total += 1; + // sub indirect1 + total += (data_blocks - INDIRECT1_BOUND + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT; + } + total as u32 + } + pub fn blocks_num_needed(&self, new_size: u32) -> u32 { + assert!(new_size >= self.size); + Self::total_blocks(new_size) - Self::total_blocks(self.size) + } +} +``` + +`data_blocks` 方法可以计算为了容纳自身 `size` 字节的内容需要多少个数据块。计算的过程只需用 `size` 除以每个块的大小 `BLOCK_SZ` 并向上取整。而 `total_blocks` 不仅包含数据块,还需要统计索引块。计算的方法也很简单,先调用 `data_blocks` 得到需要多少数据块,再根据数据块数目所处的区间统计索引块即可。 `blocks_num_needed` 可以计算将一个 `DiskInode` 的 `size` 扩容到 `new_size` 需要额外多少个数据和索引块。这只需要调用两次 `total_blocks` 作差即可。 + +下面给出 `increase_size` 方法的接口: + +```rust +// easy-fs/src/layout.rs + +impl DiskInode { + pub fn increase_size( + &mut self, + new_size: u32, + new_blocks: Vec, + block_device: &Arc, + ); +} +``` + +其中 `new_size` 表示容量扩充之后的文件大小; `new_blocks` 是一个保存了本次容量扩充所需块编号的向量,这些块都是由上层的磁盘块管理器负责分配的。 `increase_size` 的实现有些复杂,在这里不详细介绍。大致的思路是按照直接索引、一级索引再到二级索引的顺序进行扩充。 + +有些时候我们还需要清空文件的内容并回收所有数据和索引块。这是通过 `clear_size` 方法来实现的: + +```rust +// easy-fs/src/layout.rs + +impl DiskInode { + /// Clear size to zero and return blocks that should be deallocated. + /// + /// We will clear the block contents to zero later. + pub fn clear_size(&mut self, block_device: &Arc) -> Vec; +} +``` + +它会将回收的所有块的编号保存在一个向量中返回给磁盘块管理器。它的实现原理和 `increase_size` 一样也分为多个阶段,在这里不展开。 + +接下来需要考虑通过 `DiskInode` 来读写它索引的那些数据块中的数据。这些数据可以被视为一个字节序列,而每次都是选取其中的一段连续区间进行操作,以 `read_at` 为例: + +```rust +// easy-fs/src/layout.rs + +type DataBlock = [u8; BLOCK_SZ]; + +impl DiskInode { + pub fn read_at( + &self, + offset: usize, + buf: &mut [u8], + block_device: &Arc, + ) -> usize { + let mut start = offset; + let end = (offset + buf.len()).min(self.size as usize); + if start >= end { + return 0; + } + let mut start_block = start / BLOCK_SZ; + let mut read_size = 0usize; + loop { + // calculate end of current block + let mut end_current_block = (start / BLOCK_SZ + 1) * BLOCK_SZ; + end_current_block = end_current_block.min(end); + // read and update read size + let block_read_size = end_current_block - start; + let dst = &mut buf[read_size..read_size + block_read_size]; + get_block_cache( + self.get_block_id(start_block as u32, block_device) as usize, + Arc::clone(block_device), + ) + .lock() + .read(0, |data_block: &DataBlock| { + let src = &data_block[start % BLOCK_SZ..start % BLOCK_SZ + block_read_size]; + dst.copy_from_slice(src); + }); + read_size += block_read_size; + // move to next block + if end_current_block == end { break; } + start_block += 1; + start = end_current_block; + } + read_size + } +} +``` + +它的含义是:将文件内容从 `offset` 字节开始的部分读到内存中的缓冲区 `buf` 中,并返回实际读到的字节数。如果文件剩下的内容还足够多,那么缓冲区会被填满;否则文件剩下的全部内容都会被读到缓冲区中。具体实现上有很多细节,但大致的思路是遍历位于字节区间 `start,end` 中间的那些块,将它们视为一个 `DataBlock` (也就是一个字节数组),并将其中的部分内容复制到缓冲区 `buf` 中适当的区域。 `start_block` 维护着目前是文件内部第多少个数据块,需要首先调用 `get_block_id` 从索引中查到这个数据块在块设备中的块编号,随后才能传入 `get_block_cache` 中将正确的数据块缓存到内存中进行访问。 + +在第 14 行进行了简单的边界条件判断,如果要读取的内容超出了文件的范围,那么直接返回 0 ,表示读取不到任何内容。 + +`write_at` 的实现思路基本上和 `read_at` 完全相同。但不同的是 `write_at` 不会出现失败的情况;只要 Inode 管理的数据块的大小足够,传入的整个缓冲区的数据都必定会被写入到文件中。当从 `offset` 开始的区间超出了文件范围的时候,就需要调用者在调用 `write_at` 之前提前调用 `increase_size` ,将文件大小扩充到区间的右端,保证写入的完整性。 + +### 数据块与目录项 + +作为一个文件而言,它的内容在文件系统看来没有任何既定的格式,都只是一个字节序列。因此每个保存内容的数据块都只是一个字节数组: + +```rust +// easy-fs/src/layout.rs + +type DataBlock = [u8; BLOCK_SZ]; +``` + +然而,目录的内容却需要遵从一种特殊的格式。在我们的实现中,它可以看成一个目录项的序列,每个目录项都是一个二元组,二元组的首个元素是目录下面的一个文件(或子目录)的文件名(或目录名),另一个元素则是文件(或子目录)所在的索引节点编号。目录项相当于目录树结构上的子树节点,我们需要通过它来一级一级的找到实际要访问的文件或目录。目录项 `DirEntry` 的定义如下: + +```rust +// easy-fs/src/layout.rs + +const NAME_LENGTH_LIMIT: usize = 27; + +#[repr(C)] +pub struct DirEntry { + name: [u8; NAME_LENGTH_LIMIT + 1], + inode_number: u32, +} + +pub const DIRENT_SZ: usize = 32; +``` + +目录项 `Dirent` 最大允许保存长度为 27 的文件/目录名(数组 `name` 中最末的一个字节留给 `\0` ),且它自身占据空间 32 字节,每个数据块可以存储 16 个目录项。我们可以通过 `empty` 和 `new` 分别生成一个空的目录项或是一个合法的目录项: + +```rust +// easy-fs/src/layout.rs + +impl DirEntry { + pub fn empty() -> Self { + Self { + name: [0u8; NAME_LENGTH_LIMIT + 1], + inode_number: 0, + } + } + pub fn new(name: &str, inode_number: u32) -> Self { + let mut bytes = [0u8; NAME_LENGTH_LIMIT + 1]; + &mut bytes[..name.len()].copy_from_slice(name.as_bytes()); + Self { + name: bytes, + inode_number, + } + } +} +``` + +在从目录的内容中读取目录项或者是将目录项写入目录的时候,我们需要将目录项转化为缓冲区(即字节切片)的形式来符合索引节点 `Inode` 数据结构中的 `read_at` 或 `write_at` 方法接口的要求: + +```rust +// easy-fs/src/layout.rs + +impl DirEntry { + pub fn as_bytes(&self) -> &[u8] { + unsafe { + core::slice::from_raw_parts( + self as *const _ as usize as *const u8, + DIRENT_SZ, + ) + } + } + pub fn as_bytes_mut(&mut self) -> &mut [u8] { + unsafe { + core::slice::from_raw_parts_mut( + self as *mut _ as usize as *mut u8, + DIRENT_SZ, + ) + } + } +} +``` + +此外,通过 `name` 和 `inode_number` 方法可以取出目录项中的内容: + +```rust +// easy-fs/src/layout.rs + +impl DirEntry { + pub fn name(&self) -> &str { + let len = (0usize..).find(|i| self.name[*i] == 0).unwrap(); + core::str::from_utf8(&self.name[..len]).unwrap() + } + pub fn inode_number(&self) -> u32 { + self.inode_number + } +} +``` + +## 磁盘块管理器 + +本层的代码在 `efs.rs` 中。 +上面介绍了 easy-fs 的磁盘布局设计以及数据的组织方式 -- 即各类磁盘数据结构。但是它们都是以比较零散的形式分开介绍的,并没有体现出磁盘布局上各个区域是如何划分的。实现 easy-fs 的整体磁盘布局,将各段区域及上面的磁盘数据结构整合起来就是简易文件系统 `EasyFileSystem` 的职责。它知道每个布局区域所在的位置,磁盘块的分配和回收也需要经过它才能完成,因此某种意义上讲它还可以看成一个磁盘块管理器。 + +注意从这一层开始,所有的数据结构就都放在内存上了。 + +```rust +// easy-fs/src/efs.rs + +pub struct EasyFileSystem { + pub block_device: Arc, + pub inode_bitmap: Bitmap, + pub data_bitmap: Bitmap, + inode_area_start_block: u32, + data_area_start_block: u32, +} +``` + +`EasyFileSystem` 包含索引节点和数据块的两个位图 `inode_bitmap` 和 `data_bitmap` ,还记录下索引节点区域和数据块区域起始块编号方便确定每个索引节点和数据块在磁盘上的具体位置。我们还要在其中保留块设备的一个指针 `block_device` ,在进行后续操作的时候,该指针会被拷贝并传递给下层的数据结构,让它们也能够直接访问块设备。 + +通过 `create` 方法可以在块设备上创建并初始化一个 easy-fs 文件系统: + +```rust +// easy-fs/src/efs.rs + +impl EasyFileSystem { + pub fn create( + block_device: Arc, + total_blocks: u32, + inode_bitmap_blocks: u32, + ) -> Arc> { + // calculate block size of areas & create bitmaps + let inode_bitmap = Bitmap::new(1, inode_bitmap_blocks as usize); + let inode_num = inode_bitmap.maximum(); + let inode_area_blocks = + ((inode_num * core::mem::size_of::() + BLOCK_SZ - 1) / BLOCK_SZ) as u32; + let inode_total_blocks = inode_bitmap_blocks + inode_area_blocks; + let data_total_blocks = total_blocks - 1 - inode_total_blocks; + let data_bitmap_blocks = (data_total_blocks + 4096) / 4097; + let data_area_blocks = data_total_blocks - data_bitmap_blocks; + let data_bitmap = Bitmap::new( + (1 + inode_bitmap_blocks + inode_area_blocks) as usize, + data_bitmap_blocks as usize, + ); + let mut efs = Self { + block_device: Arc::clone(&block_device), + inode_bitmap, + data_bitmap, + inode_area_start_block: 1 + inode_bitmap_blocks, + data_area_start_block: 1 + inode_total_blocks + data_bitmap_blocks, + }; + // clear all blocks + for i in 0..total_blocks { + get_block_cache( + i as usize, + Arc::clone(&block_device) + ) + .lock() + .modify(0, |data_block: &mut DataBlock| { + for byte in data_block.iter_mut() { *byte = 0; } + }); + } + // initialize SuperBlock + get_block_cache(0, Arc::clone(&block_device)) + .lock() + .modify(0, |super_block: &mut SuperBlock| { + super_block.initialize( + total_blocks, + inode_bitmap_blocks, + inode_area_blocks, + data_bitmap_blocks, + data_area_blocks, + ); + }); + // write back immediately + // create a inode for root node "/" + assert_eq!(efs.alloc_inode(), 0); + let (root_inode_block_id, root_inode_offset) = efs.get_disk_inode_pos(0); + get_block_cache( + root_inode_block_id as usize, + Arc::clone(&block_device) + ) + .lock() + .modify(root_inode_offset, |disk_inode: &mut DiskInode| { + disk_inode.initialize(DiskInodeType::Directory); + }); + Arc::new(Mutex::new(efs)) + } +} +``` + +- 第 10~21 行根据传入的参数计算每个区域各应该包含多少块。根据 inode 位图的大小计算 inode 区域至少需要多少个块才能够使得 inode 位图中的每个bit都能够有一个实际的 inode 可以对应,这样就确定了 inode 位图区域和 inode 区域的大小。剩下的块都分配给数据块位图区域和数据块区域。我们希望数据块位图中的每个bit仍然能够对应到一个数据块,但是数据块位图又不能过小,不然会造成某些数据块永远不会被使用。因此数据块位图区域最合理的大小是剩余的块数除以 4097 再上取整,因为位图中的每个块能够对应 4096 个数据块。其余的块就都作为数据块使用。 +- 第 22 行创建 `EasyFileSystem` 实例 `efs` 。 +- 第 30 行首先将块设备的前 `total_blocks` 个块清零,因为 easy-fs 要用到它们,这也是为初始化做准备。 +- 第 41 行将位于块设备编号为 0 块上的超级块进行初始化,只需传入之前计算得到的每个区域的块数就行了。 +- 第 54~63 行创建根目录 `/` 。首先需要调用 `alloc_inode` 在 inode 位图中分配一个 inode ,由于这是第一次分配,它的编号固定是 0 。接下来需要将分配到的 inode 初始化为 easy-fs 中的唯一一个目录,故需要调用 `get_disk_inode_pos` 来根据 inode 编号获取该 inode 所在的块的编号以及块内偏移,之后就可以将它们传给 `get_block_cache` 和 `modify` 了。 + +通过 `open` 方法可以从一个已写入了 easy-fs 镜像的块设备上打开我们的 easy-fs : + +```rust +// easy-fs/src/efs.rs + +impl EasyFileSystem { + pub fn open(block_device: Arc) -> Arc> { + // read SuperBlock + get_block_cache(0, Arc::clone(&block_device)) + .lock() + .read(0, |super_block: &SuperBlock| { + assert!(super_block.is_valid(), "Error loading EFS!"); + let inode_total_blocks = + super_block.inode_bitmap_blocks + super_block.inode_area_blocks; + let efs = Self { + block_device, + inode_bitmap: Bitmap::new( + 1, + super_block.inode_bitmap_blocks as usize + ), + data_bitmap: Bitmap::new( + (1 + inode_total_blocks) as usize, + super_block.data_bitmap_blocks as usize, + ), + inode_area_start_block: 1 + super_block.inode_bitmap_blocks, + data_area_start_block: 1 + inode_total_blocks + super_block.data_bitmap_blocks, + }; + Arc::new(Mutex::new(efs)) + }) + } +} +``` + +它只需将块设备编号为 0 的块作为超级块读取进来,就可以从中知道 easy-fs 的磁盘布局,由此可以构造 `efs` 实例。 + +`EasyFileSystem` 知道整个磁盘布局,即可以从 inode位图 或数据块位图上分配的 bit 编号,来算出各个存储inode和数据块的磁盘块在磁盘上的实际位置。 + +```rust +// easy-fs/src/efs.rs + +impl EasyFileSystem { + pub fn get_disk_inode_pos(&self, inode_id: u32) -> (u32, usize) { + let inode_size = core::mem::size_of::(); + let inodes_per_block = (BLOCK_SZ / inode_size) as u32; + let block_id = self.inode_area_start_block + inode_id / inodes_per_block; + (block_id, (inode_id % inodes_per_block) as usize * inode_size) + } + + pub fn get_data_block_id(&self, data_block_id: u32) -> u32 { + self.data_area_start_block + data_block_id + } +} +``` + +inode 和数据块的分配/回收也由 `EasyFileSystem` 负责: + +```rust +// easy-fs/src/efs.rs + +impl EasyFileSystem { + pub fn alloc_inode(&mut self) -> u32 { + self.inode_bitmap.alloc(&self.block_device).unwrap() as u32 + } + + /// Return a block ID not ID in the data area. + pub fn alloc_data(&mut self) -> u32 { + self.data_bitmap.alloc(&self.block_device).unwrap() as u32 + self.data_area_start_block + } + + pub fn dealloc_data(&mut self, block_id: u32) { + get_block_cache( + block_id as usize, + Arc::clone(&self.block_device) + ) + .lock() + .modify(0, |data_block: &mut DataBlock| { + data_block.iter_mut().for_each(|p| { *p = 0; }) + }); + self.data_bitmap.dealloc( + &self.block_device, + (block_id - self.data_area_start_block) as usize + ) + } +} +``` + +注意: + +- `alloc_data` 和 `dealloc_data` 分配/回收数据块传入/返回的参数都表示数据块在块设备上的编号,而不是在数据块位图中分配的bit编号; +- `dealloc_inode` 未实现,因为现在还不支持文件删除。 + +## 索引节点 + +服务于文件相关系统调用的索引节点层的代码在 `vfs.rs` 中。 + +`EasyFileSystem` 实现了磁盘布局并能够将磁盘块有效的管理起来。但是对于文件系统的使用者而言,他们往往不关心磁盘布局是如何实现的,而是更希望能够直接看到目录树结构中逻辑上的文件和目录。为此需要设计索引节点 `Inode` 暴露给文件系统的使用者,让他们能够直接对文件和目录进行操作。 `Inode` 和 `DiskInode` 的区别从它们的名字中就可以看出: `DiskInode` 放在磁盘块中比较固定的位置,而 `Inode` 是放在内存中的记录文件索引节点信息的数据结构。 + +```rust +// easy-fs/src/vfs.rs + +pub struct Inode { + block_id: usize, + block_offset: usize, + fs: Arc>, + block_device: Arc, +} +``` + +`block_id` 和 `block_offset` 记录该 `Inode` 对应的 `DiskInode` 保存在磁盘上的具体位置方便我们后续对它进行访问。 `fs` 是指向 `EasyFileSystem` 的一个指针,因为对 `Inode` 的种种操作实际上都是要通过底层的文件系统来完成。 + +仿照 `BlockCache::read/modify` ,我们可以设计两个方法来简化对于 `Inode` 对应的磁盘上的 `DiskInode` 的访问流程,而不是每次都需要 `get_block_cache.lock.read/modify` : + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + fn read_disk_inode(&self, f: impl FnOnce(&DiskInode) -> V) -> V { + get_block_cache( + self.block_id, + Arc::clone(&self.block_device) + ).lock().read(self.block_offset, f) + } + + fn modify_disk_inode(&self, f: impl FnOnce(&mut DiskInode) -> V) -> V { + get_block_cache( + self.block_id, + Arc::clone(&self.block_device) + ).lock().modify(self.block_offset, f) + } +} +``` + +下面分别介绍文件系统的使用者对于文件系统的一些常用操作: + +### 获取根目录的 inode + +文件系统的使用者在通过 `EasyFileSystem::open` 从装载了 easy-fs 镜像的块设备上打开 easy-fs 之后,要做的第一件事情就是获取根目录的 `Inode` 。因为 `EasyFileSystem` 目前仅支持绝对路径,对于任何文件/目录的索引都必须从根目录开始向下逐级进行。等到索引完成之后, `EasyFileSystem` 才能对文件/目录进行操作。事实上 `EasyFileSystem` 提供了另一个名为 `root_inode` 的方法来获取根目录的 `Inode` : + +```rust +// easy-fs/src/efs.rs + +impl EasyFileSystem { + pub fn root_inode(efs: &Arc>) -> Inode { + let block_device = Arc::clone(&efs.lock().block_device); + // acquire efs lock temporarily + let (block_id, block_offset) = efs.lock().get_disk_inode_pos(0); + // release efs lock + Inode::new( + block_id, + block_offset, + Arc::clone(efs), + block_device, + ) + } +} + +// easy-fs/src/vfs.rs + +impl Inode { + /// We should not acquire efs lock here. + pub fn new( + block_id: u32, + block_offset: usize, + fs: Arc>, + block_device: Arc, + ) -> Self { + Self { + block_id: block_id as usize, + block_offset, + fs, + block_device, + } + } +} +``` + +对于 `root_inode` 的初始化,是在调用 `Inode::new` 时将传入的 `inode_id` 设置为 0 ,因为根目录对应于文件系统中第一个分配的 inode ,因此它的 `inode_id` 总会是 0 。不会在调用 `Inode::new` 过程中尝试获取整个 `EasyFileSystem` 的锁来查询 inode 在块设备中的位置,而是在调用它之前预先查询并作为参数传过去。 + +### 文件索引 + +[前面](1fs-interface.md#fs-simplification) 提到过,为了尽可能简化文件系统设计, `EasyFileSystem` 是一个扁平化的文件系统,即在目录树上仅有一个目录——那就是作为根节点的根目录。所有的文件都在根目录下面。于是,我们不必实现目录索引。文件索引的查找比较简单,仅需在根目录的目录项中根据文件名找到文件的 inode 编号即可。由于没有子目录的存在,这个过程只会进行一次。 + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + pub fn find(&self, name: &str) -> Option> { + let fs = self.fs.lock(); + self.read_disk_inode(|disk_inode| { + self.find_inode_id(name, disk_inode) + .map(|inode_id| { + let (block_id, block_offset) = fs.get_disk_inode_pos(inode_id); + Arc::new(Self::new( + block_id, + block_offset, + self.fs.clone(), + self.block_device.clone(), + )) + }) + }) + } + + fn find_inode_id( + &self, + name: &str, + disk_inode: &DiskInode, + ) -> Option { + // assert it is a directory + assert!(disk_inode.is_dir()); + let file_count = (disk_inode.size as usize) / DIRENT_SZ; + let mut dirent = DirEntry::empty(); + for i in 0..file_count { + assert_eq!( + disk_inode.read_at( + DIRENT_SZ * i, + dirent.as_bytes_mut(), + &self.block_device, + ), + DIRENT_SZ, + ); + if dirent.name() == name { + return Some(dirent.inode_number() as u32); + } + } + None + } +} +``` + +`find` 方法只会被根目录 `Inode` 调用,文件系统中其他文件的 `Inode` 不会调用这个方法。它首先调用 `find_inode_id` 方法,尝试从根目录的 `DiskInode` 上找到要索引的文件名对应的 inode 编号。这就需要将根目录内容中的所有目录项都读到内存进行逐个比对。如果能够找到,则 `find` 方法会根据查到 inode 编号,对应生成一个 `Inode` 用于后续对文件的访问。 + +这里需要注意,包括 `find` 在内,所有暴露给文件系统的使用者的文件系统操作(还包括接下来将要介绍的几种),全程均需持有 `EasyFileSystem` 的互斥锁(相对而言,文件系统内部的操作,如之前的 `Inode::new` 或是上面的 `find_inode_id` ,都是假定在已持有 efs 锁的情况下才被调用的,因此它们不应尝试获取锁)。这能够保证在多核情况下,同时最多只能有一个核在进行文件系统相关操作。这样也许会带来一些不必要的性能损失,但我们目前暂时先这样做。如果我们在这里加锁的话,其实就能够保证块缓存的互斥访问了。 + +### 文件列举 + +`ls` 方法可以收集根目录下的所有文件的文件名并以向量的形式返回,这个方法只有根目录的 `Inode` 才会调用: + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + pub fn ls(&self) -> Vec { + let _fs = self.fs.lock(); + self.read_disk_inode(|disk_inode| { + let file_count = (disk_inode.size as usize) / DIRENT_SZ; + let mut v: Vec = Vec::new(); + for i in 0..file_count { + let mut dirent = DirEntry::empty(); + assert_eq!( + disk_inode.read_at( + i * DIRENT_SZ, + dirent.as_bytes_mut(), + &self.block_device, + ), + DIRENT_SZ, + ); + v.push(String::from(dirent.name())); + } + v + }) + } +} +``` + +#### NOTE + +**Rust 语法卡片: \_ 在匹配中的使用方法** + +可以看到在 `ls` 操作中,我们虽然获取了 efs 锁,但是这里并不会直接访问 `EasyFileSystem` 实例,其目的仅仅是锁住该实例避免其他核在同时间的访问造成并发冲突。因此,我们将其绑定到以 `_` 开头的变量 `_fs` 中,这样即使我们在其作用域中并没有使用它,编译器也不会报警告。然而,我们不能将其绑定到变量 `_` 上。因为从匹配规则可以知道这意味着该操作会被编译器丢弃,从而无法达到获取锁的效果。 + +### 文件创建 + +`create` 方法可以在根目录下创建一个文件,该方法只有根目录的 `Inode` 会调用: + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + pub fn create(&self, name: &str) -> Option> { + let mut fs = self.fs.lock(); + if self.modify_disk_inode(|root_inode| { + // assert it is a directory + assert!(root_inode.is_dir()); + // has the file been created? + self.find_inode_id(name, root_inode) + }).is_some() { + return None; + } + // create a new file + // alloc a inode with an indirect block + let new_inode_id = fs.alloc_inode(); + // initialize inode + let (new_inode_block_id, new_inode_block_offset) + = fs.get_disk_inode_pos(new_inode_id); + get_block_cache( + new_inode_block_id as usize, + Arc::clone(&self.block_device) + ).lock().modify(new_inode_block_offset, |new_inode: &mut DiskInode| { + new_inode.initialize(DiskInodeType::File); + }); + self.modify_disk_inode(|root_inode| { + // append file in the dirent + let file_count = (root_inode.size as usize) / DIRENT_SZ; + let new_size = (file_count + 1) * DIRENT_SZ; + // increase size + self.increase_size(new_size as u32, root_inode, &mut fs); + // write dirent + let dirent = DirEntry::new(name, new_inode_id); + root_inode.write_at( + file_count * DIRENT_SZ, + dirent.as_bytes(), + &self.block_device, + ); + }); + + let (block_id, block_offset) = fs.get_disk_inode_pos(new_inode_id); + // return inode + Some(Arc::new(Self::new( + block_id, + block_offset, + self.fs.clone(), + self.block_device.clone(), + ))) + // release efs lock automatically by compiler + } +} +``` + +- 第 6~13 行,检查文件是否已经在根目录下,如果找到的话返回 `None` ; +- 第 14~25 行,为待创建文件分配一个新的 inode 并进行初始化; +- 第 26~39 行,将待创建文件的目录项插入到根目录的内容中,使得之后可以索引到。 + +### 文件清空 + +在以某些标志位打开文件(例如带有 *CREATE* 标志打开一个已经存在的文件)的时候,需要首先将文件清空。在索引到文件的 `Inode` 之后,可以调用 `clear` 方法: + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + pub fn clear(&self) { + let mut fs = self.fs.lock(); + self.modify_disk_inode(|disk_inode| { + let size = disk_inode.size; + let data_blocks_dealloc = disk_inode.clear_size(&self.block_device); + assert!(data_blocks_dealloc.len() == DiskInode::total_blocks(size) as usize); + for data_block in data_blocks_dealloc.into_iter() { + fs.dealloc_data(data_block); + } + }); + } +} +``` + +这会将该文件占据的索引块和数据块回收。 + +### 文件读写 + +从根目录索引到一个文件之后,可以对它进行读写。注意:和 `DiskInode` 一样,这里的读写作用在字节序列的一段区间上: + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + pub fn read_at(&self, offset: usize, buf: &mut [u8]) -> usize { + let _fs = self.fs.lock(); + self.read_disk_inode(|disk_inode| { + disk_inode.read_at(offset, buf, &self.block_device) + }) + } + + pub fn write_at(&self, offset: usize, buf: &[u8]) -> usize { + let mut fs = self.fs.lock(); + self.modify_disk_inode(|disk_inode| { + self.increase_size((offset + buf.len()) as u32, disk_inode, &mut fs); + disk_inode.write_at(offset, buf, &self.block_device) + }) + } +} +``` + +具体实现比较简单,需要注意在执行 `DiskInode::write_at` 之前先调用 `increase_size` 对自身进行扩容: + +```rust +// easy-fs/src/vfs.rs + +impl Inode { + fn increase_size( + &self, + new_size: u32, + disk_inode: &mut DiskInode, + fs: &mut MutexGuard, + ) { + if new_size < disk_inode.size { + return; + } + let blocks_needed = disk_inode.blocks_num_needed(new_size); + let mut v: Vec = Vec::new(); + for _ in 0..blocks_needed { + v.push(fs.alloc_data()); + } + disk_inode.increase_size(new_size, v, &self.block_device); + } +} +``` + +这里会从 `EasyFileSystem` 中分配一些用于扩容的数据块并传给 `DiskInode::increase_size` 。 + +## 在用户态测试 easy-fs 的功能 + +`easy-fs` 架构设计的一个优点在于它可以在Rust应用开发环境(Windows/macOS/Ubuntu)中,按照应用程序库的开发方式来进行测试,不必过早的放到内核中测试运行。众所周知,内核运行在裸机环境上,对其进行调试很困难。而面向应用的开发环境对于调试的支持更为完善,从基于命令行的 GDB 到 IDE 提供的图形化调试界面都能给文件系统的开发带来很大帮助。另外一点是,由于 `easy-fs` 需要放到在裸机上运行的内核中,使得 `easy-fs` 只能使用 `no_std` 模式,不能在 `easy-fs` 中调用标准库 `std` 。但是在把 `easy-fs` 作为一个应用的库运行的时候,可以暂时让使用它的应用程序调用标准库 `std` ,这也会在开发调试上带来一些方便。 + +`easy-fs` 的测试放在另一个名为 `easy-fs-fuse` 的应用程序中,不同于 `easy-fs` ,它是一个可以调用标准库 `std` 的应用程序 ,能够在Rust应用开发环境上运行并很容易调试。 + +### 在Rust应用开发环境中模拟块设备 + +从文件系统的使用者角度来看,它仅需要提供一个实现了 `BlockDevice` Trait 的块设备用来装载文件系统,之后就可以使用 `Inode` 来方便地进行文件系统操作了。但是在开发环境上,我们如何来提供这样一个块设备呢?答案是用 Linux (当然也可以是Windows/MacOS等其它通用操作系统)上的一个文件模拟一个块设备。 + +```rust +// easy-fs-fuse/src/main.rs + +use std::fs::File; +use easy-fs::BlockDevice; + +const BLOCK_SZ: usize = 512; + +struct BlockFile(Mutex); + +impl BlockDevice for BlockFile { + fn read_block(&self, block_id: usize, buf: &mut [u8]) { + let mut file = self.0.lock().unwrap(); + file.seek(SeekFrom::Start((block_id * BLOCK_SZ) as u64)) + .expect("Error when seeking!"); + assert_eq!(file.read(buf).unwrap(), BLOCK_SZ, "Not a complete block!"); + } + + fn write_block(&self, block_id: usize, buf: &[u8]) { + let mut file = self.0.lock().unwrap(); + file.seek(SeekFrom::Start((block_id * BLOCK_SZ) as u64)) + .expect("Error when seeking!"); + assert_eq!(file.write(buf).unwrap(), BLOCK_SZ, "Not a complete block!"); + } +} +``` + +`std::file::File` 由 Rust 标准库 std 提供,可以访问 Linux 上的一个文件。我们将它包装成 `BlockFile` 类型来模拟一块磁盘,为它实现 `BlockDevice` 接口。注意 `File` 本身仅通过 `read/write` 接口是不能实现随机读写的,在访问一个特定的块的时候,我们必须先 `seek` 到这个块的开头位置。 + +测试主函数为 `easy-fs-fuse/src/main.rs` 中的 `efs_test` 函数中,我们只需在 `easy-fs-fuse` 目录下 `cargo test` 即可执行该测试: + +```default +running 1 test +test efs_test ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.27s +``` + +看到上面的内容就说明测试通过了。 + +`efs_test` 展示了 `easy-fs` 库的使用方法,大致分成以下几个步骤: + +### 打开块设备 + +```rust +let block_file = Arc::new(BlockFile(Mutex::new({ + let f = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open("target/fs.img")?; + f.set_len(8192 * 512).unwrap(); + f +}))); +EasyFileSystem::create( + block_file.clone(), + 4096, + 1, +); +``` + +第一步我们需要打开虚拟块设备。这里我们在 Linux 上创建文件 `easy-fs-fuse/target/fs.img` 来新建一个虚拟块设备,并将它的容量设置为 8192 个块即 4MiB 。在创建的时候需要将它的访问权限设置为可读可写。 + +由于我们在进行测试,需要初始化测试环境,因此在虚拟块设备 `block_file` 上初始化 easy-fs 文件系统,这会将 `block_file` 用于放置 easy-fs 镜像的前 4096 个块上的数据覆盖,然后变成仅有一个根目录的初始文件系统。如果块设备上已经放置了一个合法的 easy-fs 镜像,则我们不必这样做。 + +### 从块设备上打开文件系统 + +```rust +let efs = EasyFileSystem::open(block_file.clone()); +``` + +这是通常进行的第二个步骤。 + +### 获取根目录的 Inode + +```rust +let root_inode = EasyFileSystem::root_inode(&efs); +``` + +这是通常进行的第三个步骤。 + +### 进行各种文件操作 + +拿到根目录 `root_inode` 之后,可以通过它进行各种文件操作,目前支持以下几种: + +- 通过 `create` 创建文件。 +- 通过 `ls` 列举根目录下的文件。 +- 通过 `find` 根据文件名索引文件。 + +当通过索引获取根目录下的一个文件的 inode 之后则可以进行如下操作: + +- 通过 `clear` 将文件内容清空。 +- 通过 `read/write_at` 读写文件,注意我们需要将读写在文件中开始的位置 `offset` 作为一个参数传递进去。 + +测试方法在这里不详细介绍,大概是每次清空文件 `filea` 的内容,向其中写入一个不同长度的随机数字字符串,然后再全部读取出来,验证和写入的内容一致。其中有一个细节是:用来生成随机字符串的 `rand` crate 并不支持 `no_std` ,因此只有在用户态我们才能更容易进行测试。 + +## 将应用打包为 easy-fs 镜像 + +在第六章中我们需要将所有的应用都链接到内核中,随后在应用管理器中通过应用名进行索引来找到应用的 ELF 数据。这样做有一个缺点,就是会造成内核体积过度膨胀。在 k210 平台上可以很明显的感觉到从第五章开始随着应用数量的增加,向开发板上烧写内核镜像的耗时显著增长。同时这也会浪费内存资源,因为未被执行的应用也占据了内存空间。在实现了 easy-fs 文件系统之后,终于可以将这些应用打包到 easy-fs 镜像中放到磁盘中,当我们要执行应用的时候只需从文件系统中取出ELF 执行文件格式的应用 并加载到内存中执行即可,这样就避免了前面章节的存储开销等问题。 + +`easy-fs-fuse` 的主体 `easy-fs-pack` 函数就实现了这个功能: + +```rust +// easy-fs-fuse/src/main.rs + +use clap::{Arg, App}; + +fn easy_fs_pack() -> std::io::Result<()> { + let matches = App::new("EasyFileSystem packer") + .arg(Arg::with_name("source") + .short("s") + .long("source") + .takes_value(true) + .help("Executable source dir(with backslash)") + ) + .arg(Arg::with_name("target") + .short("t") + .long("target") + .takes_value(true) + .help("Executable target dir(with backslash)") + ) + .get_matches(); + let src_path = matches.value_of("source").unwrap(); + let target_path = matches.value_of("target").unwrap(); + println!("src_path = {}\ntarget_path = {}", src_path, target_path); + let block_file = Arc::new(BlockFile(Mutex::new({ + let f = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(format!("{}{}", target_path, "fs.img"))?; + f.set_len(8192 * 512).unwrap(); + f + }))); + // 4MiB, at most 4095 files + let efs = EasyFileSystem::create( + block_file.clone(), + 8192, + 1, + ); + let root_inode = Arc::new(EasyFileSystem::root_inode(&efs)); + let apps: Vec<_> = read_dir(src_path) + .unwrap() + .into_iter() + .map(|dir_entry| { + let mut name_with_ext = dir_entry.unwrap().file_name().into_string().unwrap(); + name_with_ext.drain(name_with_ext.find('.').unwrap()..name_with_ext.len()); + name_with_ext + }) + .collect(); + for app in apps { + // load app data from host file system + let mut host_file = File::open(format!("{}{}", target_path, app)).unwrap(); + let mut all_data: Vec = Vec::new(); + host_file.read_to_end(&mut all_data).unwrap(); + // create a file in easy-fs + let inode = root_inode.create(app.as_str()).unwrap(); + // write data to easy-fs + inode.write_at(0, all_data.as_slice()); + } + // list apps + for app in root_inode.ls() { + println!("{}", app); + } + Ok(()) +} +``` + +- 为了实现 `easy-fs-fuse` 和 `os/user` 的解耦,第 6~21 行使用 `clap` crate 进行命令行参数解析,需要通过 `-s` 和 `-t` 分别指定应用的源代码目录和保存应用 ELF 的目录,而不是在 `easy-fs-fuse` 中硬编码。如果解析成功的话它们会分别被保存在变量 `src_path` 和 `target_path` 中。 +- 第 23~38 行依次完成:创建 4MiB 的 easy-fs 镜像文件、进行 easy-fs 初始化、获取根目录 inode 。 +- 第 39 行获取源码目录中的每个应用的源代码文件并去掉后缀名,收集到向量 `apps` 中。 +- 第 48 行开始,枚举 `apps` 中的每个应用,从放置应用执行程序的目录中找到对应应用的 ELF 文件(这是一个 Linux 上的文件),并将数据读入内存。接着需要在 easy-fs 中创建一个同名文件并将 ELF 数据写入到这个文件中。这个过程相当于将 Linux 上的文件系统中的一个文件复制到我们的 easy-fs 中。 + +尽管没有进行任何同步写回磁盘的操作,我们也不用担心块缓存中的修改没有写回磁盘。因为在 `easy-fs-fuse` 这个应用正常退出的过程中,块缓存因生命周期结束会被回收,届时如果块缓存的 `modified` 标志为 true ,就会将其修改写回磁盘。 diff --git a/ch6/book/3using-easy-fs-in-kernel.md b/ch6/book/3using-easy-fs-in-kernel.md new file mode 100644 index 00000000..c06bcd9a --- /dev/null +++ b/ch6/book/3using-easy-fs-in-kernel.md @@ -0,0 +1,669 @@ +# 在内核中接入 easy-fs + +## 本节导读 + +上节实现了 `easy-fs` 文件系统,并能在用户态来进行测试,但还没有放入到内核中来。本节我们介绍如何将 `easy-fs` 文件系统接入内核中从而在内核中支持常规文件和目录。为此,在操作系统内核中需要有对接 `easy-fs` 文件系统的各种结构,它们自下而上可以分成这样几个层次: + +- 块设备驱动层:针对内核所要运行在的 qemu 或 k210 平台,我们需要将平台上的块设备驱动起来并实现 `easy-fs` 所需的 `BlockDevice` Trait ,这样 `easy-fs` 才能将该块设备用作 easy-fs 镜像的载体。 +- `easy-fs` 层:我们在上一节已经介绍了 `easy-fs` 文件系统内部的层次划分。这里是站在内核的角度,只需知道它接受一个块设备 `BlockDevice` ,并可以在上面打开文件系统 `EasyFileSystem` ,进而获取 `Inode` 核心数据结构,进行各种文件系统操作即可。 +- 内核索引节点层:在内核中需要将 `easy-fs` 提供的 `Inode` 进一步封装成 `OSInode` ,以表示进程中一个打开的常规文件。由于有很多种不同的打开方式,因此在 `OSInode` 中要维护一些额外的信息。 +- 文件描述符层:常规文件对应的 `OSInode` 是文件的内核内部表示,因此需要为它实现 `File` Trait 从而能够可以将它放入到进程文件描述符表中并通过 `sys_read/write` 系统调用进行读写。 +- 系统调用层:由于引入了常规文件这种文件类型,导致一些系统调用以及相关的内核机制需要进行一定的修改。 + +## 文件简介 + +应用程序看到并被操作系统管理的 **文件** (File) 就是一系列的字节组合。操作系统不关心文件内容,只关心如何对文件按字节流进行读写的机制,这就意味着任何程序可以读写任何文件(即字节流),对文件具体内容的解析是应用程序的任务,操作系统对此不做任何干涉。例如,一个Rust编译器可以读取一个C语言源程序并进行编译,操作系统并并不会阻止这样的事情发生。 + +有了文件这样的抽象后,操作系统内核就可把能读写并持久存储的数据按文件来进行管理,并把文件分配给进程,让进程以很简洁的统一抽象接口 `File` 来读写数据: + +```rust +// os/src/fs/mod.rs + +pub trait File : Send + Sync { + fn read(&self, buf: UserBuffer) -> usize; + fn write(&self, buf: UserBuffer) -> usize; +} +``` + +这个接口在内存和存储设备之间建立了数据交换的通道。其中 `UserBuffer` 是我们在 `mm` 子模块中定义的应用地址空间中的一段缓冲区(即内存)的抽象。它的具体实现在本质上其实只是一个 `&[u8]` ,位于应用地址空间中,内核无法直接通过用户地址空间的虚拟地址来访问,因此需要进行封装。然而,在理解抽象接口 `File` 的各方法时,我们仍可以将 `UserBuffer` 看成一个 `&[u8]` 切片,它是一个同时给出了缓冲区起始地址和长度的胖指针。 + +`read` 指的是从文件中读取数据放到缓冲区中,最多将缓冲区填满(即读取缓冲区的长度那么多字节),并返回实际读取的字节数;而 `write` 指的是将缓冲区中的数据写入文件,最多将缓冲区中的数据全部写入,并返回直接写入的字节数。至于 `read` 和 `write` 的实现则与文件具体是哪种类型有关,它决定了数据如何被读取和写入。 + +回过头来再看一下用户缓冲区的抽象 `UserBuffer` ,它的声明如下: + +```rust +// os/src/mm/page_table.rs + +pub fn translated_byte_buffer( + token: usize, + ptr: *const u8, + len: usize +) -> Vec<&'static mut [u8]>; + +pub struct UserBuffer { + pub buffers: Vec<&'static mut [u8]>, +} + +impl UserBuffer { + pub fn new(buffers: Vec<&'static mut [u8]>) -> Self { + Self { buffers } + } + pub fn len(&self) -> usize { + let mut total: usize = 0; + for b in self.buffers.iter() { + total += b.len(); + } + total + } +} +``` + +它只是将我们调用 `translated_byte_buffer` 获得的包含多个切片的 `Vec` 进一步包装起来,通过 `len` 方法可以得到缓冲区的长度。此外,我们还让它作为一个迭代器可以逐字节进行读写。有兴趣的同学可以参考类型 `UserBufferIterator` 还有 `IntoIterator` 和 `Iterator` 两个 Trait 的使用方法。 + +## 块设备驱动层 + +在 `drivers` 子模块中的 `block/mod.rs` 中,我们可以找到内核访问的块设备实例 `BLOCK_DEVICE` : + +```rust +// os/drivers/block/mod.rs + +#[cfg(feature = "board_qemu")] +type BlockDeviceImpl = virtio_blk::VirtIOBlock; + +#[cfg(feature = "board_k210")] +type BlockDeviceImpl = sdcard::SDCardWrapper; + +lazy_static! { + pub static ref BLOCK_DEVICE: Arc = Arc::new(BlockDeviceImpl::new()); +} +``` + +qemu 和 k210 平台上的块设备是不同的。在 qemu 上,我们使用 `VirtIOBlock` 访问 VirtIO 块设备;而在 k210 上,我们使用 `SDCardWrapper` 来访问插入 k210 开发板上真实的 microSD 卡,它们都实现了 `easy-fs` 要求的 `BlockDevice` Trait 。通过 `#[cfg(feature)]` 可以在编译的时候根据编译参数调整 `BlockDeviceImpl` 具体为哪个块设备,之后将它全局实例化为 `BLOCK_DEVICE` ,使得内核的其他模块可以访问。 + +### Qemu 模拟器平台 + +在启动 Qemu 模拟器的时候,我们可以配置参数来添加一块 VirtIO 块设备: + +```makefile +# os/Makefile + +FS_IMG := ../user/target/$(TARGET)/$(MODE)/fs.img + +run-inner: build +ifeq ($(BOARD),qemu) + @qemu-system-riscv64 \ + -machine virt \ + -nographic \ + -bios $(BOOTLOADER) \ + -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \ + -drive file=$(FS_IMG),if=none,format=raw,id=x0 \ + -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 +``` + +- 第 12 行,我们为虚拟机添加一块虚拟硬盘,内容为我们之前通过 `easy-fs-fuse` 工具打包的包含应用 ELF 的 easy-fs 镜像,并命名为 `x0` 。 +- 第 13 行,我们将硬盘 `x0` 作为一个 VirtIO 总线中的一个块设备接入到虚拟机系统中。 `virtio-mmio-bus.0` 表示 VirtIO 总线通过 MMIO 进行控制,且该块设备在总线中的编号为 0 。 + +**内存映射 I/O** (MMIO, Memory-Mapped I/O) 指的是外设的设备寄存器可以通过特定的物理内存地址来访问,每个外设的设备寄存器都分布在没有交集的一个或数个物理地址区间中,不同外设的设备寄存器所占的物理地址空间也不会产生交集,且这些外设物理地址区间也不会和RAM的物理内存所在的区间存在交集(注:在后续的外设相关章节有更深入的讲解)。从Qemu for RISC-V 64 平台的 [源码](https://github.com/qemu/qemu/blob/f1dd640896ee2b50cb34328f2568aad324702954/hw/riscv/virt.c#L83) 中可以找到 VirtIO 外设总线的 MMIO 物理地址区间为从 0x10001000 开头的 4KiB 。为了能够在内核中访问 VirtIO 外设总线,我们就必须在内核地址空间中对特定内存区域提前进行映射: + +```rust +// os/src/config.rs + +#[cfg(feature = "board_qemu")] +pub const MMIO: &[(usize, usize)] = &[ + (0x10001000, 0x1000), +]; +``` + +如上面一段代码所示,在 `config` 子模块中我们硬编码 Qemu 上的 VirtIO 总线的 MMIO 地址区间(起始地址,长度)。在创建内核地址空间的时候需要建立页表映射: + +```rust +// os/src/mm/memory_set.rs + +use crate::config::MMIO; + +impl MemorySet { + /// Without kernel stacks. + pub fn new_kernel() -> Self { + ... + println!("mapping memory-mapped registers"); + for pair in MMIO { + memory_set.push(MapArea::new( + (*pair).0.into(), + ((*pair).0 + (*pair).1).into(), + MapType::Identical, + MapPermission::R | MapPermission::W, + ), None); + } + memory_set + } +} +``` + +这里我们进行的是透明的恒等映射,从而让内核可以兼容于直接访问物理地址的设备驱动库。 + +由于设备驱动的开发过程比较琐碎,我们这里直接使用已有的 [virtio-drivers](https://github.com/rcore-os/virtio-drivers) crate ,它已经支持 VirtIO 总线架构下的块设备、网络设备、GPU 等设备。注:关于VirtIO 相关驱动的内容,在后续的外设相关章节有更深入的讲解。 + +```rust +// os/src/drivers/block/virtio_blk.rs + +use virtio_drivers::{VirtIOBlk, VirtIOHeader}; +const VIRTIO0: usize = 0x10001000; + +pub struct VirtIOBlock(Mutex>); + +impl VirtIOBlock { + pub fn new() -> Self { + Self(Mutex::new(VirtIOBlk::new( + unsafe { &mut *(VIRTIO0 as *mut VirtIOHeader) } + ).unwrap())) + } +} + +impl BlockDevice for VirtIOBlock { + fn read_block(&self, block_id: usize, buf: &mut [u8]) { + self.0.lock().read_block(block_id, buf).expect("Error when reading VirtIOBlk"); + } + fn write_block(&self, block_id: usize, buf: &[u8]) { + self.0.lock().write_block(block_id, buf).expect("Error when writing VirtIOBlk"); + } +} +``` + +上面的代码中,我们将 `virtio-drivers` crate 提供的 VirtIO 块设备抽象 `VirtIOBlk` 包装为我们自己的 `VirtIOBlock` ,实质上只是加上了一层互斥锁,生成一个新的类型来实现 `easy-fs` 需要的 `BlockDevice` Trait 。注意在 `VirtIOBlk::new` 的时候需要传入一个 `&mut VirtIOHeader` 的参数, `VirtIOHeader` 实际上就代表以 MMIO 方式访问 VirtIO 设备所需的一组设备寄存器。因此我们从 `qemu-system-riscv64` 平台上的 Virtio MMIO 区间左端 `VIRTIO0` 开始转化为一个 `&mut VirtIOHeader` 就可以在该平台上访问这些设备寄存器了。 + +很容易为 `VirtIOBlock` 实现 `BlockDevice` Trait ,因为它内部来自 `virtio-drivers` crate 的 `VirtIOBlk` 类型已经实现了 `read/write_block` 方法,我们进行转发即可。 + +VirtIO 设备需要占用部分内存作为一个公共区域从而更好的和 CPU 进行合作。这就像 MMU 需要在内存中保存多级页表才能和 CPU 共同实现分页机制一样。在 VirtIO 架构下,需要在公共区域中放置一种叫做 VirtQueue 的环形队列,CPU 可以向此环形队列中向 VirtIO 设备提交请求,也可以从队列中取得请求的结果,详情可以参考 [virtio 文档](https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.pdf) 。对于 VirtQueue 的使用涉及到物理内存的分配和回收,但这并不在 VirtIO 驱动 `virtio-drivers` 的职责范围之内,因此它声明了数个相关的接口,需要库的使用者自己来实现: + +```rust +// https://github.com/rcore-os/virtio-drivers/blob/master/src/hal.rs#L57 + +extern "C" { + fn virtio_dma_alloc(pages: usize) -> PhysAddr; + fn virtio_dma_dealloc(paddr: PhysAddr, pages: usize) -> i32; + fn virtio_phys_to_virt(paddr: PhysAddr) -> VirtAddr; + fn virtio_virt_to_phys(vaddr: VirtAddr) -> PhysAddr; +} +``` + +由于我们已经实现了基于分页内存管理的地址空间,实现这些功能自然不在话下: + +```rust +// os/src/drivers/block/virtio_blk.rs + +lazy_static! { + static ref QUEUE_FRAMES: Mutex> = Mutex::new(Vec::new()); +} + +#[no_mangle] +pub extern "C" fn virtio_dma_alloc(pages: usize) -> PhysAddr { + let mut ppn_base = PhysPageNum(0); + for i in 0..pages { + let frame = frame_alloc().unwrap(); + if i == 0 { ppn_base = frame.ppn; } + assert_eq!(frame.ppn.0, ppn_base.0 + i); + QUEUE_FRAMES.lock().push(frame); + } + ppn_base.into() +} + +#[no_mangle] +pub extern "C" fn virtio_dma_dealloc(pa: PhysAddr, pages: usize) -> i32 { + let mut ppn_base: PhysPageNum = pa.into(); + for _ in 0..pages { + frame_dealloc(ppn_base); + ppn_base.step(); + } + 0 +} + +#[no_mangle] +pub extern "C" fn virtio_phys_to_virt(paddr: PhysAddr) -> VirtAddr { + VirtAddr(paddr.0) +} + +#[no_mangle] +pub extern "C" fn virtio_virt_to_phys(vaddr: VirtAddr) -> PhysAddr { + PageTable::from_token(kernel_token()).translate_va(vaddr).unwrap() +} +``` + +这里有一些细节需要注意: + +- `virtio_dma_alloc/dealloc` 需要分配/回收数个 *连续* 的物理页帧,而我们的 `frame_alloc` 是逐个分配,严格来说并不保证分配的连续性。幸运的是,这个过程只会发生在内核初始化阶段,因此能够保证连续性。 +- 在 `virtio_dma_alloc` 中通过 `frame_alloc` 得到的那些物理页帧 `FrameTracker` 都会被保存在全局的向量 `QUEUE_FRAMES` 以延长它们的生命周期,避免提前被回收。 + +### K210 真实硬件平台 + +在 K210 开发板上,我们可以插入 microSD 卡并将其作为块设备。相比 VirtIO 块设备来说,想要将 microSD 驱动起来是一件比较困难的事情。microSD 自身的通信规范比较复杂,且还需考虑在 K210 中microSD挂在 **串行外设接口** (SPI, Serial Peripheral Interface) 总线上的情况。此外还需要正确设置 GPIO 的管脚映射并调整各锁相环的频率。实际上,在一块小小的芯片中除了 K210 CPU 之外,还集成了很多不同种类的外设和控制模块,它们内在的关联比较紧密,不能像 VirtIO 设备那样容易地从系统中独立出来。 + +好在目前 Rust 嵌入式的生态正高速发展,针对 K210 平台也有比较成熟的封装了各类外设接口的库可以用来开发上层应用。但是其功能往往分散为多个 crate ,在使用的时候需要开发者根据需求自行进行组装。这属于 Rust 的特点之一,和 C 语言提供一个一站式的板级开发包风格有很大的不同。在开发的时候,笔者就从社区中选择了一些 crate 并进行了微量修改最终变成 `k210-hal/k210-pac/k210-soc` 三个能够运行在 S 特权级(它们的原身仅支持运行在 M 特权级)的 crate ,它们可以更加便捷的实现 microSD 的驱动。关于 microSD 的驱动 `SDCardWrapper` 的实现,有兴趣的同学可以参考 `os/src/drivers/block/sdcard.rs` 。 + +#### NOTE + +**感谢相关 crate 的原身** + +- [k210-hal](https://github.com/riscv-rust/k210-hal) +- [k210-pac](https://github.com/riscv-rust/k210-pac) +- [k210-sdk-stuff](https://github.com/laanwj/k210-sdk-stuff) + +要在 K210 上启用 microSD ,执行的时候无需任何改动,只需在 `make run` 之前将 microSD 插入 PC 再通过 `make sdcard` 将 easy-fs 镜像烧写进去即可。而后,将 microSD 插入 K210 开发板,连接到 PC 再 `make run` 。 + +在对 microSD 进行操作的时候,会涉及到 K210 内置的各种外设,正所谓”牵一发而动全身“。因此 K210 平台上的 MMIO 包含很多区间: + +```rust +// os/src/config.rs + +#[cfg(feature = "board_k210")] +pub const MMIO: &[(usize, usize)] = &[ + // we don't need clint in S priv when running + // we only need claim/complete for target0 after initializing + (0x0C00_0000, 0x3000), /* PLIC */ + (0x0C20_0000, 0x1000), /* PLIC */ + (0x3800_0000, 0x1000), /* UARTHS */ + (0x3800_1000, 0x1000), /* GPIOHS */ + (0x5020_0000, 0x1000), /* GPIO */ + (0x5024_0000, 0x1000), /* SPI_SLAVE */ + (0x502B_0000, 0x1000), /* FPIOA */ + (0x502D_0000, 0x1000), /* TIMER0 */ + (0x502E_0000, 0x1000), /* TIMER1 */ + (0x502F_0000, 0x1000), /* TIMER2 */ + (0x5044_0000, 0x1000), /* SYSCTL */ + (0x5200_0000, 0x1000), /* SPI0 */ + (0x5300_0000, 0x1000), /* SPI1 */ + (0x5400_0000, 0x1000), /* SPI2 */ +]; +``` + +## 内核索引节点层 + +在本章的第一小节我们介绍过,站在用户的角度看来,在一个进程中可以使用多种不同的标志来打开一个文件,这会影响到打开的这个文件可以用何种方式被访问。此外,在连续调用 `sys_read/write` 读写一个文件的时候,我们知道进程中也存在着一个文件读写的当前偏移量,它也随着文件读写的进行而被不断更新。这些用户视角中的文件系统抽象特征需要内核来实现,与进程有很大的关系,而 `easy-fs` 文件系统不必涉及这些与进程结合紧密的属性。因此,我们需要将 `easy-fs` 提供的 `Inode` 加上上述信息,进一步封装为 OS 中的索引节点 `OSInode` : + +```rust +// os/src/fs/inode.rs + +pub struct OSInode { + readable: bool, + writable: bool, + inner: Mutex, +} + +pub struct OSInodeInner { + offset: usize, + inode: Arc, +} + +impl OSInode { + pub fn new( + readable: bool, + writable: bool, + inode: Arc, + ) -> Self { + Self { + readable, + writable, + inner: Mutex::new(OSInodeInner { + offset: 0, + inode, + }), + } + } +} +``` + +`OSInode` 就表示进程中一个被打开的常规文件或目录。 `readable/writable` 分别表明该文件是否允许通过 `sys_read/write` 进行读写。至于在 `sys_read/write` 期间被维护偏移量 `offset` 和它在 `easy-fs` 中的 `Inode` 则加上一把互斥锁丢到 `OSInodeInner` 中。这在提供内部可变性的同时,也可以简单应对多个进程同时读写一个文件的情况。 + +## 文件描述符层 + + + +一个进程可以访问的多个文件,所以在操作系统中需要有一个管理进程访问的多个文件的结构,这就是 **文件描述符表** (File Descriptor Table) ,其中的每个 **文件描述符** (File Descriptor) 代表了一个特定读写属性的I/O资源。 + +为简化操作系统设计实现,可以让每个进程都带有一个线性的 **文件描述符表** ,记录该进程请求内核打开并读写的那些文件集合。而 **文件描述符** (File Descriptor) 则是一个非负整数,表示文件描述符表中一个打开的 **文件描述符** 所处的位置(可理解为数组下标)。进程通过文件描述符,可以在自身的文件描述符表中找到对应的文件记录信息,从而也就找到了对应的文件,并对文件进行读写。当打开( `open` )或创建( `create` ) 一个文件的时候,一般情况下内核会返回给应用刚刚打开或创建的文件对应的文件描述符;而当应用想关闭( `close` )一个文件的时候,也需要向内核提供对应的文件描述符,以完成对应文件相关资源的回收操作。 + +因为 `OSInode` 也是一种要放到进程文件描述符表中文件,并可通过 `sys_read/write` 系统调用进行读写操作,因此我们也需要为它实现 `File` Trait : + +```rust +// os/src/fs/inode.rs + +impl File for OSInode { + fn readable(&self) -> bool { self.readable } + fn writable(&self) -> bool { self.writable } + fn read(&self, mut buf: UserBuffer) -> usize { + let mut inner = self.inner.lock(); + let mut total_read_size = 0usize; + for slice in buf.buffers.iter_mut() { + let read_size = inner.inode.read_at(inner.offset, *slice); + if read_size == 0 { + break; + } + inner.offset += read_size; + total_read_size += read_size; + } + total_read_size + } + fn write(&self, buf: UserBuffer) -> usize { + let mut inner = self.inner.lock(); + let mut total_write_size = 0usize; + for slice in buf.buffers.iter() { + let write_size = inner.inode.write_at(inner.offset, *slice); + assert_eq!(write_size, slice.len()); + inner.offset += write_size; + total_write_size += write_size; + } + total_write_size + } +} +``` + +本章我们为 `File` Trait 新增了 `readable/writable` 两个抽象接口从而在 `sys_read/sys_write` 的时候进行简单的访问权限检查。 `read/write` 的实现也比较简单,只需遍历 `UserBuffer` 中的每个缓冲区片段,调用 `Inode` 写好的 `read/write_at` 接口就好了。注意 `read/write_at` 的起始位置是在 `OSInode` 中维护的 `offset` ,这个 `offset` 也随着遍历的进行被持续更新。在 `read/write` 的全程需要获取 `OSInode` 的互斥锁,保证两个进程无法同时访问同个文件。 + +## 文件描述符表 + +为了支持进程对文件的管理,我们需要在进程控制块中加入文件描述符表的相应字段: + +```rust +// os/src/task/task.rs + +pub struct TaskControlBlockInner { + pub trap_cx_ppn: PhysPageNum, + pub base_size: usize, + pub task_cx_ptr: usize, + pub task_status: TaskStatus, + pub memory_set: MemorySet, + pub parent: Option>, + pub children: Vec>, + pub exit_code: i32, + pub fd_table: Vec>>, +} +``` + +可以看到 `fd_table` 的类型包含多层嵌套,我们从外到里分别说明: + +- `Vec` 的动态长度特性使得我们无需设置一个固定的文件描述符数量上限,我们可以更加灵活的使用内存,而不必操心内存管理问题; +- `Option` 使得我们可以区分一个文件描述符当前是否空闲,当它是 `None` 的时候是空闲的,而 `Some` 则代表它已被占用; +- `Arc` 首先提供了共享引用能力。后面我们会提到,可能会有多个进程共享同一个文件对它进行读写。此外被它包裹的内容会被放到内核堆而不是栈上,于是它便不需要在编译期有着确定的大小; +- `dyn` 关键字表明 `Arc` 里面的类型实现了 `File/Send/Sync` 三个 Trait ,但是编译期无法知道它具体是哪个类型(可能是任何实现了 `File` Trait 的类型如 `Stdin/Stdout` ,故而它所占的空间大小自然也无法确定),需要等到运行时才能知道它的具体类型,对于一些抽象方法的调用也是在那个时候才能找到该类型实现的方法并跳转过去。 + +#### NOTE + +**Rust 语法卡片:Rust 中的多态** + +在编程语言中, **多态** (Polymorphism) 指的是在同一段代码中可以隐含多种不同类型的特征。在 Rust 中主要通过泛型和 Trait 来实现多态。 + +泛型是一种 **编译期多态** (Static Polymorphism),在编译一个泛型函数的时候,编译器会对于所有可能用到的类型进行实例化并对应生成一个版本的汇编代码,在编译期就能知道选取哪个版本并确定函数地址,这可能会导致生成的二进制文件体积较大;而 Trait 对象(也即上面提到的 `dyn` 语法)是一种 **运行时多态** (Dynamic Polymorphism),需要在运行时查一种类似于 C++ 中的 **虚表** (Virtual Table) 才能找到实际类型对于抽象接口实现的函数地址并进行调用,这样会带来一定的运行时开销,但是更省空间且灵活。 + +## 应用访问文件的内核机制实现 + +应用程序在访问文件之前,首先需要完成对文件系统的初始化和加载。这可以通过操作系统来完成,也可以让应用程序发出文件系统相关的系统调用(如 `mount` 等)来完成。我们这里的选择是让操作系统直接完成。 + +应用程序如果要基于文件进行I/O访问,大致就会涉及如下一些系统调用: + +- 打开文件 -- sys_open:进程只有打开文件,操作系统才能返回一个可进行读写的文件描述符给进程,进程才能基于这个值来进行对应文件的读写。 +- 关闭文件 -- sys_close:进程基于文件描述符关闭文件后,就不能再对文件进行读写操作了,这样可以在一定程度上保证对文件的合法访问。 +- 读文件 -- sys_read:进程可以基于文件描述符来读文件内容到相应内存中。 +- 写文件 -- sys_write:进程可以基于文件描述符来把相应内存内容写到文件中。 + +### 文件系统初始化 + +在上一小节我们介绍过,为了使用 `easy-fs` 提供的抽象和服务,我们需要进行一些初始化操作才能成功将 `easy-fs` 接入到我们的内核中。按照前面总结的步骤: + +1. 打开块设备。从本节前面可以看出,我们已经打开并可以访问装载有 easy-fs 文件系统镜像的块设备 `BLOCK_DEVICE` ; +2. 从块设备 `BLOCK_DEVICE` 上打开文件系统; +3. 从文件系统中获取根目录的 inode 。 + +2-3 步我们在这里完成: + +```rust +// os/src/fs/inode.rs + +lazy_static! { + pub static ref ROOT_INODE: Arc = { + let efs = EasyFileSystem::open(BLOCK_DEVICE.clone()); + Arc::new(EasyFileSystem::root_inode(&efs)) + }; +} +``` + +这之后就可以使用根目录的 inode `ROOT_INODE` ,在内核中进行各种 `easy-fs` 的相关操作了。例如,在文件系统初始化完毕之后,在内核主函数 `rust_main` 中调用 `list_apps` 函数来列举文件系统中可用的应用的文件名: + +```rust +// os/src/fs/inode.rs + +pub fn list_apps() { + println!("/**** APPS ****"); + for app in ROOT_INODE.ls() { + println!("{}", app); + } + println!("**************/") +} +``` + +### 打开与关闭文件 + +我们需要在内核中也定义一份打开文件的标志 `OpenFlags` : + +```rust +// os/src/fs/inode.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; + } +} + +impl OpenFlags { + /// Do not check validity for simplicity + /// Return (readable, writable) + pub fn read_write(&self) -> (bool, bool) { + if self.is_empty() { + (true, false) + } else if self.contains(Self::WRONLY) { + (false, true) + } else { + (true, true) + } + } +} +``` + +它的 `read_write` 方法可以根据标志的情况返回要打开的文件是否允许读写。简单起见,这里假设标志自身一定合法。 + +接着,我们实现 `open_file` 内核函数,可根据文件名打开一个根目录下的文件: + +```rust +// os/src/fs/inode.rs + +pub fn open_file(name: &str, flags: OpenFlags) -> Option> { + let (readable, writable) = flags.read_write(); + if flags.contains(OpenFlags::CREATE) { + if let Some(inode) = ROOT_INODE.find(name) { + // clear size + inode.clear(); + Some(Arc::new(OSInode::new( + readable, + writable, + inode, + ))) + } else { + // create file + ROOT_INODE.create(name) + .map(|inode| { + Arc::new(OSInode::new( + readable, + writable, + inode, + )) + }) + } + } else { + ROOT_INODE.find(name) + .map(|inode| { + if flags.contains(OpenFlags::TRUNC) { + inode.clear(); + } + Arc::new(OSInode::new( + readable, + writable, + inode + )) + }) + } +} +``` + +这里主要是实现了 `OpenFlags` 各标志位的语义。例如只有 `flags` 参数包含 CREATE 标志位才允许创建文件;而如果文件已经存在,则清空文件的内容。另外我们将从 `OpenFlags` 解析得到的读写相关权限传入 `OSInode` 的创建过程中。 + +在其基础上, `sys_open` 也就很容易实现了: + +```rust +// os/src/syscall/fs.rs + +pub fn sys_open(path: *const u8, flags: u32) -> isize { + let task = current_task().unwrap(); + let token = current_user_token(); + let path = translated_str(token, path); + if let Some(inode) = open_file( + path.as_str(), + OpenFlags::from_bits(flags).unwrap() + ) { + let mut inner = task.inner_exclusive_access(); + let fd = inner.alloc_fd(); + inner.fd_table[fd] = Some(inode); + fd as isize + } else { + -1 + } +} +``` + +关闭文件的系统调用 `sys_close` 实现非常简单,我们只需将进程控制块中的文件描述符表对应的一项改为 `None` 代表它已经空闲即可,同时这也会导致内层的引用计数类型 `Arc` 被销毁,会减少一个文件的引用计数,当引用计数减少到 0 之后文件所占用的资源就会被自动回收。 + +```rust +// os/src/syscall/fs.rs + +pub fn sys_close(fd: usize) -> isize { + let task = current_task().unwrap(); + let mut inner = task.inner_exclusive_access(); + if fd >= inner.fd_table.len() { + return -1; + } + if inner.fd_table[fd].is_none() { + return -1; + } + inner.fd_table[fd].take(); + 0 +} +``` + +### 基于文件来加载并执行应用 + +在有了文件系统支持之后,我们在 `sys_exec` 所需的应用的 ELF 文件格式的数据就不再需要通过应用加载器从内核的数据段获取,而是从文件系统中获取,这样内核与应用的代码/数据就解耦了: + +```rust +// os/src/syscall/process.rs + +pub fn sys_exec(path: *const u8) -> isize { + let token = current_user_token(); + let path = translated_str(token, path); + if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) { + let all_data = app_inode.read_all(); + let task = current_task().unwrap(); + task.exec(all_data.as_slice()); + 0 + } else { + -1 + } +} +``` + +注意上面代码片段中的高亮部分。当执行获取应用的 ELF 数据的操作时,首先调用 `open_file` 函数,以只读的方式在内核中打开应用文件并获取它对应的 `OSInode` 。接下来可以通过 `OSInode::read_all` 将该文件的数据全部读到一个向量 `all_data` 中: + +```rust +// os/src/fs/inode.rs + +impl OSInode { + pub fn read_all(&self) -> Vec { + let mut inner = self.inner.lock(); + let mut buffer = [0u8; 512]; + let mut v: Vec = Vec::new(); + loop { + let len = inner.inode.read_at(inner.offset, &mut buffer); + if len == 0 { + break; + } + inner.offset += len; + v.extend_from_slice(&buffer[..len]); + } + v + } +} +``` + +之后,就可以从向量 `all_data` 中拿到应用中的 ELF 数据,当解析完毕并创建完应用地址空间后该向量将会被回收。 + +同样的,我们在内核中创建初始进程 `initproc` 也需要替换为基于文件系统的实现: + +```rust +// os/src/task/mod.rs + +lazy_static! { + pub static ref INITPROC: Arc = Arc::new({ + let inode = open_file("initproc", OpenFlags::RDONLY).unwrap(); + let v = inode.read_all(); + TaskControlBlock::new(v.as_slice()) + }); +} +``` + +### 读写文件 + +基于文件抽象接口和文件描述符表,我们可以按照无结构的字节流来处理基本的文件读写,这样可以让文件读写系统调用 `sys_read/write` 变得更加具有普适性,为后续支持把管道等抽象为文件打下了基础: + +```rust +// os/src/syscall/fs.rs + +pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize { + let token = current_user_token(); + let task = current_task().unwrap(); + let inner = task.inner_exclusive_access(); + if fd >= inner.fd_table.len() { + return -1; + } + if let Some(file) = &inner.fd_table[fd] { + let file = file.clone(); + // release current task TCB manually to avoid multi-borrow + drop(inner); + file.write( + UserBuffer::new(translated_byte_buffer(token, buf, len)) + ) as isize + } else { + -1 + } +} + +pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize { + let token = current_user_token(); + let task = current_task().unwrap(); + let inner = task.inner_exclusive_access(); + if fd >= inner.fd_table.len() { + return -1; + } + if let Some(file) = &inner.fd_table[fd] { + let file = file.clone(); + // release current task TCB manually to avoid multi-borrow + drop(inner); + file.read( + UserBuffer::new(translated_byte_buffer(token, buf, len)) + ) as isize + } else { + -1 + } +} +``` + +操作系统都是通过文件描述符在当前进程的文件描述符表中找到某个文件,无需关心文件具体的类型,只要知道它一定实现了 `File` Trait 的 `read/write` 方法即可。Trait 对象提供的运行时多态能力会在运行的时候帮助我们定位到符合实际类型的 `read/write` 方法。 diff --git a/ch6/book/4exercise.md b/ch6/book/4exercise.md new file mode 100644 index 00000000..2241d3d5 --- /dev/null +++ b/ch6/book/4exercise.md @@ -0,0 +1,138 @@ +# 练习 + +## 课后练习 + +### 编程题 + +1. \* 扩展easy-fs文件系统功能,扩大单个文件的大小,支持三重间接inode。 +2. \* 扩展内核功能,支持stat系统调用,能显示文件的inode元数据信息。 +3. \*\* 扩展内核功能,支持mmap系统调用,支持对文件的映射,实现基于内存读写方式的文件读写功能。 +4. \*\* 扩展easy-fs文件系统功能,支持二级目录结构。可扩展:支持N级目录结构。 +5. \*\*\* 扩展easy-fs文件系统功能,通过日志机制支持crash一致性。 + +### 问答题 + +1. \* 文件系统的功能是什么? +2. \*\* 目前的文件系统只有单级目录,假设想要支持多级文件目录,请描述你设想的实现方式,描述合理即可。 +3. \*\* 软链接和硬链接是干什么的?有什么区别?当删除一个软链接或硬链接时分别会发生什么? +4. \*\*\* 在有了多级目录之后,我们就也可以为一个目录增加硬链接了。在这种情况下,文件树中是否可能出现环路(软硬链接都可以,鼓励多尝试)?你认为应该如何解决?请在你喜欢的系统上实现一个环路,描述你的实现方式以及系统提示、实际测试结果。 +5. \* 目录是一类特殊的文件,存放的是什么内容?用户可以自己修改目录内容吗? +6. \*\* 在实际操作系统中,如Linux,为什么会存在大量的文件系统类型? +7. \*\* 可以把文件控制块放到目录项中吗?这样做有什么优缺点? +8. \*\* 为什么要同时维护进程的打开文件表和操作系统的打开文件表?这两个打开文件表有什么区别和联系? +9. \*\* 文件分配的三种方式是如何组织文件数据块的?各有什么特征(存储、文件读写、可靠性)? +10. \*\* 如果一个程序打开了一个文件,写入了一些数据,但是没有及时关闭,可能会有什么后果?如果打开文件后,又进一步发出了读文件的系统调用,操作系统中各个组件是如何相互协作完成整个读文件的系统调用的? +11. \*\*\* 文件系统是一个操作系统必要的组件吗?是否可以将文件系统放到用户态?这样做有什么好处?操作系统需要提供哪些基本支持? + +## 实验练习 + +实验练习包括实践作业和问答作业两部分。 + +**理解文件系统比较费事,编程难度适中** + +### 实践作业 + +#### 硬链接 + +硬链接要求两个不同的目录项指向同一个文件,在我们的文件系统中也就是两个不同名称目录项指向同一个磁盘块。 + +本节要求实现三个系统调用 `sys_linkat、sys_unlinkat、sys_stat` 。 + +**linkat**: + +> * syscall ID: 37 +> * 功能:创建一个文件的一个硬链接, [linkat标准接口](https://linux.die.net/man/2/linkat) 。 +> * C接口: `int linkat(int olddirfd, char* oldpath, int newdirfd, char* newpath, unsigned int flags)` +> * Rust 接口: `fn linkat(olddirfd: i32, oldpath: *const u8, newdirfd: i32, newpath: *const u8, flags: u32) -> i32` +> * 参数: +> : * olddirfd,newdirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。 +> * flags: 仅为了兼容性考虑,本次实验中始终为 0,可以忽略。 +> * oldpath:原有文件路径 +> * newpath: 新的链接文件路径。 +> * 说明: +> : * 为了方便,不考虑新文件路径已经存在的情况(属于未定义行为),除非链接同名文件。 +> * 返回值:如果出现了错误则返回 -1,否则返回 0。 +> * 可能的错误 +> : * 链接同名文件。 + +**unlinkat**: + +> * syscall ID: 35 +> * 功能:取消一个文件路径到文件的链接, [unlinkat标准接口](https://linux.die.net/man/2/unlinkat) 。 +> * C接口: `int unlinkat(int dirfd, char* path, unsigned int flags)` +> * Rust 接口: `fn unlinkat(dirfd: i32, path: *const u8, flags: u32) -> i32` +> * 参数: +> : * dirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。 +> * flags: 仅为了兼容性考虑,本次实验中始终为 0,可以忽略。 +> * path:文件路径。 +> * 说明: +> : * 为了方便,不考虑使用 unlink 彻底删除文件的情况。 +> * 返回值:如果出现了错误则返回 -1,否则返回 0。 +> * 可能的错误 +> : * 文件不存在。 + +**fstat**: + +> * syscall ID: 80 +> * 功能:获取文件状态。 +> * C接口: `int fstat(int fd, struct Stat* st)` +> * Rust 接口: `fn fstat(fd: i32, st: *mut Stat) -> i32` +> * 参数: +> : * fd: 文件描述符 +> * st: 文件状态结构体 +>
+> ```rust +> #[repr(C)] +> #[derive(Debug)] +> pub struct Stat { +> /// 文件所在磁盘驱动器号,该实验中写死为 0 即可 +> pub dev: u64, +> /// inode 文件所在 inode 编号 +> pub ino: u64, +> /// 文件类型 +> pub mode: StatMode, +> /// 硬链接数量,初始为1 +> pub nlink: u32, +> /// 无需考虑,为了兼容性设计 +> pad: [u64; 7], +> } +>
+> /// StatMode 定义: +> bitflags! { +> pub struct StatMode: u32 { +> const NULL = 0; +> /// directory +> const DIR = 0o040000; +> /// ordinary regular file +> const FILE = 0o100000; +> } +> } +> ``` + +#### 实验要求 + +- 实现分支:ch7-lab +- 实验目录要求不变 +- 通过所有测例 + + 在 os 目录下 `make run TEST=1` 加载所有测例, `test_usertest` 打包了所有你需要通过的测例,你也可以通过修改这个文件调整本地测试的内容。 + + 你的内核必须前向兼容,能通过前一章的所有测例。 + +#### NOTE + +**如何调试 easy-fs** + +如果你在第一章练习题中已经借助 `log` crate 实现了日志功能,那么你可以直接在 `easy-fs` 中引入 `log` crate,通过 `log::info!/debug!` 等宏即可进行调试并在内核中看到日志输出。具体来说,在 `easy-fs` 中的修改是:在 `easy-fs/Cargo.toml` 的依赖中加入一行 `log = "0.4.0"`,然后在 `easy-fs/src/lib.rs` 中加入一行 `extern crate log` 。 + +你也可以完全在用户态进行调试。仿照 `easy-fs-fuse` 建立一个在当前操作系统中运行的应用程序,将测试逻辑写在 `main` 函数中。这个时候就可以将它引用的 `easy-fs` 的 `no_std` 去掉并使用 `println!` 进行调试。 + +### 问答作业 + +无 + +### 实验练习的提交报告要求 + +* 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) +* 完成问答问题 +* (optional) 你对本次实验设计及难度的看法。 diff --git a/ch6/book/5answer.md b/ch6/book/5answer.md new file mode 100644 index 00000000..5e5970c0 --- /dev/null +++ b/ch6/book/5answer.md @@ -0,0 +1,944 @@ +# 练习参考答案 + +## 课后练习 + +### 编程题 + +1. \* 扩展easy-fs文件系统功能,扩大单个文件的大小,支持三级间接inode。 + +在修改之前,先看看原始inode的结构: + +```rust +/// The max number of direct inodes +const INODE_DIRECT_COUNT: usize = 28; + +#[repr(C)] +pub struct DiskInode { + pub size: u32, + pub direct: [u32; INODE_DIRECT_COUNT], + pub indirect1: u32, + pub indirect2: u32, + type_: DiskInodeType, +} + +#[derive(PartialEq)] +pub enum DiskInodeType { + File, + Directory, +} +``` + +一个 `DiskInode` 在磁盘上占据128字节的空间。我们考虑加入 `indirect3` 字段并缩减 `INODE_DIRECT_COUNT` 为27以保持 `DiskInode` 的大小不变。此时直接索引可索引13.5KiB的内容,一级间接索引和二级间接索引仍然能索引64KiB和8MiB的内容,而三级间接索引能索引128 \* 8MiB = 1GiB的内容。当文件大小大于13.5KiB + 64KiB + 8MiB时,需要用到三级间接索引。 + +下面的改动都集中在 `easy-fs/src/layout.rs` 中。首先修改 `DiskInode` 和相关的常量定义。 + +```rust +pub struct DiskInode { + pub size: u32, + pub direct: [u32; INODE_DIRECT_COUNT], + pub indirect1: u32, + pub indirect2: u32, + pub indirect3: u32, + type_: DiskInodeType, +} +``` + +在计算给定文件大小对应的块总数时,需要新增对三级间接索引的处理。三级间接索引的存在使得二级间接索引所需的块数不再计入所有的剩余数据块。 + +```rust +pub fn total_blocks(size: u32) -> u32 { + let data_blocks = Self::_data_blocks(size) as usize; + let mut total = data_blocks as usize; + // indirect1 + if data_blocks > INODE_DIRECT_COUNT { + total += 1; + } + // indirect2 + if data_blocks > INDIRECT1_BOUND { + total += 1; + // sub indirect1 + let level2_extra = + (data_blocks - INDIRECT1_BOUND + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT; + total += level2_extra.min(INODE_INDIRECT1_COUNT); + } + // indirect3 + if data_blocks > INDIRECT2_BOUND { + let remaining = data_blocks - INDIRECT2_BOUND; + let level2_extra = (remaining + INODE_INDIRECT2_COUNT - 1) / INODE_INDIRECT2_COUNT; + let level3_extra = (remaining + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT; + total += 1 + level2_extra + level3_extra; + } + total as u32 +} +``` + +`DiskInode` 的 `get_block_id` 方法中遇到三级间接索引要额外读取三次块缓存。 + +```rust +pub fn get_block_id(&self, inner_id: u32, block_device: &Arc) -> u32 { + let inner_id = inner_id as usize; + if inner_id < INODE_DIRECT_COUNT { + // ... + } else if inner_id < INDIRECT1_BOUND { + // ... + } else if inner_id < INDIRECT2_BOUND { + // ... + } else { // 对三级间接索引的处理 + let last = inner_id - INDIRECT2_BOUND; + let indirect1 = get_block_cache(self.indirect3 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect3: &IndirectBlock| { + indirect3[last / INODE_INDIRECT2_COUNT] + }); + let indirect2 = get_block_cache(indirect1 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect2: &IndirectBlock| { + indirect2[(last % INODE_INDIRECT2_COUNT) / INODE_INDIRECT1_COUNT] + }); + get_block_cache(indirect2 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect1: &IndirectBlock| { + indirect1[(last % INODE_INDIRECT2_COUNT) % INODE_INDIRECT1_COUNT] + }) + } +} +``` + +方法 `increase_size` 的实现本身比较繁琐,如果按照原有的一级和二级间接索引的方式实现对三级间接索引的处理,代码会比较丑陋。实际上多重间接索引是树结构,变量 `current_blocks` 和 `total_blocks` 对应着当前树的叶子数量和目标叶子数量,我们可以用递归函数来实现树的生长。先实现以下的辅助方法: + +```rust +/// Helper to build tree recursively +/// extend number of leaves from `src_leaf` to `dst_leaf` +fn build_tree( + &self, + blocks: &mut alloc::vec::IntoIter, + block_id: u32, + mut cur_leaf: usize, + src_leaf: usize, + dst_leaf: usize, + cur_depth: usize, + dst_depth: usize, + block_device: &Arc, +) -> usize { + if cur_depth == dst_depth { + return cur_leaf + 1; + } + get_block_cache(block_id as usize, Arc::clone(block_device)) + .lock() + .modify(0, |indirect_block: &mut IndirectBlock| { + let mut i = 0; + while i < INODE_INDIRECT1_COUNT && cur_leaf < dst_leaf { + if cur_leaf >= src_leaf { + indirect_block[i] = blocks.next().unwrap(); + } + cur_leaf = self.build_tree( + blocks, + indirect_block[i], + cur_leaf, + src_leaf, + dst_leaf, + cur_depth + 1, + dst_depth, + block_device, + ); + i += 1; + } + }); + cur_leaf +} +``` + +然后修改方法 `increase_size`。不要忘记在填充二级间接索引时维护 `current_blocks` 的变化,并限制目标索引 `(a1, b1)` 的范围。 + +```rust +/// Increase the size of current disk inode +pub fn increase_size( + &mut self, + new_size: u32, + new_blocks: Vec, + block_device: &Arc, +) { + // ... + // alloc indirect2 + // ... + // fill indirect2 from (a0, b0) -> (a1, b1) + // 不要忘记限制 (a1, b1) 的范围 + // ... + // alloc indirect3 + if total_blocks > INODE_INDIRECT2_COUNT as u32 { + if current_blocks == INODE_INDIRECT2_COUNT as u32 { + self.indirect3 = new_blocks.next().unwrap(); + } + current_blocks -= INODE_INDIRECT2_COUNT as u32; + total_blocks -= INODE_INDIRECT2_COUNT as u32; + } else { + return; + } + // fill indirect3 + self.build_tree( + &mut new_blocks, + self.indirect3, + 0, + current_blocks as usize, + total_blocks as usize, + 0, + 3, + block_device, + ); +``` + +对方法 `clear_size` 的修改与 `increase_size` 类似。先实现辅助方法 `collect_tree_blocks`: + +```rust +/// Helper to recycle blocks recursively +fn collect_tree_blocks( + &self, + collected: &mut Vec, + block_id: u32, + mut cur_leaf: usize, + max_leaf: usize, + cur_depth: usize, + dst_depth: usize, + block_device: &Arc, +) -> usize { + if cur_depth == dst_depth { + return cur_leaf + 1; + } + get_block_cache(block_id as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect_block: &IndirectBlock| { + let mut i = 0; + while i < INODE_INDIRECT1_COUNT && cur_leaf < max_leaf { + collected.push(indirect_block[i]); + cur_leaf = self.collect_tree_blocks( + collected, + indirect_block[i], + cur_leaf, + max_leaf, + cur_depth + 1, + dst_depth, + block_device, + ); + i += 1; + } + }); + cur_leaf +} +``` + +然后修改方法 `clear_size`。 + +```rust +/// Clear size to zero and return blocks that should be deallocated. +/// We will clear the block contents to zero later. +pub fn clear_size(&mut self, block_device: &Arc) -> Vec { + // ... + // indirect2 block + // ... + // indirect2 + // 不要忘记限制 (a1, b1) 的范围 + self.indirect2 = 0; + // indirect3 block + assert!(data_blocks <= INODE_INDIRECT3_COUNT); + if data_blocks > INODE_INDIRECT2_COUNT { + v.push(self.indirect3); + data_blocks -= INODE_INDIRECT2_COUNT; + } else { + return v; + } + // indirect3 + self.collect_tree_blocks(&mut v, self.indirect3, 0, data_blocks, 0, 3, block_device); + self.indirect3 = 0; + v +} +``` + +接下来你可以在 `easy-fs-fuse/src/main.rs` 中测试easy-fs文件系统的修改,比如读写大小超过10MiB的文件。 + +1. \* 扩展内核功能,支持stat系统调用,能显示文件的inode元数据信息。 + +你将在本章的编程实验中实现这个功能。 + +1. \*\* 扩展内核功能,支持mmap系统调用,支持对文件的映射,实现基于内存读写方式的文件读写功能。 + +#### NOTE + +这里只是给出了一种参考实现。mmap本身行为比较复杂,使用你认为合理的方式实现即可。 + +在第四章的编程实验中你应该已经实现了mmap的匿名映射功能,这里我们要实现文件映射。 +[mmap](https://man7.org/linux/man-pages/man2/mmap.2.html) 的原型如下: + +```c +void *mmap(void *addr, size_t length, int prot, int flags, + int fd, off_t offset); +``` + +其中 `addr` 是一个虚拟地址的hint,在映射文件时我们不关心具体的虚拟地址(相当于传入 `NULL` ),这里我们的系统调用忽略这个参数。 `prot` 和 `flags` 指定了一些属性,为简单起见我们也不要这两个参数,映射的虚拟内存的属性直接继承自文件的读写属性。我们最终保留 `length` 、 `fd` 和 `offset` 三个参数。 + +考虑最简单的一种实现方式:mmap调用时随便选择一段虚拟地址空间,将它映射到一些随机的物理页面上,之后再把文件的对应部分全部读到内存里。如果这段映射是可写的,那么内核还要在合适的时机(比如调用msync、munmap、进程退出时)把内存里的东西回写到文件。 + +这样做的问题是被映射的文件可能很大,将映射的区域全部读入内存可能很慢,而且用户未必会访问所有的页面。这里可以应用按需分页的惰性加载策略:先不实际建立虚拟内存到物理内存的映射,当用户访问映射的区域时会触发缺页异常,我们在处理异常时分配实际的物理页面并将文件读入内存。 + +按照上述方式已经可以实现文件映射了,但让我们来考虑较为微妙的情况。比如以下的Linux C程序: + +```c +#include +#include +#include +#include + +int main() +{ + char str[] = {"asdbasdq3423423\n"}; + int fd = open("2.txt", O_RDWR | O_CREAT | O_TRUNC, 0664); + if (fd < 0) { + printf("open failed\n"); + return -1; + } + + if (write(fd, str, sizeof(str)) < 0) { + printf("write failed\n"); + return -1; + } + + char *p1 = mmap(NULL, sizeof(str), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + char *p2 = mmap(NULL, sizeof(str), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + printf("p1 = %p, p2 = %p\n", p1, p2); + close(fd); + + p1[1] = '1'; + p2[2] = '2'; + p2[0] = '2'; + p1[0] = '1'; + printf("content1: %s", p1); + printf("content2: %s", p2); + return 0; +} +``` + +一个可能的输出结果如下: + +```default +p1 = 0x7f955a3cf000, p2 = 0x7f955a3a2000 +content1: 112basdq3423423 +content2: 112basdq3423423 +``` + +可以看到文件的同一段区域被映射到了两个不同的虚拟地址,对这两段虚拟内存的修改全部生效(冲突的修改也是最后的可见),修改后再读出来的内容也相同。这样的结果是符合直觉的,因为底层的文件只有一个(也与 `MAP_SHARED` 有关,由于设置 `MAP_PRIVATE` 标志不会将修改真正写入文件,我们参考 `MAP_SHARED` 的行为)。如果按照上面说的方式将两个虚拟内存区域映射到不同的物理页面,那么对两个区域的修改无法同时生效,我们也无法确定应该将哪个页面回写到文件。这个例子启示我们, **如果文件映射包含文件的相同部分,那么相应的虚拟页面应该映射到相同的物理页** 。 + +不幸的是,现有的 `MapArea` 类型只含 `Identical` 和 `Framed` ,不支持不同的虚拟页面共享物理页,所以我们需要手动管理一些资源。下面的 `FileMapping` 结构描述了一个文件的若干段映射: + +```rust +pub struct FileMapping { + file: Arc, + ranges: Vec, + frames: Vec, + dirty_parts: BTreeSet, // file segments that need writing back + map: BTreeMap, // file offset -> ppn +} +``` + +其中 `file` 代表被映射的文件,你可能会好奇它的类型为什么不是一个文件描述符编号或者 `Arc` 。首先mmap之后使用的文件描述符可以立即被关闭而不会对文件映射造成任何影响,所以不适合只存放fd编号;其次mmap通常要求映射的文件是常规文件 (例:映射stdin和stdout毫无意义),这里用 `Inode` 来提醒我们这点。 `ranges` 里面存放了若干 `MapRange` ,每个都用于描述一段映射区域。 `frames` 用于管理实际分配的物理页帧。 `dirty_parts` 记录了需要回写的脏页,注意它实际上用文件内的偏移来表示。 `map` 维护文件内偏移到物理页号的映射。需要注意的是这里记录脏页的方式比较简单,而且也完全没有考虑在进程间共享物理页,你可以使用引用计数等手段进行扩展。 + +```rust +#[derive(Clone)] +struct MapRange { + start: VirtAddr, + len: usize, // length in bytes + offset: usize, // offset in file + perm: MapPermission, +} +``` + +`MapRange` 描述了一段映射区域。 `start` 是该区域的起始虚拟地址, `offset` 为其在文件中的偏移, `perm` 记录了该区域的属性。 + +前面提到过,我们的mmap忽略掉作为hint的 `addr` 参数,那这里的虚拟地址填什么呢?一般来说64位架构具有大到用不完的虚拟地址空间,用一个简单的线性分配器随便分配虚拟地址即可。 + +```rust +/// Base virtual address for mmap +pub const MMAP_AREA_BASE: usize = 0x0000_0001_0000_0000; // 随便选的基址,挑块没人用的 + +/// A naive linear virtual address space allocator +pub struct VirtualAddressAllocator { + cur_va: VirtAddr, +} + +impl VirtualAddressAllocator { + /// Create a new allocator with given base virtual address + pub fn new(base: usize) -> Self { + Self { + cur_va: base.into(), + } + } + + /// Allocate a virtual address area + pub fn alloc(&mut self, len: usize) -> VirtAddr { + let start = self.cur_va; + let end: VirtAddr = (self.cur_va.0 + len).into(); + self.cur_va = end.ceil().into(); + start + } + + // 不必释放 +} +``` + +然后把 `VirtualAddressAllocator` 和 `FileMapping` 放进 `TaskControlBlockInner` 里。为简单起见,fork时不考虑这两个字段的复制和映射的共享。 + +os/src/task/task.rs + +```rust +pub struct TaskControlBlockInner { + pub trap_cx_ppn: PhysPageNum, + pub base_size: usize, + pub task_cx: TaskContext, + pub task_status: TaskStatus, + pub memory_set: MemorySet, + pub parent: Option>, + pub children: Vec>, + pub exit_code: i32, + pub fd_table: Vec>>, + pub mmap_va_allocator: VirtualAddressAllocator, + pub file_mappings: Vec, +} +``` + +下面来添加mmap系统调用: + +```rust +/// This is a simplified version of mmap which only supports file-backed mapping +pub fn sys_mmap(fd: usize, len: usize, offset: usize) -> isize { + if len == 0 { + // invalid length + return -1; + } + if (offset & (PAGE_SIZE - 1)) != 0 { + // offset must be page size aligned + return -1; + } + + let task = current_task().unwrap(); + let mut tcb = task.inner_exclusive_access(); + if fd >= tcb.fd_table.len() { + return -1; + } + if tcb.fd_table[fd].is_none() { + return -1; + } + + let fp = tcb.fd_table[fd].as_ref().unwrap(); + let opt_inode = fp.as_any().downcast_ref::(); + if opt_inode.is_none() { + // must be a regular file + return -1; + } + + let inode = opt_inode.unwrap(); + let perm = parse_permission(inode); + let file = inode.clone_inner_inode(); + if offset >= file.get_size() { + // file offset exceeds size limit + return -1; + } + + let start = tcb.mmap_va_allocator.alloc(len); + let mappings = &mut tcb.file_mappings; + if let Some(m) = find_file_mapping(mappings, &file) { + m.push(start, len, offset, perm); + } else { + let mut m = FileMapping::new_empty(file); + m.push(start, len, offset, perm); + mappings.push(m); + } + start.0 as isize +} +``` + +这里面有不少无聊的参数检查和辅助函数,就不详细介绍了。总之这个系统调用实际做的事情只有维护对应的 `FileMapping` 结构,实际的工作被推迟到缺页异常处理例程中。 + +os/src/trap/mod.rs + +```rust +#[no_mangle] +/// handle an interrupt, exception, or system call from user space +pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Exception(Exception::UserEnvCall) => { + // ... + } + Trap::Exception(Exception::StoreFault) + | Trap::Exception(Exception::StorePageFault) + | Trap::Exception(Exception::InstructionFault) + | Trap::Exception(Exception::InstructionPageFault) + | Trap::Exception(Exception::LoadFault) + | Trap::Exception(Exception::LoadPageFault) => { + if !handle_page_fault(stval) { + println!( + "[kernel] {:?} in application, bad addr = {:#x}, bad instruction = {:#x}, kernel killed it.", + scause.cause(), + stval, + current_trap_cx().sepc, + ); + // page fault exit code + exit_current_and_run_next(-2); + } + } + Trap::Exception(Exception::IllegalInstruction) => { + // ... + } + Trap::Interrupt(Interrupt::SupervisorTimer) => { + // ... + } + _ => { + panic!( + "Unsupported trap {:?}, stval = {:#x}!", + scause.cause(), + stval + ); + } + } + trap_return(); +} +``` + +我们在这里尝试处理缺页异常,如果 `handle_page_fault` 返回 `true` 表明异常已经被处理,否则内核仍然会杀死当前进程。 + +```rust +/// Try to handle page fault caused by demand paging +/// Returns whether this page fault is fixed +pub fn handle_page_fault(fault_addr: usize) -> bool { + let fault_va: VirtAddr = fault_addr.into(); + let fault_vpn = fault_va.floor(); + let task = current_task().unwrap(); + let mut tcb = task.inner_exclusive_access(); + + if let Some(pte) = tcb.memory_set.translate(fault_vpn) { + if pte.is_valid() { + return false; // fault va already mapped, we cannot handle this + } + } + + match tcb.file_mappings.iter_mut().find(|m| m.contains(fault_va)) { + Some(mapping) => { + let file = Arc::clone(&mapping.file); + // fix vm mapping + let (ppn, range, shared) = mapping.map(fault_va).unwrap(); + tcb.memory_set.map(fault_vpn, ppn, range.perm); + + if !shared { + // load file content + let file_size = file.get_size(); + let file_offset = range.file_offset(fault_vpn); + assert!(file_offset < file_size); + + // let va_offset = range.va_offset(fault_vpn); + // let va_len = range.len - va_offset; + // Note: we do not limit `read_len` with `va_len` + // consider two overlapping areas with different lengths + + let read_len = PAGE_SIZE.min(file_size - file_offset); + file.read_at(file_offset, &mut ppn.get_bytes_array()[..read_len]); + } + true + } + None => false, + } +} +``` + +- `handle_page_fault` 的9~13行先检查触发异常的虚拟内存页是否已经映射到物理页面,如果是则说明此异常并非源自惰性按需分页(比如写入只读页),这个问题不归我们管,直接返回 `false`。 +- 接下来的第15行检查出错的虚拟地址是否在映射区域内,如果是我们才上手来处理。 + +在实际的修复过程中: +- 第19行先调用 `FileMapping` 的 `map` 方法建立目标虚拟地址到物理页面的映射; +- 第20行将新的映射关系添加到页表; +- 第22~35行处理文件读入。注意实际的文件读取只发生在物理页面的引用计数从0变为1的时候,存在共享的情况下再读取文件可能会覆盖掉用户对内存的修改。 + +`FileMapping` 的 `map` 方法实现如下: + +```rust +impl FileMapping { + /// Create mapping for given virtual address + fn map(&mut self, va: VirtAddr) -> Option<(PhysPageNum, MapRange, bool)> { + // Note: currently virtual address ranges never intersect + let vpn = va.floor(); + for range in &self.ranges { + if !range.contains(va) { + continue; + } + let offset = range.file_offset(vpn); + let (ppn, shared) = match self.map.get(&offset) { + Some(&ppn) => (ppn, true), + None => { + let frame = frame_alloc().unwrap(); + let ppn = frame.ppn; + self.frames.push(frame); + self.map.insert(offset, ppn); + (ppn, false) + } + }; + if range.perm.contains(MapPermission::W) { + self.dirty_parts.insert(offset); + } + return Some((ppn, range.clone(), shared)); + } + None + } +} +``` + +- 第6~9行先找到包含目标虚拟地址的映射区域; +- 第10行计算虚拟地址对应的文件内偏移; +- 第11~20行先查询此文件偏移是否对应已分配的物理页,如果没有则分配一个物理页帧并记录映射关系; +- 第21~23行检查此映射区域是否有写入权限,如果有则将对应的物理页面标记为脏页。这个处理实际上比较粗糙,有些没有被真正写入的页面也被视为脏页,导致最后会有多余的文件回写。你也可以考虑不维护脏页信息,而是通过检查页表项中由硬件维护的 Dirty 位来确定哪些是真正的脏页。 + +修复后用户进程重新执行触发缺页异常的指令,此时物理页里存放了文件的内容,这样用户就实现了以读取内存的方式来读取文件。最后来处理被修改的脏页的同步,给 `FileMapping` 添加 `sync` 方法: + +```rust +impl FileMapping { + /// Write back all dirty pages + pub fn sync(&self) { + let file_size = self.file.get_size(); + for &offset in self.dirty_parts.iter() { + let ppn = self.map.get(&offset).unwrap(); + if offset < file_size { + // WARNING: this can still cause garbage written + // to file when sharing physical page + let va_len = self + .ranges + .iter() + .map(|r| { + if r.offset <= offset && offset < r.offset + r.len { + PAGE_SIZE.min(r.offset + r.len - offset) + } else { + 0 + } + }) + .max() + .unwrap(); + let write_len = va_len.min(file_size - offset); + + self.file + .write_at(offset, &ppn.get_bytes_array()[..write_len]); + } + } + } +} +``` + +这个方法将所有潜在的脏物理页内容回写至文件。第10~22行的计算主要为了限制写入内容的长度,以避免垃圾被意外写入文件。 + +剩下的问题是何时调用 `sync` 。正常来说munmap、msync是同步点,你可以自行实现这两个系统调用,这里我们把它放在进程退出之前: + +os/src/task/mod.rs + +```rust +/// Exit the current 'Running' task and run the next task in task list. +pub fn exit_current_and_run_next(exit_code: i32) { + let task = take_current_task().unwrap(); + // ... + let mut inner = task.inner_exclusive_access(); + // ... + inner.children.clear(); + // deallocate user space + inner.memory_set.recycle_data_pages(); + // write back dirty pages + for mapping in inner.file_mappings.iter() { + mapping.sync(); + } + drop(inner); + // **** release current PCB + // drop task manually to maintain rc correctly + drop(task); + // ... +} +``` + +这样我们就实现了基于内存读写方式的文件读写功能。可以看到mmap不是魔法,内核悄悄帮你完成了实际的文件读写。 + +1. \*\* 扩展easy-fs文件系统功能,支持二级目录结构。可扩展:支持N级目录结构。 + +实际上easy-fs现有的代码支持目录的存在,只不过整个文件系统只有根目录一个目录,我们考虑放宽现有代码的一些限制。 + +原本的 `easy-fs/src/vfs.rs` 中有一个用于在当前目录下创建常规文件的 `create` 方法,我们给它加个参数并包装一下: + +easy-fs/src/vfs.rs + +```rust +impl Inode { + /// Create inode under current inode by name + fn create_inode(&self, name: &str, inode_type: DiskInodeType) -> Option> { + let mut fs = self.fs.lock(); + let op = |root_inode: &DiskInode| { + // assert it is a directory + assert!(root_inode.is_dir()); + // has the file been created? + self.find_inode_id(name, root_inode) + }; + if self.read_disk_inode(op).is_some() { + return None; + } + // create a new file + // alloc a inode with an indirect block + let new_inode_id = fs.alloc_inode(); + // initialize inode + let (new_inode_block_id, new_inode_block_offset) = fs.get_disk_inode_pos(new_inode_id); + get_block_cache(new_inode_block_id as usize, Arc::clone(&self.block_device)) + .lock() + .modify(new_inode_block_offset, |new_inode: &mut DiskInode| { + new_inode.initialize(inode_type); + }); + self.modify_disk_inode(|root_inode| { + // append file in the dirent + let file_count = (root_inode.size as usize) / DIRENT_SZ; + let new_size = (file_count + 1) * DIRENT_SZ; + // increase size + self.increase_size(new_size as u32, root_inode, &mut fs); + // write dirent + let dirent = DirEntry::new(name, new_inode_id); + root_inode.write_at( + file_count * DIRENT_SZ, + dirent.as_bytes(), + &self.block_device, + ); + }); + + let (block_id, block_offset) = fs.get_disk_inode_pos(new_inode_id); + block_cache_sync_all(); + // return inode + Some(Arc::new(Self::new( + block_id, + block_offset, + self.fs.clone(), + self.block_device.clone(), + ))) + // release efs lock automatically by compiler + } + + /// Create regular file under current inode + pub fn create(&self, name: &str) -> Option> { + self.create_inode(name, DiskInodeType::File) + } + + /// Create directory under current inode + pub fn create_dir(&self, name: &str) -> Option> { + self.create_inode(name, DiskInodeType::Directory) + } +} +``` + +这样我们就可以在一个目录底下调用 `create_dir` 创建新目录了(笑)。本质上我们什么也没改,我们再改改其它方法装装样子: + +easy-fs/src/vfs.rs + +```rust +impl Inode { + /// List inodes under current inode + pub fn ls(&self) -> Vec { + let _fs = self.fs.lock(); + self.read_disk_inode(|disk_inode| { + let mut v: Vec = Vec::new(); + if disk_inode.is_file() { + return v; + } + + let file_count = (disk_inode.size as usize) / DIRENT_SZ; + for i in 0..file_count { + let mut dirent = DirEntry::empty(); + assert_eq!( + disk_inode.read_at(i * DIRENT_SZ, dirent.as_bytes_mut(), &self.block_device,), + DIRENT_SZ, + ); + v.push(String::from(dirent.name())); + } + v + }) + } + + /// Write data to current inode + pub fn write_at(&self, offset: usize, buf: &[u8]) -> usize { + let mut fs = self.fs.lock(); + let size = self.modify_disk_inode(|disk_inode| { + assert!(disk_inode.is_file()); + + self.increase_size((offset + buf.len()) as u32, disk_inode, &mut fs); + disk_inode.write_at(offset, buf, &self.block_device) + }); + block_cache_sync_all(); + size + } + + /// Clear the data in current inode + pub fn clear(&self) { + let mut fs = self.fs.lock(); + self.modify_disk_inode(|disk_inode| { + assert!(disk_inode.is_file()); + + let size = disk_inode.size; + let data_blocks_dealloc = disk_inode.clear_size(&self.block_device); + assert!(data_blocks_dealloc.len() == DiskInode::total_blocks(size) as usize); + for data_block in data_blocks_dealloc.into_iter() { + fs.dealloc_data(data_block); + } + }); + block_cache_sync_all(); + } +} +``` + +对一个普通文件的inode调用 `ls` 方法毫无意义,但为了保持接口不变,我们返回一个空 `Vec`。随意地清空或写入目录文件都会损坏目录结构,这里直接在 `write_at` 和 `clear` 方法中断言,你也可以改成其它的错误处理方式。 + +接下来是实际一点的修改(有,但不多):我们让 `find` 方法支持简单的相对路径(不含“.”和“..”)。 + +easy-fs/src/vfs.rs + +```rust +impl Inode { + /// Find inode under current inode by **path** + pub fn find(&self, path: &str) -> Option> { + let fs = self.fs.lock(); + let mut block_id = self.block_id as u32; + let mut block_offset = self.block_offset; + for name in path.split('/').filter(|s| !s.is_empty()) { + let inode_id = get_block_cache(block_id as usize, self.block_device.clone()) + .lock() + .read(block_offset, |disk_inode: &DiskInode| { + if disk_inode.is_file() { + return None; + } + self.find_inode_id(name, disk_inode) + }); + if inode_id.is_none() { + return None; + } + (block_id, block_offset) = fs.get_disk_inode_pos(inode_id.unwrap()); + } + Some(Arc::new(Self::new( + block_id, + block_offset, + self.fs.clone(), + self.block_device.clone(), + ))) + } +} +``` + +最后在 `easy-fs-fuse/src/main.rs` 里试试我们添加的新特性: + +easy-fs-fuse/src/main.rs + +```rust +fn read_string(file: &Arc) -> String { + let mut read_buffer = [0u8; 512]; + let mut offset = 0usize; + let mut read_str = String::new(); + loop { + let len = file.read_at(offset, &mut read_buffer); + if len == 0 { + break; + } + offset += len; + read_str.push_str(core::str::from_utf8(&read_buffer[..len]).unwrap()); + } + read_str +} + +fn tree(inode: &Arc, name: &str, depth: usize) { + for _ in 0..depth { + print!(" "); + } + println!("{}", name); + for name in inode.ls() { + let child = inode.find(&name).unwrap(); + tree(&child, &name, depth + 1); + } +} + +#[test] +fn efs_dir_test() -> std::io::Result<()> { + let block_file = Arc::new(BlockFile(Mutex::new({ + let f = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open("target/fs.img")?; + f.set_len(8192 * 512).unwrap(); + f + }))); + EasyFileSystem::create(block_file.clone(), 4096, 1); + let efs = EasyFileSystem::open(block_file.clone()); + let root = Arc::new(EasyFileSystem::root_inode(&efs)); + root.create("f1"); + root.create("f2"); + + let d1 = root.create_dir("d1").unwrap(); + + let f3 = d1.create("f3").unwrap(); + let d2 = d1.create_dir("d2").unwrap(); + + let f4 = d2.create("f4").unwrap(); + tree(&root, "/", 0); + + let f3_content = "3333333"; + let f4_content = "4444444444444444444"; + f3.write_at(0, f3_content.as_bytes()); + f4.write_at(0, f4_content.as_bytes()); + + assert_eq!(read_string(&d1.find("f3").unwrap()), f3_content); + assert_eq!(read_string(&root.find("/d1/f3").unwrap()), f3_content); + assert_eq!(read_string(&d2.find("f4").unwrap()), f4_content); + assert_eq!(read_string(&d1.find("d2/f4").unwrap()), f4_content); + assert_eq!(read_string(&root.find("/d1/d2/f4").unwrap()), f4_content); + assert!(f3.find("whatever").is_none()); + Ok(()) +} +``` + +如果你觉得这个练习不够过瘾,可以试试下面的任务: + +- 让easy-fs支持包含“.”和“..”的相对路径。你可以在目录文件里存放父目录的inode。 +- 在内核里给进程加上当前路径信息,然后实现chdir和getcwd。当然,也可以顺便补上openat和mkdir。 +- 在easy-fs中实现rename和mv的功能。在目录文件中删掉一些目录项也许要实现 `decrease_size` 或者类似删除的东西,但也可以考虑用删除标记这种常见的手段让一个目录项变得“不存在”。 + +### 问答题 + +1. \* 文件系统的功能是什么? + + 将数据以文件的形式持久化保存在存储设备上。 +2. \*\* 目前的文件系统只有单级目录,假设想要支持多级文件目录,请描述你设想的实现方式,描述合理即可。 + + 允许在目录项中存在目录(原本只能存在普通文件)即可。 +3. \*\* 软链接和硬链接是干什么的?有什么区别?当删除一个软链接或硬链接时分别会发生什么? + + 软硬链接的作用都是给一个文件以"别名",使得不同的多个路径可以指向同一个文件。当删除软链接时候,对文件没有任何影响,当删除硬链接时,文件的引用计数会被减一,若引用计数为0,则该文件所占据的磁盘空间将会被回收。 +4. \*\*\* 在有了多级目录之后,我们就也可以为一个目录增加硬链接了。在这种情况下,文件树中是否可能出现环路(软硬链接都可以,鼓励多尝试)?你认为应该如何解决?请在你喜欢的系统上实现一个环路,描述你的实现方式以及系统提示、实际测试结果。 + + 是可以出现环路的,一种可能的解决方式是在访问文件的时候检查自己遍历的路径中是否有重复的inode,并在发现环路时返回错误。 +5. \* 目录是一类特殊的文件,存放的是什么内容?用户可以自己修改目录内容吗? + + 存放的是目录中的文件列表以及他们对应的inode,通常而言用户不能自己修改目录的内容,但是可以通过操作目录(如mv里面的文件)的方式间接修改。 +6. \*\* 在实际操作系统中,如Linux,为什么会存在大量的文件系统类型? + + 因为不同的文件系统有着不同的特性,比如对于特定种类的存储设备的优化,或是快照和多设备管理等高级特性,适用于不同的使用场景。 +7. \*\* 可以把文件控制块放到目录项中吗?这样做有什么优缺点? + + 可以,是对于小目录可以减少一次磁盘访问,提升性能,但是对大目录而言会使得在目录中查找文件的性能降低。 +8. \*\* 为什么要同时维护进程的打开文件表和操作系统的打开文件表?这两个打开文件表有什么区别和联系? + + 多个进程可能会同时打开同一个文件,操作系统级的打开文件表可以加快后续的打开操作,但同时由于每个进程打开文件时使用的访问模式或是偏移量不同,所以还需要进程的打开文件表另外记录。 +9. \*\* 文件分配的三种方式是如何组织文件数据块的?各有什么特征(存储、文件读写、可靠性)? + + 连续分配:实现简单、存取速度快,但是难以动态增加文件大小,长期使用后会产生大量无法使用(过小而无法放入大文件)碎片空间。 + + 链接分配:可以处理文件大小的动态增长,也不会出现碎片,但是只能按顺序访问文件中的块,同时一旦有一个块损坏,后面的其他块也无法读取,可靠性差。 + + 索引分配:可以随机访问文件中的偏移量,但是对于大文件需要实现多级索引,实现较为复杂。 +10. \*\* 如果一个程序打开了一个文件,写入了一些数据,但是没有及时关闭,可能会有什么后果?如果打开文件后,又进一步发出了读文件的系统调用,操作系统中各个组件是如何相互协作完成整个读文件的系统调用的? + +> (若也没有flush的话)假如此时操作系统崩溃,尚处于内存缓冲区中未写入磁盘的数据将会丢失,同时也会占用文件描述符,造成资源的浪费。首先是系统调用处理的部分,将这一请求转发给文件系统子系统,文件系统子系统再将其转发给块设备子系统,最后再由块设备子系统转发给实际的磁盘驱动程序读取数据,最终返回给程序。 +1. \*\*\* 文件系统是一个操作系统必要的组件吗?是否可以将文件系统放到用户态?这样做有什么好处?操作系统需要提供哪些基本支持? + + 不是,如在本章之前的rCore就没有文件系统。可以,如在Linux下就有FUSE这样的框架可以实现这一点。这样可以使得文件系统的实现更为灵活,开发与调试更为简便。操作系统需要提供一个注册用户态文件系统实现的机制,以及将收到的文件系统相关系统调用转发给注册的用户态进程的支持。 diff --git a/ch6/book/file-and-dir.png b/ch6/book/file-and-dir.png new file mode 100644 index 00000000..7e82bb36 Binary files /dev/null and b/ch6/book/file-and-dir.png differ diff --git a/ch6/book/file-open.png b/ch6/book/file-open.png new file mode 100644 index 00000000..3c2dcf15 Binary files /dev/null and b/ch6/book/file-open.png differ diff --git a/ch6/book/filesystem-general.png b/ch6/book/filesystem-general.png new file mode 100644 index 00000000..4803ae4d Binary files /dev/null and b/ch6/book/filesystem-general.png differ diff --git a/ch6/book/fsos-fsdisk.png b/ch6/book/fsos-fsdisk.png new file mode 100644 index 00000000..79872536 Binary files /dev/null and b/ch6/book/fsos-fsdisk.png differ diff --git a/ch6/book/index.md b/ch6/book/index.md new file mode 100644 index 00000000..7f7126be --- /dev/null +++ b/ch6/book/index.md @@ -0,0 +1,80 @@ + + +# 第六章:文件系统 + +* [引言](0intro.md) + * [本章导读](0intro.md#id2) + * [实践体验](0intro.md#id4) + * [本章代码树](0intro.md#id5) + * [本章代码导读](0intro.md#id6) +* [文件系统接口](1fs-interface.md) + * [本节导读](1fs-interface.md#id2) + * [文件和目录](1fs-interface.md#id3) + * [常规文件](1fs-interface.md#id4) + * [目录](1fs-interface.md#id5) + * [文件系统](1fs-interface.md#id6) + * [简化的文件与目录抽象](1fs-interface.md#fs-simplification) + * [打开、关闭与读写文件的系统调用](1fs-interface.md#id8) + * [文件打开](1fs-interface.md#sys-open) + * [文件关闭](1fs-interface.md#sys-close) + * [文件的顺序读写](1fs-interface.md#id11) +* [简易文件系统 easy-fs](2fs-implementation.md) + * [本节导读](2fs-implementation.md#id1) + * [松耦合模块化设计思路](2fs-implementation.md#id2) + * [块设备接口层](2fs-implementation.md#id3) + * [块缓存层](2fs-implementation.md#id4) + * [块缓存](2fs-implementation.md#id5) + * [块缓存全局管理器](2fs-implementation.md#id6) + * [磁盘布局及磁盘上数据结构](2fs-implementation.md#id7) + * [easy-fs 磁盘布局概述](2fs-implementation.md#id8) + * [easy-fs 超级块](2fs-implementation.md#id9) + * [位图](2fs-implementation.md#id10) + * [磁盘上索引节点](2fs-implementation.md#id11) + * [数据块与目录项](2fs-implementation.md#id12) + * [磁盘块管理器](2fs-implementation.md#id13) + * [索引节点](2fs-implementation.md#id14) + * [获取根目录的 inode](2fs-implementation.md#inode) + * [文件索引](2fs-implementation.md#id15) + * [文件列举](2fs-implementation.md#id16) + * [文件创建](2fs-implementation.md#id17) + * [文件清空](2fs-implementation.md#id18) + * [文件读写](2fs-implementation.md#id19) + * [在用户态测试 easy-fs 的功能](2fs-implementation.md#id20) + * [在Rust应用开发环境中模拟块设备](2fs-implementation.md#rust) + * [打开块设备](2fs-implementation.md#id21) + * [从块设备上打开文件系统](2fs-implementation.md#id22) + * [获取根目录的 Inode](2fs-implementation.md#id23) + * [进行各种文件操作](2fs-implementation.md#id24) + * [将应用打包为 easy-fs 镜像](2fs-implementation.md#id25) +* [在内核中接入 easy-fs](3using-easy-fs-in-kernel.md) + * [本节导读](3using-easy-fs-in-kernel.md#id1) + * [文件简介](3using-easy-fs-in-kernel.md#id2) + * [块设备驱动层](3using-easy-fs-in-kernel.md#id3) + * [Qemu 模拟器平台](3using-easy-fs-in-kernel.md#qemu) + * [K210 真实硬件平台](3using-easy-fs-in-kernel.md#k210) + * [内核索引节点层](3using-easy-fs-in-kernel.md#id5) + * [文件描述符层](3using-easy-fs-in-kernel.md#id6) + * [文件描述符表](3using-easy-fs-in-kernel.md#id7) + * [应用访问文件的内核机制实现](3using-easy-fs-in-kernel.md#id8) + * [文件系统初始化](3using-easy-fs-in-kernel.md#id9) + * [打开与关闭文件](3using-easy-fs-in-kernel.md#id10) + * [基于文件来加载并执行应用](3using-easy-fs-in-kernel.md#id11) + * [读写文件](3using-easy-fs-in-kernel.md#id12) +* [练习](4exercise.md) + * [课后练习](4exercise.md#id2) + * [编程题](4exercise.md#id3) + * [问答题](4exercise.md#id4) + * [实验练习](4exercise.md#id5) + * [实践作业](4exercise.md#id6) + * [硬链接](4exercise.md#id7) + * [实验要求](4exercise.md#id8) + * [问答作业](4exercise.md#id9) + * [实验练习的提交报告要求](4exercise.md#id10) +* [练习参考答案](5answer.md) + * [课后练习](5answer.md#id2) + * [编程题](5answer.md#id3) + * [问答题](5answer.md#id4) + + diff --git a/ch6/book/simple-file-and-dir.png b/ch6/book/simple-file-and-dir.png new file mode 100644 index 00000000..d976a180 Binary files /dev/null and b/ch6/book/simple-file-and-dir.png differ diff --git "a/ch6/book/\346\226\207\344\273\266\347\263\273\347\273\237\345\270\203\345\261\200.png" "b/ch6/book/\346\226\207\344\273\266\347\263\273\347\273\237\345\270\203\345\261\200.png" new file mode 100644 index 00000000..d116d82f Binary files /dev/null and "b/ch6/book/\346\226\207\344\273\266\347\263\273\347\273\237\345\270\203\345\261\200.png" differ