add ch5.
This commit is contained in:
parent
cf3ec2bb2a
commit
43c2f8d9e9
|
@ -4,41 +4,18 @@
|
|||
本章导读
|
||||
-------------------------------------------
|
||||
|
||||
在正式开始这一章的介绍之前,我们很高兴告诉读者:在前面的章节中基本涵盖了一个功能相对完善的内核抽象所需的所有硬件机制,而从本章开始我们所做的主要是一些软件上的工作,这会略微轻松一些。
|
||||
本章不同于前面几章对OS框架的大量修改,主要着力于增加OS对于进程管理的支持。因此内容和难度相比前面都会轻松很多~~.
|
||||
|
||||
在前面的章节中,随着应用的需求逐渐变得复杂,作为其执行环境的内核也需要在硬件提供的相关机制的支持之下努力为应用提供更多强大、易用且安全的抽象。让我们先来简单回顾一下:
|
||||
|
||||
- 第一章《RV64 裸机应用》中,由于我们从始至终只需运行一个应用,这时我们的内核看起来只是一个 **函数库** ,它会对应用的执行环境进行初始化,包括设置函数调用栈的位置使得应用能够正确使用内存。此外,它还将 SBI 接口函数进行了封装使得应用更容易使用这些功能。
|
||||
- 第二章《批处理系统》中,我们需要自动加载并执行一个固定序列内的多个应用,当一个应用出错或者正常退出之后则切换到下一个。为了让这个流程能够稳定进行而不至于被某个应用的错误所破坏,内核需要借助硬件提供的 **特权级机制** 将应用代码放在 U 特权级执行,并对它的行为进行限制。一旦应用出现错误或者请求一些只有内核才能提供的服务时,控制权会移交给内核并对该 **Trap** 进行处理。
|
||||
- 第三章《多道程序与分时多任务》中,出于一些对于总体性能或者交互性的要求,从 CPU 的角度看,它在执行一个应用一段时间之后,会暂停这个应用并切换出去,等到之后切换回来的时候再继续执行。其核心机制就是 **任务切换** 。对于每个应用来说,它会认为自己始终独占一个 CPU ,不过这只是内核对 CPU 资源的恰当抽象给它带来的一种幻象。
|
||||
- 第四章《地址空间》中,我们利用一种经典的抽象—— **地址空间** 来代替先前对于物理内存的直接访问。这样做使得每个应用独占一个访存空间并与其他应用隔离起来,并由内核和硬件机制保证不同应用的数据(应用间的共享数据除外)被实际存放在物理内存上的位置也不相交。于是开发者在开发应用的时候无需顾及其他应用,整个系统的安全性也得到了一定保证。
|
||||
|
||||
事实上,由于我们还没有充分发掘这些抽象的能力,应用的开发和使用仍然比较受限,且用户在应用运行过程中的灵活性和交互性不够强,这尤其体现在交互能力上。目前为止,所有的应用都是在内核初始化阶段被一并加载到内存中的,之后也无法对应用进行动态增删,从用户的角度来看这和第二章的批处理系统似乎并没有什么不同。
|
||||
|
||||
.. _term-terminal:
|
||||
.. _term-command-line:
|
||||
支持了页表之后,我们的操作系统在硬件上的支持就告一段落了。但是目前我们应用测例的执行方式还是十分机械化的,并且无法和用户交互。目前为止,所有的应用都是在内核初始化阶段被一并加载到内存中的,之后也无法对应用进行动态增删,从用户的角度来看这和第二章的批处理系统似乎并没有什么不同。
|
||||
|
||||
于是,本章我们会开发一个用户 **终端** (Terminal) 或称 **命令行** 应用(Command Line Application, 俗称 **Shell** ) ,形成用户与操作系统进行交互的命令行界面(Command Line Interface),它就和我们今天常用的 OS 中的命令行应用(如 Linux中的bash,Windows中的CMD等)没有什么不同:只需在其中输入命令即可启动或杀死应用,或者监控系统的运行状况。这自然是现代 OS 中不可缺少的一部分,并大大增加了系统的 **可交互性** ,使得用户可以更加灵活地控制系统。
|
||||
|
||||
为了在用户态就可以借助操作系统的服务动态灵活地管理和控制应用的执行,我们需要在已有的 **任务** 抽象的基础上进一步扩展,形成新的抽象: **进程** ,并实现若干基于 **进程** 的强大系统调用。
|
||||
我们想一下shell执行命令的过程。首先,shell必须支持读入用户的输入,并且如果我们在shell之中运行一个测例程序,它需要创建一个新的进程来执行这个命令对应的执行流。这里shell本身对于OS来说,也是一个进程。这就意味着我们需要支持进程创建进程的系统调用。实际上,在第四章添加了页表支持之后,现在我们可以开始实现几个进程非常关键的系统调用了,它们都是大家在课堂上已经耳熟能详的函数::
|
||||
|
||||
- *创建* (Create):操作系统需要提供一些创建新进程的服务。用户在shell中键入命令或用鼠标双击应用程序图标(这需要GUI界面,目前我们还没有实现)时,会调用操作系统服务来创建新进程,运行指定的程序。
|
||||
- *销毁* (Destroy):操作系统还需提供退出并销毁进程的服务。进程会在运行完成后可自行退出,但还需要其他进程(如创建这些进程的父进程)来回收这些进程最后的资源并销毁这些进程。
|
||||
- *等待* (Wait):操作系统提供等待进程停止运行是很有用的,比如上面提到的退出信息的收集。
|
||||
- *信息* (Info):操作系统也可提供有关进程的身份和状态等进程信息,例如进程的ID,进程的运行状态,进程的优先级等。
|
||||
- 其他控制:操作系统还可有其他的进程控制服务。例如,让一个进程能够杀死另外一个进程,暂停进程(停止运行一段时间),恢复进程(继续运行)等。
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
**任务和进程的关系与区别**
|
||||
|
||||
第三章提到的 **任务** 和这里提到的 **进程** 有何关系和区别? 这需要从二者对资源的占用和执行的过程这两个方面来进行分析。
|
||||
|
||||
任务和进程都是一个程序的执行过程,或表示了一个运行的程序;都是能够被操作系统打断并通过切换来分时占用CPU资源;都需要 **地址空间** 来放置代码和数据;都有从开始运行到结束运行这样的生命周期。
|
||||
|
||||
第三章提到的 **任务** 是这里提到的 **进程** 的初级阶段,还没进化到拥有更强大的动态变化的功能:进程可以在运行的过程中,创建 **子进程** 、 用新的 **程序** 内容覆盖已有的 **程序** 内容、可管理更多的 物理或虚拟的 **资源** 。
|
||||
|
||||
sys_read(int fd, char* buf, int size): 从标准输入读取若干个字节。
|
||||
sys_fork(): 创建一个与当前进程几乎完全一致的进程。
|
||||
sys_exec(char* filename): 修改当前进程,使其从头开始执行指定程序。
|
||||
sys_wait(int pid, int* exit_code): 等待某一个或者任意一个子进程结束,获取其 exit_code。
|
||||
|
||||
|
||||
实践体验
|
||||
|
@ -48,8 +25,6 @@
|
|||
|
||||
.. code-block:: console
|
||||
|
||||
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
|
||||
$ cd rCore-Tutorial-v3
|
||||
$ git checkout ch5
|
||||
|
||||
在 qemu 模拟器上运行本章代码:
|
||||
|
@ -134,116 +109,10 @@
|
|||
|
||||
当应用执行完毕后,将继续回到shell程序的命令输入模式。
|
||||
|
||||
本章代码树
|
||||
--------------------------------------
|
||||
|
||||
.. code-block::
|
||||
:linenos:
|
||||
|
||||
./os/src
|
||||
Rust 25 Files 1760 Lines
|
||||
Assembly 3 Files 88 Lines
|
||||
|
||||
├── bootloader
|
||||
│ ├── rustsbi-k210.bin
|
||||
│ └── rustsbi-qemu.bin
|
||||
├── LICENSE
|
||||
├── os
|
||||
│ ├── build.rs(修改:基于应用名的应用构建器)
|
||||
│ ├── Cargo.toml
|
||||
│ ├── Makefile
|
||||
│ └── src
|
||||
│ ├── config.rs
|
||||
│ ├── console.rs
|
||||
│ ├── entry.asm
|
||||
│ ├── lang_items.rs
|
||||
│ ├── link_app.S
|
||||
│ ├── linker-k210.ld
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
├── tools
|
||||
│ ├── kflash.py
|
||||
│ ├── LICENSE
|
||||
│ ├── package.json
|
||||
│ ├── README.rst
|
||||
│ └── setup.py
|
||||
└── 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
|
||||
|
||||
|
||||
本章代码导读
|
||||
-----------------------------------------------------
|
||||
|
||||
本章的第一小节 :doc:`/chapter5/1process` 介绍了操作系统中经典的进程概念,并描述我们将要实现的参考自 Unix 系内核并经过简化的精简版进程模型。在该模型下,若想对进程进行管理,实现创建、退出等操作,核心就在于 ``fork/exec/waitpid`` 三个系统调用。
|
||||
本章对于框架没有大量修改的代码。由于添加的系统调用是针对进程方面的,除了在syscall.c之中添加了相关接口的定义之外,主要函数的实现都在proc.c之中完成。
|
||||
|
||||
首先我们修改运行在应用态的应用软件,它们均放置在 ``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`` 可以按照顺序执行绝大部分应用,会在测试的时候为我们提供很多方便。
|
||||
|
||||
接下来就需要在内核中实现简化版的进程机制并支持新增的系统调用。在本章第二小节 :doc:`/chapter5/2core-data-structures` 中我们对一些进程机制相关的数据结构进行了重构或者修改:
|
||||
|
||||
- 为了支持基于应用名而不是应用 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 决定,我们需要在二者之间建立联系并提供一些相应的资源自动回收机制。可以参考 ``os/src/task/pid.rs`` 。
|
||||
|
||||
有了这些数据结构的支撑,我们在本章第三小节 :doc:`/chapter5/3implement-process-mechanism` 实现进程机制。它可以分成如下几个方面:
|
||||
|
||||
- 初始进程的自动创建。在内核初始化的时候需要调用 ``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`` 两个系统调用放在相同位置)捕获到它的退出码之后,它的进程控制块才会被回收,从而所有资源都被回收。
|
||||
- 为了支持用户终端 ``user_shell`` 读取用户键盘输入的功能,还需要实现 ``read`` 系统调用,它可以在 ``os/src/syscall/fs.rs`` 中找到。
|
||||
(训练可能会改)我们已经完成了对上述几系统调用的支持,在开始本章的练习之前,大家需要仔细研究它们的实现细节,可以复习课堂上的知识,并且大大降低练习的难度。
|
|
@ -1,44 +1,18 @@
|
|||
进程概念及重要系统调用
|
||||
进程的重要系统调用
|
||||
================================================
|
||||
|
||||
本节导读
|
||||
进程复习
|
||||
-------------------------
|
||||
|
||||
本节的内容有:
|
||||
本章虽然添加了一系列的系统调用,但是关于进程的调度等是基本没有修改的。主要修改的是进程的结构体以及针对系统调用的支持。
|
||||
|
||||
- 介绍进程的概念以及它和一些其他相近的概念的比较;
|
||||
- 从应用开发者或是用户的角度介绍我们的实现中一种简单的类 Unix 进程模型;
|
||||
- 介绍三个最重要的进程相关的系统调用并给出一些用例。
|
||||
我们看一看我们进程支持的状态::
|
||||
|
||||
进程概念
|
||||
-------------------------
|
||||
UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE
|
||||
|
||||
.. _term-process:
|
||||
其中的ZOMBIE(僵尸)状态在本章开始我们就可能遇到了。ZOMBIE在我们的OS中可能会在如下情景出现:一个进程存在父进程且在父进程未结束时就结束,在等待父进程释放其资源时,我们设定其处于ZOMBIE态。
|
||||
|
||||
在本章的引言中,出于方便应用开发和使得应用功能更加强大的目标,我们引入了进程的概念。所谓 **进程** (Process) ,就是指 **正在执行的程序** 。尽管说起来很容易,但事实上进程是一个内涵相当丰富且深刻、难以从单个角度解释清楚的抽象概念。我们可以先试着从动态和静态的角度来进行初步的思考。我们知道,当一个应用被成功构建之后,它会从源代码变为某种格式的可执行文件,如果将其展开的话可以在它的内存布局中看到若干个功能迥异的逻辑段。但仅是如此的话,它也就只是某种格式特殊的、被 **静态** 归档到存储器上的一个文件而已。
|
||||
|
||||
然而,可执行文件与其他类型文件的决定性的不同就在于它可以被内核加载并执行。这一过程自然是不能凭空进行的,而是需要占据某些真实的硬件资源。例如,可执行文件一定需要被加载到物理内存的某些区域中才能执行,另外还可能需要预留一些可执行文件内存布局中未规划的区域(比如栈),这就会消耗掉部分内存空间;在执行的时候需要占据一个 CPU 的全部硬件资源,我们之前介绍过的有通用寄存器(其中程序计数器 pc 和栈指针 sp 两个意义尤其重大)、CSR 、各级 cache 、TLB 等。
|
||||
|
||||
打一个比方,可执行文件本身可以看成一张编译器解析源代码之后总结出的一张记载如何利用各种硬件资源进行一轮生产流程的 **蓝图** 。而内核的一大功能便是作为一个硬件资源管理器,它可以随时启动一轮生产流程(即执行任意一个应用),这需要选中一张蓝图(此时确定执行哪个可执行文件),接下来就需要内核按照蓝图上所记载的对资源的需求来对应的将各类资源分配给它让这轮生产流程得以顺利进行。当按照蓝图上的记载生产流程完成(应用退出)之后,内核还需要将对应的硬件资源回收以便后续的重复利用。
|
||||
|
||||
因此,进程就是选取某个可执行文件并对其进行一次动态执行的过程。相比可执行文件,它的动态性主要体现在:
|
||||
|
||||
1. 它是一个过程,从时间上来看有开始也有结束;
|
||||
2. 在该过程中对于可执行文件中给出的需求要相应对 **硬件资源** 进行 **动态绑定** 。
|
||||
|
||||
这里需要指出的是,两个进程可以选择同一个可执行文件执行,然而它们却是截然不同的进程:它们的启动时间、占据的硬件资源、输入数据均有可能是不同的,这些条件均会导致它们是不一样的执行过程。在某些情况下,我们可以看到它们的输出是不同的——这是其中一种可能的直观表象。
|
||||
|
||||
在内核中,需要有一个进程管理器,在其中记录每个进程对资源的占用情况,这是内核作为一个硬件资源管理器所必须要做到的。进程管理器通常需要管理多个进程,因为如果同一时间只有一个进程的话,就可以简单的将所有的硬件资源都交给该进程,同时内核也会像第一章《RV64 裸机应用》那样退化成一个函数库。
|
||||
|
||||
本节接下来主要站在应用开发者和用户的角度来介绍如何理解进程概念并基于它编写应用程序。
|
||||
|
||||
.. note::
|
||||
|
||||
**为何要在这里才引入进程**
|
||||
|
||||
根据我们多年来的OS课程经验,学生对 ``进程`` 的简单定义“ **正在执行的程序** ”比较容易理解。但对于多个运行的程序之间如何切换,会带来哪些并发问题,进程创建与虚拟内存的关系是啥等问题很难一下子理解清楚,也不清楚试图解决这些问题的原因。
|
||||
|
||||
这是由于在 ``进程`` 这个定义背后,有特权级切换、异常处理,程序执行的上下文切换、地址映射、地址空间、虚存管理等一系列的知识的支撑,才能理解清楚操作系统对进程的整个管理过程。所以,我们在前面几章对上述知识进行了铺垫。并以此为基础,更加全面地来分析操作系统是如何管理进程的。
|
||||
对其他部分有点忘的同学可以复习一下ch3的实验~。
|
||||
|
||||
.. note::
|
||||
|
||||
|
@ -54,12 +28,9 @@
|
|||
|
||||
协程(coroutines,也称纤程(Fiber)),也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善。在具体实现上,协程可以在用户态运行时库这一层面通过函数调用来实现;也可在语言级支持协程,比如Rust语言引入的 ``async`` 、 ``wait`` 关键字等,通过编译器和运行时库二者配合来简化程序员编程的负担并提高整体的性能。
|
||||
|
||||
进程模型与重要系统调用
|
||||
重要系统调用
|
||||
------------------------------------------------------------
|
||||
|
||||
目前,我们只介绍一种我们的内核实现中所采用的一种非常简单的进程模型。这个进程模型有三个运行状态:就绪态、运行态和等待态;有基于独立的页表的地址空间;可被操作系统调度来分时占用CPU执行;可以动态创建和退出;可通过系统调用获得操作系统的服务。
|
||||
前面我们并没有给出进程需要使用哪些类型的资源,这其实取决于内核提供给应用的系统调用接口以及内核的具体实现。我们实现的进程模型建立在地址空间抽象之上:每个进程都需要一个地址空间,它涵盖了它选择的可执行文件的内存布局,还包含一些其他的逻辑段。且进程模型需要操作系统支持一些重要的系统调用:创建进程、执行新程序、等待进程结束等,来达到应用程序执行的动态灵活性。接下来会介绍这些系统调用的基本功能和设计思路。
|
||||
|
||||
fork 系统调用
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
@ -68,65 +39,152 @@ fork 系统调用
|
|||
|
||||
系统中同一时间存在的每个进程都被一个不同的 **进程标识符** (PID, Process Identifier) 所标识。在内核初始化完毕之后会创建一个进程——即 **用户初始进程** (Initial Process) ,它是目前在内核中以硬编码方式创建的唯一一个进程。其他所有的进程都是通过一个名为 ``fork`` 的系统调用来创建的。
|
||||
|
||||
.. code-block:: rust
|
||||
首先,创建一个进程,就意味着我们要完成对应进程的PCB结构体以及其页表和栈的初始化等等。
|
||||
|
||||
/// 功能:当前进程 fork 出来一个子进程。
|
||||
/// 返回值:对于子进程返回 0,对于当前进程则返回子进程的 PID 。
|
||||
/// syscall ID:220
|
||||
pub fn sys_fork() -> isize;
|
||||
.. code-block:: c
|
||||
|
||||
进程A调用 ``fork`` 系统调用之后,内核会创建一个新进程B,这个进程B和调用 ``fork`` 的进程A在返回用户态那一瞬间几乎处于相同的状态:这意味着它们包含的用户态的代码段、堆栈段及其他数据段的内容完全相同,但是它们是被放在两个独立的地址空间中的。因此新进程的地址空间需要从原有进程的地址空间完整拷贝一份。两个进程通用寄存器也几乎完全相同。例如, pc 相同意味着两个进程会从同一位置的一条相同指令(我们知道其上一条指令一定是用于系统调用的 ecall 指令)开始向下执行, sp 相同则意味着两个进程的用户栈在各自的地址空间中的位置相同。其余的寄存器相同则确保了二者回到了相同的执行流状态。
|
||||
// kernel/proc.c
|
||||
struct proc {
|
||||
enum procstate state;
|
||||
int pid;
|
||||
pagetable_t pagetable;
|
||||
uint64 ustack;
|
||||
uint64 kstack;
|
||||
struct trapframe *trapframe;
|
||||
struct context context;
|
||||
uint64 sz; // Memory size
|
||||
struct proc *parent; // Parent process
|
||||
uint64 exit_code;
|
||||
};
|
||||
|
||||
.. _term-child-process:
|
||||
.. _term-parent-process:
|
||||
|
||||
但是唯有用来保存 ``fork`` 系统调用返回值的 a0 寄存器(这是 RV64 函数调用规范规定的函数返回值所用的寄存器)的值是不同的。这区分了两个进程:原进程的返回值为新创建进程的 PID ,而新创建进程的返回值为 0 。由于新的进程是原进程主动调用 ``fork`` 衍生出来的,我们称新进程为原进程的 **子进程** (Child Process) ,相对的原进程则被称为新进程的 **父进程** (Parent Process) 。这样二者就建立了一种父子关系。注意到每个进程可能有多个子进程,但最多只能有一个父进程,于是所有进程可以被组织成一颗有根树,其根节点正是代表用户初始程序-initproc的第一个用户态的初始进程。
|
||||
进程A调用 ``fork`` 系统调用之后,内核会创建一个新进程B,我们设定B是成为A的子进程。也就会设定其parent指向A的地址。我们再来看一下fork是如何进行新进程的初始化的::
|
||||
|
||||
相比创建一个进程, ``fork`` 更重要的功能是建立一对新的父子关系。在我们的进程模型中,父进程和子进程之间的联系更为紧密,它们更容易进行合作或通信,而且一些重要的机制也需要在它们之间才能展开。
|
||||
.. code-block:: c
|
||||
|
||||
waitpid 系统调用
|
||||
int
|
||||
fork(void)
|
||||
{
|
||||
int pid;
|
||||
struct proc *np;
|
||||
struct proc *p = curr_proc();
|
||||
// Allocate process.
|
||||
if((np = allocproc()) == 0){
|
||||
panic("allocproc\n");
|
||||
}
|
||||
// Copy user memory from parent to child.
|
||||
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
|
||||
panic("uvmcopy\n");
|
||||
}
|
||||
np->sz = p->sz;
|
||||
|
||||
// copy saved user registers.
|
||||
*(np->trapframe) = *(p->trapframe);
|
||||
|
||||
// Cause fork to return 0 in the child.
|
||||
np->trapframe->a0 = 0;
|
||||
pid = np->pid;
|
||||
np->parent = p;
|
||||
np->state = RUNNABLE;
|
||||
return pid;
|
||||
}
|
||||
|
||||
首先,fork调用allocproc分配一个新的进程PCB(具体内容请见lab3,lab4,注意页表的初始化也在alloc时完成了)。之后,根据fork的规定,我们需要把进程A的内存拷贝至B的进程使得二者一样。我们不能仅仅拷贝一份一模一样的页表,那么父子进程就会修改同样的物理内存,发生数据冲突,不符合进程隔离的要求。需要把页表对应的页先拷贝一份,然后建立一个对这些新页有同样映射的页表。这一工作由一个 uvmcopy 的函数去做。uvmcopy函数会遍历A进程的页表,以页为单位将对应的内存复制到B进程页表中新kalloc的空闲地址之中。注意由于mmap系统调用的存在,我们不能简单直接复制A进程虚拟地址[0x0, memory size)对应的物理地址到B,这样会产生遗漏。
|
||||
|
||||
之后,我们把A的trapframe也复制给B,确保了B能继续A的执行流。但是我们设定a0寄存器的值为a,这是因为fork要求子进程的fork返回值是0。之后就是对于PCB的状态设定。
|
||||
|
||||
全部处理完之后,我们就得到了fork的新进程,并且父进程此时的返回值就是子进程的pid。
|
||||
|
||||
这里大家要仔细思考一下,当调度的我们新生成的子进程B的时候,它的执行流具体是什么样子的?这个问题对于理解OS框架十分重要。
|
||||
|
||||
wait 系统调用
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. _term-zombie-process:
|
||||
在 fork 设 定好父子关系之后,wait 的实现就很简单了。我们通过直接遍历进程池数组来获得当前进程的所有子进程。我们来看一下具体系统调用的要求.
|
||||
|
||||
当一个进程通过 ``exit`` 系统调用退出之后,它所占用的资源并不能够立即全部回收。比如该进程的内核栈目前就正用来进行系统调用处理,如果将放置它的物理页帧回收的话,可能会导致系统调用不能正常处理。对于这种问题,一种典型的做法是当进程退出的时候内核立即回收一部分资源并将该进程标记为 **僵尸进程** (Zombie Process) 。之后,由该进程的父进程通过一个名为 ``waitpid`` 的系统调用来收集该进程的返回状态并回收掉它所占据的全部资源,这样这个进程才被彻底销毁。系统调用 ``waitpid`` 的原型如下:
|
||||
.. code-block:: c
|
||||
|
||||
.. code-block:: rust
|
||||
/// pid 表示要等待结束的子进程的进程 ID,如果为 0或者-1 的话表示等待任意一个子进程结束;
|
||||
/// status 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。
|
||||
/// 返回值:如果出现了错误则返回 -1;否则返回结束的子进程的进程 ID。
|
||||
/// 如果子进程存在且尚未完成,该系统调用阻塞等待。
|
||||
/// pid 非法或者指定的不是该进程的子进程或传入的地址 status 不为 0 但是不合法均会导致错误。
|
||||
int waitpid(int pid, int *status);
|
||||
|
||||
/// 功能:当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。
|
||||
/// 参数:pid 表示要等待的子进程的进程 ID,如果为 -1 的话表示等待任意一个子进程;
|
||||
/// exit_code 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。
|
||||
/// 返回值:如果要等待的子进程不存在则返回 -1;否则如果要等待的子进程均未结束则返回 -2;
|
||||
/// 否则返回结束的子进程的进程 ID。
|
||||
/// syscall ID:260
|
||||
pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize;
|
||||
来看一下具体waitpid的实现.
|
||||
|
||||
一般情况下一个进程要负责通过 ``waitpid`` 系统调用来等待所有它 ``fork`` 出来的子进程结束并回收掉它们占据的资源,这也是父子进程间的一种同步手段。但这并不是必须的:如果一个进程先于它的子进程结束,在它退出的时候,它的所有子进程将成为进程树的根节点——用户初始进程的子进程,同时这些子进程的父进程也会变成用户初始进程。这之后,这些子进程的资源就由用户初始进程负责回收了,这也是用户初始进程很重要的一个用途。后面我们会介绍用户初始进程是如何实现的。
|
||||
.. code-block:: c
|
||||
|
||||
int
|
||||
wait(int pid, int* code)
|
||||
{
|
||||
struct proc *np;
|
||||
int havekids;
|
||||
struct proc *p = curr_proc();
|
||||
|
||||
for(;;){
|
||||
// Scan through table looking for exited children.
|
||||
havekids = 0;
|
||||
for(np = pool; np < &pool[NPROC]; np++){
|
||||
if(np->state != UNUSED && np->parent == p && (pid <= 0 || np->pid == pid)){
|
||||
havekids = 1;
|
||||
if(np->state == ZOMBIE){
|
||||
// Found one.
|
||||
np->state = UNUSED;
|
||||
pid = np->pid;
|
||||
*code = np->exit_code;
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!havekids){
|
||||
return -1;
|
||||
}
|
||||
p->state = RUNNABLE;
|
||||
sched();
|
||||
}
|
||||
}
|
||||
|
||||
wait 的思路就是遍历进程数组,看有没有和 pid 匹配的进程。如果有且已经结束(ZOMBIE态),按要求返回。如果指定进程不存在或者不是当前进程子进程,返回错误。如果子进程存在但未结束,调用 sched 切换到其他进程来等待子进程结束。
|
||||
|
||||
exec 系统调用
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
如果仅有 ``fork`` 的话,那么所有的进程都只能和用户初始进程一样执行同样的代码段,这显然是远远不够的。于是我们还需要引入 ``exec`` 系统调用来执行不同的可执行文件:
|
||||
如果仅有 ``fork`` 的话,那么所有的进程都只能和用户初始进程一样执行同样的代码段,这显然是远远不够的。于是我们还需要引入 ``exec`` 系统调用来执行不同的可执行文件。exec要干的事情和 bin_loader 是很相似的。事实上,不同点在于,exec 需要先清理并回收掉当前进程占用的资源,目前只有内存。
|
||||
|
||||
.. code-block:: rust
|
||||
.. code-block:: c
|
||||
|
||||
/// 功能:将当前进程的地址空间清空并加载一个特定的可执行文件,返回用户态后开始它的执行。
|
||||
/// 参数:path 给出了要加载的可执行文件的名字;
|
||||
/// 返回值:如果出错的话(如找不到名字相符的可执行文件)则返回 -1,否则不应该返回。
|
||||
/// syscall ID:221
|
||||
pub fn sys_exec(path: &str) -> isize;
|
||||
|
||||
注意,我们知道 ``path`` 作为 ``&str`` 类型是一个胖指针,既有起始地址又包含长度信息。在实际进行系统调用的时候,我们只会将起始地址传给内核(对标 C 语言仅会传入一个 ``char*`` )。这就需要应用负责在传入的字符串的末尾加上一个 ``\0`` ,这样内核才能知道字符串的长度。下面给出了用户库 ``user_lib`` 中的调用方式:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// user/src/exec.rs
|
||||
|
||||
pub fn sys_exec(path: &str) -> isize {
|
||||
syscall(SYSCALL_EXEC, [path.as_ptr() as usize, 0, 0])
|
||||
int exec(char* name) {
|
||||
int id = get_id_by_name(name);
|
||||
if(id < 0)
|
||||
return -1;
|
||||
struct proc *p = curr_proc();
|
||||
proc_freepagetable(p->pagetable, p->sz);
|
||||
p->sz = 0;
|
||||
p->pagetable = proc_pagetable(p);
|
||||
if(p->pagetable == 0){
|
||||
panic("");
|
||||
}
|
||||
loader(id, p);
|
||||
return 0;
|
||||
}
|
||||
|
||||
这样,利用 ``fork`` 和 ``exec`` 的组合,我们很容易在一个进程内 ``fork`` 出一个子进程并执行一个特定的可执行文件。
|
||||
我们exec的设计是传入待执行测例的文件名。之后会找到文件名对应的id。如果存在对应文件,就会执行内存的释放。
|
||||
|
||||
.. code-block:: c
|
||||
|
||||
void proc_freepagetable(pagetable_t pagetable, uint64 sz)
|
||||
{
|
||||
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
|
||||
uvmunmap(pagetable, TRAPFRAME, 1, 0);
|
||||
uvmfree(pagetable, sz);
|
||||
}
|
||||
|
||||
由于 trapframe 和 trampoline 是可以复用的(每个进程都一样),所以我们并不会把他们删掉,而仅仅是 unmap。而对于用户真正的数据,就会删掉映射的同时把物理页面也 free 掉。(其实 trapframe 和 trampoline 也可以不 unmap 直接用,但我们想复用 loader.c 中的代码,所以先 unmap 掉。)
|
||||
|
||||
之后重新创建一个新的页表并进行trapframe 和 trampoline的新的映射,并将测例加载进入新的进程。这个loader函数相较前面的章节有比较大的修改,我们会在下一节说明。
|
||||
|
||||
支持了fork和exec之后,我们就用拥有了支持shell的基本能力。
|
||||
|
||||
.. _term-redirection:
|
||||
|
||||
|
@ -138,253 +196,3 @@ exec 系统调用
|
|||
因为如果使用 ``fork`` 和 ``exec`` 的组合,那么 ``fork`` 出来的进程仅仅是为了 ``exec`` 一个新应用提供空间。而执行 ``fork`` 中对父进程的地址空间拷贝没有用处,还浪费了时间,且在后续清空地址空间的时候还会产生一些资源回收的额外开销。
|
||||
然而这样做是经过实践考验的——事实上 ``fork`` 和 ``exec`` 是一种灵活的系统调用组合。上述的这些开销能够通过一些技术方法(如 ``copy on write`` 等)大幅降低,且拆分为两个系统调用后,可以灵活地支持 **重定向** (Redirection) 等功能。
|
||||
上述方法是UNIX类操作系统的典型做法,这一点与Windows操作系统不一样。在Windows中, ``CreateProcess`` 函数用来创建一个新的进程和它的主线程,通过这个新进程运行指定的可执行文件。虽然是一个函数,但这个函数的参数十个之多,使得这个函数很复杂,且没有 ``fork`` 和 ``exec`` 的组合的灵活性。
|
||||
|
||||
|
||||
应用程序示例
|
||||
-----------------------------------------------
|
||||
|
||||
我们刚刚介绍了 ``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 :
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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`` 是立即返回的,即它的返回值只能给出返回这一时刻的状态。如果这一时刻要等待的子进程还尚未结束,那么也只能如实向应用报告这一结果。于是用户库 ``user_lib`` 就需要负责对返回状态进行持续的监控,因此它里面便需要进行循环检查。在后面的实现中,我们会将 ``sys_waitpid`` 的内核实现设计为 **阻塞** 的,也即直到得到一个确切的结果位置都停在内核内,也就意味着内核返回给应用的结果可以直接使用。那是 ``wait`` 和 ``waitpid`` 两个 API 的实现便会更加简单。
|
||||
|
||||
用户初始程序-initproc
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
我们首先来看用户初始程序-initproc是如何实现的:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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需要捕获我们的输入并进行解析处理,我们需要加入一个新的用于输入的系统调用:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
/// 功能:从文件中读取一段内容到缓冲区。
|
||||
/// 参数:fd 是待读取文件的文件描述符,切片 buffer 则给出缓冲区。
|
||||
/// 返回值:如果出现了错误则返回 -1,否则返回实际读到的字节数。
|
||||
/// syscall ID:63
|
||||
pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize;
|
||||
|
||||
在实际调用的时候我们必须要同时向内核提供缓冲区的起始地址及长度:
|
||||
|
||||
.. code-block:: 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`` 函数:
|
||||
|
||||
.. code-block:: 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`` 是如何实现的了:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
:emphasize-lines: 28,53,61
|
||||
|
||||
// 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`` 中支持动态内存分配,与第四章的做法相同,只需加入以下内容即可:
|
||||
|
||||
.. code-block:: 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 特权级执行的,但是相比其他应用,它们要更加基础。原则上应该将它们作为一个组件打包在操作系统中。但这里为了实现更加简单,我们并不将它们和其他应用进行区分。
|
||||
|
||||
除此之外,我们还从 :math:`\mu\text{core}` 中借鉴了很多应用测例。它们可以做到同一时间 **并发** 多个进程并能够有效检验我们内核实现的正确性。感兴趣的读者可以参考 ``matrix`` 和 ``forktree`` 等应用。
|
|
@ -1,548 +1,151 @@
|
|||
进程管理的核心数据结构
|
||||
shell与测例的加载
|
||||
===================================
|
||||
|
||||
本节导读
|
||||
-----------------------------------
|
||||
|
||||
为了更好实现进程管理,同时也使得操作系统整体架构更加灵活,能够满足后续的一些需求,我们需要重新设计一些数据结构包含的内容及接口。本节将按照如下顺序来进行介绍:
|
||||
本节将会展示新的bin_loader加载测例到进程的方式,并且展示我们的shell测例是如何运行的。
|
||||
|
||||
- 基于应用名的应用链接/加载器
|
||||
- 进程标识符 ``PidHandle`` 以及内核栈 ``KernelStack``
|
||||
- 任务控制块 ``TaskControlBlock``
|
||||
- 任务管理器 ``TaskManager``
|
||||
- 处理器管理结构 ``Processor``
|
||||
|
||||
基于应用名的应用链接/加载器
|
||||
新的bin_loader
|
||||
------------------------------------------------------------------------
|
||||
|
||||
在实现 ``exec`` 系统调用的时候,我们需要根据应用的名字而不仅仅是一个编号来获取应用的 ELF 格式数据。因此原有的链接和加载接口需要做出如下变更:
|
||||
exec会调用bin_loader,将对应文件名的测例加载到指定的进程p之中。
|
||||
|
||||
在链接器 ``os/build.rs`` 中,我们需要按顺序保存链接进来的每个应用的名字:
|
||||
|
||||
.. code-block::
|
||||
.. code-block:: c
|
||||
:linenos:
|
||||
:emphasize-lines: 8-13
|
||||
|
||||
// os/build.rs
|
||||
|
||||
for i in 0..apps.len() {
|
||||
writeln!(f, r#" .quad app_{}_start"#, i)?;
|
||||
void bin_loader(uint64 start, uint64 end, struct proc *p) {
|
||||
uint64 s = PGROUNDDOWN(start), e = PGROUNDUP(end), length = e - s;
|
||||
// proc_pagetable 完成 trapframe 和 trampoline 的映射
|
||||
p->pagetable = proc_pagetable(p);
|
||||
// 完成 .bin 数据的映射
|
||||
for(uint64 va = BASE_ADDRESS, pa = s; pa < e; va += PGSIZE, pa += PGSIZE) {
|
||||
void* page = kalloc();
|
||||
memmove(page, (const void*)pa, PGSIZE);
|
||||
mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W | PTE_X);
|
||||
}
|
||||
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`` 指出。
|
||||
|
||||
而在加载器 ``loader.rs`` 中,我们用一个全局可见的 *只读* 向量 ``APP_NAMES`` 来按照顺序将所有应用的名字保存在内存中:
|
||||
|
||||
.. code-block:: 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
|
||||
};
|
||||
// 完成用户栈的映射
|
||||
alloc_ustack(p);
|
||||
|
||||
p->trapframe->epc = BASE_ADDRESS;
|
||||
p->sz = USTACK_SIZE + length;
|
||||
}
|
||||
|
||||
使用 ``get_app_data_by_name`` 可以按照应用的名字来查找获得应用的 ELF 数据,而 ``list_apps`` 在内核初始化时被调用,它可以打印出所有可用的应用的名字。
|
||||
其中,对于用户栈、trapframe、trampoline 的映射没有变化,但是对 .bin 数据的映射似乎面目全非了,竟然由一个循环完成。其实,这个循环的逻辑十分简单,就是对于 .bin 的每一页,都申请一个新页并进行内容拷贝,最后建立这一页的映射。之所以这么麻烦完全是由于我们的物理内存管理过于简陋,一次只能分配一个页,如果能够分配连续的物理页,那么这个循环可以被一个 mappages 替代。
|
||||
|
||||
.. code-block:: 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!("**************/")
|
||||
}
|
||||
那么另一个问题是,为什么要拷贝呢?想想 lab4 我们是怎么干的,直接把虚存和物理内存映射就好了,根本没有拷贝。那么,拷贝是为了什么呢?其实,按照 lab4 的做法,程序运行之后就会修改仅有一份的程序"原像",你会发现,lab4 的程序都是一次性的,如果第二次执行,会发现 .data 和 .bss 段数据都被上一次执行改掉了,不是初始化的状态。但是 lab4 的时候,每个程序最多执行一次,所以这么做是可以的。但在 lab5 所有程序都可能被无数次的执行,我们就必须对“程序原像”做保护,在“原像”的拷贝上运行程序了。
|
||||
|
||||
|
||||
进程标识符和内核栈
|
||||
测例的执行
|
||||
------------------------------------------------------------------------
|
||||
|
||||
进程标识符
|
||||
从本章开始,大家可以发现我们的run_all_app函数有所改变:
|
||||
|
||||
.. code-block:: c
|
||||
:linenos:
|
||||
|
||||
// kernel/loader.c
|
||||
int run_all_app() {
|
||||
struct proc *p = allocproc();
|
||||
p->parent = 0;
|
||||
int id = get_id_by_name("user_shell");
|
||||
if(id < 0)
|
||||
panic("no user shell");
|
||||
loader(id, p);
|
||||
p->state = RUNNABLE;
|
||||
return 0;
|
||||
}
|
||||
|
||||
(练习修改)我们这里只加载user_shell的bin程序。如果大家打开user_shell的测例源码,可以发现它是通过spwan系统调用来执行其他测例的。
|
||||
|
||||
|
||||
usershell
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
同一时间存在的所有进程都有一个自己的进程标识符,它们是互不相同的整数。这里我们使用 RAII 的思想,将其抽象为一个 ``PidHandle`` 类型,当它的生命周期结束后对应的整数会被编译器自动回收:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/pid.rs
|
||||
|
||||
pub struct PidHandle(pub usize);
|
||||
|
||||
类似之前的物理页帧分配器 ``FrameAllocator`` ,我们实现一个同样使用简单栈式分配策略的进程标识符分配器 ``PidAllocator`` ,并将其全局实例化为 ``PID_ALLOCATOR`` :
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/pid.rs
|
||||
|
||||
struct PidAllocator {
|
||||
current: usize,
|
||||
recycled: Vec<usize>,
|
||||
}
|
||||
|
||||
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 : Mutex<PidAllocator> = Mutex::new(PidAllocator::new());
|
||||
}
|
||||
|
||||
``PidAllocator::alloc`` 将会分配出去一个将 ``usize`` 包装之后的 ``PidHandle`` 。我们将其包装为一个全局分配进程标识符的接口 ``pid_alloc`` 提供给内核的其他子模块:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/pid.rs
|
||||
|
||||
pub fn pid_alloc() -> PidHandle {
|
||||
PID_ALLOCATOR.lock().alloc()
|
||||
}
|
||||
|
||||
同时我们也需要为 ``PidHandle`` 实现 ``Drop`` Trait 来允许编译器进行自动的资源回收:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/pid.rs
|
||||
|
||||
impl Drop for PidHandle {
|
||||
fn drop(&mut self) {
|
||||
PID_ALLOCATOR.lock().dealloc(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
内核栈
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
在前面的章节中我们介绍过 :ref:`内核地址空间布局 <kernel-as-high>` ,当时我们将每个应用的内核栈按照应用编号从小到大的顺序将它们作为逻辑段从高地址到低地址放在内核地址空间中,且两两之间保留一个守护页面使得我们能够尽可能早的发现内核栈溢出问题。从本章开始,我们将应用编号替换为进程标识符来决定每个进程内核栈在地址空间中的位置。
|
||||
|
||||
因此,在内核栈 ``KernelStack`` 中保存着它所属进程的 PID :
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/pid.rs
|
||||
|
||||
pub struct KernelStack {
|
||||
pid: usize,
|
||||
}
|
||||
|
||||
它提供以下方法:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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
|
||||
.lock()
|
||||
.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<T>(&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::<T>()) 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`` 生命周期结束后,这些物理页帧也将会被编译器自动回收:
|
||||
|
||||
.. code-block:: 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
|
||||
.lock()
|
||||
.remove_area_with_start_vpn(kernel_stack_bottom_va.into());
|
||||
}
|
||||
}
|
||||
|
||||
这仅需要为 ``KernelStack`` 实现 ``Drop`` Trait,一旦它的生命周期结束则在内核地址空间中将对应的逻辑段删除(为此在 ``MemorySet`` 中新增了一个名为 ``remove_area_with_start_vpn`` 的方法,感兴趣的读者可以参考其实现),由前面章节的介绍我们知道这也就意味着那些物理页帧被同时回收掉了。
|
||||
|
||||
进程控制块
|
||||
------------------------------------------------------------------------
|
||||
|
||||
在内核中,每个进程的执行状态、资源控制等元数据均保存在一个被称为 **进程控制块** (PCB, Process Control Block) 的结构中,它是内核对进程进行管理的单位,故而是一种极其关键的内核数据结构。在内核看来,它就等价于一个进程。
|
||||
|
||||
承接前面的章节,我们仅需对任务控制块 ``TaskControlBlock`` 进行若干改动并让它直接承担进程控制块的功能:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// os/src/task/task.rs
|
||||
|
||||
pub struct TaskControlBlock {
|
||||
// immutable
|
||||
pub pid: PidHandle,
|
||||
pub kernel_stack: KernelStack,
|
||||
// mutable
|
||||
inner: Mutex<TaskControlBlockInner>,
|
||||
}
|
||||
|
||||
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<Weak<TaskControlBlock>>,
|
||||
pub children: Vec<Arc<TaskControlBlock>>,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
任务控制块中包含两部分:
|
||||
|
||||
- 在初始化之后就不再变化的作为一个字段直接放在任务控制块中。这里将进程标识符 ``PidHandle`` 和内核栈 ``KernelStack`` 放在其中;
|
||||
- 在运行过程中可能发生变化的则放在 ``TaskControlBlockInner`` 中,将它再包裹上一层互斥锁 ``Mutex<T>`` 放在任务控制块中。这是因为在我们的设计中外层只能获取任务控制块的不可变引用,若想修改里面的部分内容的话这需要 ``Mutex<T>`` 所提供的内部可变性。另外,当后续真正可能有多核同时修改同一个任务控制块中的内容时, ``Mutex<T>`` 可以提供互斥从而避免数据竞争。
|
||||
|
||||
``TaskControlBlockInner`` 中则包含下面这些内容:
|
||||
|
||||
- ``trap_cx_ppn`` 指出了应用地址空间中的 Trap 上下文(详见第四章)被放在的物理页帧的物理页号。
|
||||
- ``base_size`` 的含义是:应用数据仅有可能出现在应用地址空间低于 ``base_size`` 字节的区域中。借助它我们可以清楚的知道应用有多少数据驻留在内存中。
|
||||
- ``task_cx_ptr`` 指出一个暂停的任务的任务上下文在内核地址空间(更确切的说是在自身内核栈)中的位置,用于任务切换。
|
||||
- ``task_status`` 维护当前进程的执行状态。
|
||||
- ``memory_set`` 表示应用地址空间。
|
||||
- ``parent`` 指向当前进程的父进程(如果存在的话)。注意我们使用 ``Weak`` 而非 ``Arc`` 来包裹另一个任务控制块,因此这个智能指针将不会影响父进程的引用计数。
|
||||
- ``children`` 则将当前进程的所有子进程的任务控制块以 ``Arc`` 智能指针的形式保存在一个向量中,这样才能够更方便的找到它们。
|
||||
- 当进程调用 exit 系统调用主动退出或者执行出错由内核终止的时候,它的退出码 ``exit_code`` 会被内核保存在它的任务控制块中,并等待它的父进程通过 waitpid 回收它的资源的同时也收集它的 PID 以及退出码。
|
||||
|
||||
注意我们在维护父子进程关系的时候大量用到了引用计数 ``Arc/Weak`` 。子进程的进程控制块并不会被直接放到父进程控制块下面,因为子进程完全有可能在父进程退出后仍然存在。因此进程控制块的本体是被放到内核堆上面的,对于它的一切访问都是通过智能指针 ``Arc/Weak`` 来进行的。当且仅当它的引用计数变为 0 的时候,进程控制块以及被绑定到它上面的各类资源才会被回收。
|
||||
|
||||
``TaskControlBlockInner`` 提供的方法主要是对于它内部的字段的快捷访问:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/task.rs
|
||||
|
||||
impl TaskControlBlockInner {
|
||||
pub fn get_task_cx_ptr2(&self) -> *const usize {
|
||||
&self.task_cx_ptr as *const usize
|
||||
}
|
||||
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`` 目前提供以下方法:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/task.rs
|
||||
|
||||
impl TaskControlBlock {
|
||||
pub fn acquire_inner_lock(&self) -> MutexGuard<TaskControlBlockInner> {
|
||||
self.inner.lock()
|
||||
}
|
||||
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<TaskControlBlock>) -> Arc<TaskControlBlock> {...}
|
||||
}
|
||||
|
||||
- ``acquire_inner_lock`` 尝试获取互斥锁来得到一个 ``MutexGuard`` ,它可以被看成一个内层 ``TaskControlBlockInner`` 的可变引用并可以对它指向的内容进行修改。之所以要包装为一个方法而不是直接通过 ``self.inner.lock`` 是由于这样接口的定义更加清晰明确。
|
||||
- ``getpid`` 以 ``usize`` 的形式返回当前进程的进程标识符。
|
||||
- ``new`` 用来创建一个新的进程,目前仅用于内核中手动创建唯一一个初始进程 ``initproc`` 。
|
||||
- ``exec`` 用来实现 ``exec`` 系统调用,即当前进程加载并执行另一个 ELF 格式可执行文件。
|
||||
- ``fork`` 用来实现 ``fork`` 系统调用,即当前进程 fork 出来一个与之几乎相同的子进程。
|
||||
|
||||
``new/exec/fork`` 的实现我们将在下一小节再介绍。
|
||||
|
||||
任务管理器
|
||||
------------------------------------------------------------------------
|
||||
|
||||
在前面的章节中,任务管理器 ``TaskManager`` 不仅负责管理所有的任务,还维护着 CPU 当前在执行哪个任务。由于这种设计不够灵活,不能拓展到后续的多核环境,我们需要将任务管理器对于 CPU 的监控职能拆分到下面即将介绍的处理器管理结构 ``Processor`` 中去,任务管理器自身仅负责管理所有任务。在这里,任务指的就是进程。
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// os/src/task/manager.rs
|
||||
|
||||
pub struct TaskManager {
|
||||
ready_queue: VecDeque<Arc<TaskControlBlock>>,
|
||||
}
|
||||
|
||||
/// A simple FIFO scheduler.
|
||||
impl TaskManager {
|
||||
pub fn new() -> Self {
|
||||
Self { ready_queue: VecDeque::new(), }
|
||||
}
|
||||
pub fn add(&mut self, task: Arc<TaskControlBlock>) {
|
||||
self.ready_queue.push_back(task);
|
||||
}
|
||||
pub fn fetch(&mut self) -> Option<Arc<TaskControlBlock>> {
|
||||
self.ready_queue.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TASK_MANAGER: Mutex<TaskManager> = Mutex::new(TaskManager::new());
|
||||
}
|
||||
|
||||
pub fn add_task(task: Arc<TaskControlBlock>) {
|
||||
TASK_MANAGER.lock().add(task);
|
||||
}
|
||||
|
||||
pub fn fetch_task() -> Option<Arc<TaskControlBlock>> {
|
||||
TASK_MANAGER.lock().fetch()
|
||||
}
|
||||
|
||||
``TaskManager`` 将所有的任务控制块用引用计数 ``Arc`` 智能指针包裹后放在一个双端队列 ``VecDeque`` 中。正如之前介绍的那样,我们并不直接将任务控制块放到 ``TaskManager`` 里面,而是将它们放在内核堆上,在任务管理器中仅存放他们的引用计数智能指针,这也是任务管理器的操作单位。这样做的原因在于,任务控制块经常需要被放入/取出,如果直接移动任务控制块自身将会带来大量的数据拷贝开销,而对于智能指针进行移动则没有多少开销。其次,允许任务控制块的共享引用在某些情况下能够让我们的实现更加方便。
|
||||
|
||||
``TaskManager`` 提供 ``add/fetch`` 两个操作,前者表示将一个任务加入队尾,后者则表示从队头中取出一个任务来执行。从调度算法来看,这里用到的就是最简单的 RR 算法。全局实例 ``TASK_MANAGER`` 则提供给内核的其他子模块 ``add_task/fetch_task`` 两个函数。
|
||||
|
||||
在我们的设计中,即使在多核情况下,我们也只有单个任务管理器共享给所有的核来使用。然而在其他设计中,每个核可能都有一个自己独立的任务管理器来管理仅可以在自己上面运行的任务。
|
||||
|
||||
处理器管理结构
|
||||
------------------------------------------------------------------------
|
||||
|
||||
处理器管理结构 ``Processor`` 负责从任务管理器 ``TaskManager`` 分离出去的那部分维护 CPU 状态的职责:
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/processor.rs
|
||||
|
||||
pub struct Processor {
|
||||
inner: RefCell<ProcessorInner>,
|
||||
}
|
||||
|
||||
unsafe impl Sync for Processor {}
|
||||
|
||||
struct ProcessorInner {
|
||||
current: Option<Arc<TaskControlBlock>>,
|
||||
idle_task_cx_ptr: usize,
|
||||
}
|
||||
|
||||
impl Processor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: RefCell::new(ProcessorInner {
|
||||
current: None,
|
||||
idle_task_cx_ptr: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
在 ``Processor`` 中仅有一个被 ``RefCell<T>`` 包裹起来的 ``ProcessorInner`` 结构体,存放所有在运行过程中可能变化的内容,目前包括:
|
||||
|
||||
- ``current`` 表示在当前处理器上正在执行的任务;
|
||||
- ``idle_task_cx_ptr`` 表示当前处理器上的 idle 执行流的任务上下文的地址。
|
||||
|
||||
``Processor`` 是一种 per-CPU 的数据结构,即每个核都有一份专属的 ``Processor`` 结构体,只有这个核自己会访问它,它很容易被拓展到多核环境下使用。因此无论是单核还是多核环境,在访问 ``Processor`` 的时候都不会带来任何隐含的数据竞争风险,这样我们就可以将 ``Processor`` 标记为 ``Sync`` 并全局实例化。但是由于在运行时我们还需要对里面的内容进行修改,故而我们使用一个 ``RefCell<T>`` 将可能被修改的内容包裹起来以提供内部可变性。
|
||||
|
||||
在单核环境下,我们仅创建单个 ``Processor`` 的全局实例 ``PROCESSOR`` :
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/processor.rs
|
||||
|
||||
lazy_static! {
|
||||
pub static ref PROCESSOR: Processor = Processor::new();
|
||||
}
|
||||
|
||||
正在执行的任务
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
在抢占式调度模型中,在一个处理器上执行的任务常常被换入或换出,因此我们需要维护在一个处理器上正在执行的任务,可以查看它的信息或是对它进行替换:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// os/src/task/processor.rs
|
||||
|
||||
impl Processor {
|
||||
pub fn take_current(&self) -> Option<Arc<TaskControlBlock>> {
|
||||
self.inner.borrow_mut().current.take()
|
||||
}
|
||||
pub fn current(&self) -> Option<Arc<TaskControlBlock>> {
|
||||
self.inner.borrow().current.as_ref().map(|task| Arc::clone(task))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_current_task() -> Option<Arc<TaskControlBlock>> {
|
||||
PROCESSOR.take_current()
|
||||
}
|
||||
|
||||
pub fn current_task() -> Option<Arc<TaskControlBlock>> {
|
||||
PROCESSOR.current()
|
||||
}
|
||||
|
||||
pub fn current_user_token() -> usize {
|
||||
let task = current_task().unwrap();
|
||||
let token = task.acquire_inner_lock().get_user_token();
|
||||
token
|
||||
}
|
||||
|
||||
pub fn current_trap_cx() -> &'static mut TrapContext {
|
||||
current_task().unwrap().acquire_inner_lock().get_trap_cx()
|
||||
}
|
||||
|
||||
- 第 4 行的 ``Processor::take_current`` 可以取出当前正在执行的任务。注意首先需要通过 ``inner.borrow_mut`` 来获得里层 ``ProcessorInner`` 的可变引用,而后通过 ``Option::take`` 来将正在执行的任务取出并返回,这意味着 ``ProcessorInner`` 里面的 ``current`` 字段也变为 ``None`` 。
|
||||
- 第 7 行的 ``Processor::current`` 返回当前执行的任务的一份拷贝,这并不会影响到 ``ProcessorInner`` 里面的 ``current`` 字段,因此只需通过 ``borrow`` 来获取 ``ProcessorInner`` 的不可变引用。
|
||||
- 第 12 行的 ``take_current_task`` 以及第 16 行的 ``current_task`` 是对 ``Processor::take_current/current`` 进行封装并提供给内核其他子模块的接口。
|
||||
- 第 20 行的 ``current_user_token`` 和第 26 行的 ``current_trap_cx`` 基于 ``current_task`` 实现,可以提供当前正在执行的任务的更多信息。
|
||||
|
||||
|
||||
任务调度的 idle 执行流
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
每个 ``Processor`` 都有一个不同的 idle 执行流,它们运行在每个核各自的启动栈上,功能是尝试从任务管理器中选出一个任务来在当前核上执行。在内核初始化完毕之后,每个核都会通过调用 ``run_tasks`` 函数来进入 idle 执行流:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// os/src/task/processor.rs
|
||||
|
||||
impl Processor {
|
||||
fn get_idle_task_cx_ptr2(&self) -> *const usize {
|
||||
let inner = self.inner.borrow();
|
||||
&inner.idle_task_cx_ptr as *const usize
|
||||
}
|
||||
pub fn run(&self) {
|
||||
loop {
|
||||
if let Some(task) = fetch_task() {
|
||||
let idle_task_cx_ptr2 = self.get_idle_task_cx_ptr2();
|
||||
// acquire
|
||||
let mut task_inner = task.acquire_inner_lock();
|
||||
let next_task_cx_ptr2 = task_inner.get_task_cx_ptr2();
|
||||
task_inner.task_status = TaskStatus::Running;
|
||||
drop(task_inner);
|
||||
// release
|
||||
self.inner.borrow_mut().current = Some(task);
|
||||
unsafe {
|
||||
__switch(
|
||||
idle_task_cx_ptr2,
|
||||
next_task_cx_ptr2,
|
||||
);
|
||||
我们也提供了一个十分简单的支持交互的usershell测例。
|
||||
|
||||
.. code-block:: c
|
||||
|
||||
// 手搓了一个极简的 stack,用来维护用户输入,保存一行的输入
|
||||
char line[100] = {};
|
||||
int top = 0;
|
||||
void push(char c) { line[top++] = c; }
|
||||
void pop() { --top; }
|
||||
int is_empty() { return top == 0; }
|
||||
void clear() { top = 0; }
|
||||
|
||||
int main() {
|
||||
printf("C user shell\n");
|
||||
printf(">> ");
|
||||
// shell 是不会结束的
|
||||
while (1) {
|
||||
// 读取一个字符
|
||||
char c = getchar();
|
||||
switch (c) {
|
||||
// 敲了回车,将输入内容解析位一个程序名,通过 fork + exec 执行
|
||||
case LF:
|
||||
case CR:
|
||||
printf("\n");
|
||||
if (!is_empty()) {
|
||||
push('\0');
|
||||
int pid = fork();
|
||||
if (pid == 0) {
|
||||
// child process
|
||||
if (exec(line) < 0) {
|
||||
printf("no such program\n");
|
||||
exit(0);
|
||||
}
|
||||
panic("unreachable!");
|
||||
} else {
|
||||
// 父进程 wait 执行的函数
|
||||
int xstate = 0;
|
||||
int exit_pid = 0;
|
||||
exit_pid = wait(pid, &xstate);
|
||||
assert(pid == exit_pid, -1);
|
||||
printf("Shell: Process %d exited with code %d\n", pid, xstate);
|
||||
}
|
||||
// 无论如何,清空输入 buffer
|
||||
clear();
|
||||
}
|
||||
}
|
||||
printf(">> ");
|
||||
break;
|
||||
case BS:
|
||||
case DL:
|
||||
// 退格键
|
||||
if (!is_empty()) {
|
||||
putchar(BS);
|
||||
printf(" ");
|
||||
putchar(BS);
|
||||
pop();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 普通输入,回显
|
||||
putchar(c);
|
||||
push(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
pub fn run_tasks() {
|
||||
PROCESSOR.run();
|
||||
}
|
||||
可以看到这个测例实际上就是实现了一个简单的字符串处理的函数,并且针对解析得到的不同的指令调用不同的系统调用。要注意这需要shell支持read的系统调用。当读入用户的输入时,它会死循环的等待用户输入一个代表程序名称的字符串(通过sys_read),当用户按下空格之后,shell 会使用 fork 和 exec 创建并执行这个程序,然后通过 sys_wait 来等待程序执行结束,并输出 exit_code。有了 shell 之后,我们可以只执行自己希望的程序,也可以执行某一个程序很多次来观察输出,这对于使用体验是极大的提升!可以说,第五章的所有努力都是为了支持 shell。
|
||||
|
||||
可以看到,调度功能的主体在第 8 行的 ``Processor::run`` 中实现。它循环调用 ``fetch_task`` 直到顺利从任务管理器中取出一个任务,随后便准备通过任务切换的方式来执行:
|
||||
我们简单看一下sys_read的实现:
|
||||
|
||||
- 第 11 行得到 ``__switch`` 的第一个参数,也就是当前 idle 执行流的 task_cx_ptr2,这调用了第 4 行的 ``get_idle_task_cx_ptr2`` 方法。
|
||||
- 第 13~16 行需要先获取从任务管理器中取出的任务的互斥锁再对对应的任务控制块进行操作,因为在多核环境下有可能会产生并发冲突。在里面我们获取任务的 task_cx_ptr2 作为 ``__switch`` 的第二个参数并修改任务的状态。第 16 行我们需要手动释放互斥锁,这样才能划分出更加精确的临界区。如果依赖编译器在循环的末尾自动释放的话,相当于扩大了临界区,有可能会导致死锁。
|
||||
- 第 18 行我们修改当前 ``Processor`` 正在执行的任务为我们取出的任务。注意这里相当于 ``Arc<TaskControlBlock>`` 形式的任务从任务管理器流动到了处理器管理结构中。也就是说,在稳定的情况下,每个尚未结束的进程的任务控制块都只能被引用一次,要么在任务管理器中,要么则是在某个处理器的 ``Processor`` 中。
|
||||
- 第 20 行我们调用 ``__switch`` 来从当前的 idle 执行流切换到接下来要执行的任务。
|
||||
.. code-block:: c
|
||||
|
||||
上面介绍了从 idle 执行流通过任务调度切换到某个任务开始执行的过程。而反过来,当一个应用用尽了内核本轮分配给它的时间片或者它主动调用 ``yield`` 系统调用交出 CPU 使用权之后,进入内核后它会调用 ``schedule`` 函数来切换到 idle 执行流并开启新一轮的任务调度。
|
||||
|
||||
.. code-block:: rust
|
||||
|
||||
// os/src/task/processor.rs
|
||||
|
||||
pub fn schedule(switched_task_cx_ptr2: *const usize) {
|
||||
let idle_task_cx_ptr2 = PROCESSOR.get_idle_task_cx_ptr2();
|
||||
unsafe {
|
||||
__switch(
|
||||
switched_task_cx_ptr2,
|
||||
idle_task_cx_ptr2,
|
||||
);
|
||||
uint64 sys_read(int fd, uint64 va, uint64 len) {
|
||||
if (fd != 0)
|
||||
return -1;
|
||||
struct proc *p = curr_proc();
|
||||
char str[200];
|
||||
for(int i = 0; i < len; ++i) {
|
||||
int c = console_getchar();
|
||||
str[i] = c;
|
||||
}
|
||||
copyout(p->pagetable, va, str, len);
|
||||
return len;
|
||||
}
|
||||
|
||||
这里,我们需要传入即将被切换出去的任务的 task_cx_ptr2 来在合适的位置保存任务上下文,之后就可以通过 ``__switch`` 来切换到 idle 执行流。切换回去之后,从源代码级来看,我们将跳转到 ``Processor::run`` 中 ``__switch`` 返回之后的位置,也即开启了下一轮循环。
|
||||
目前我们只支持标准输入stdin的输入(对应fd = 0)。console_getchar和putchar一样,在sbi.c之中实现了其系统调用的过程。
|
|
@ -1,647 +0,0 @@
|
|||
进程管理机制的设计实现
|
||||
============================================
|
||||
|
||||
本节导读
|
||||
--------------------------------------------
|
||||
|
||||
本节将从如下四个方面介绍如何基于上一节设计的内核数据结构来实现进程管理:
|
||||
|
||||
- 初始进程 ``initproc`` 的创建;
|
||||
- 进程调度机制:当进程主动调用 ``sys_yield`` 交出 CPU 使用权或者内核本轮分配的时间片用尽之后如何切换到下一个进程;
|
||||
- 进程生成机制:介绍进程相关的两个重要系统调用 ``sys_fork/sys_exec`` 的实现;
|
||||
- 字符输入机制:为了支对shell程序-user_shell获得字符输入,介绍 ``sys_read`` 系统调用的实现;
|
||||
- 进程资源回收机制:当进程调用 ``sys_exit`` 正常退出或者出错被内核终止之后如何保存其退出码,其父进程又是如何通过 ``sys_waitpid`` 系统调用收集该进程的信息并回收其资源。
|
||||
|
||||
初始进程的创建
|
||||
--------------------------------------------
|
||||
|
||||
内核初始化完毕之后即会调用 ``task`` 子模块提供的 ``add_initproc`` 函数来将初始进程 ``initproc`` 加入任务管理器,但在这之前我们需要初始化初始进程的进程控制块 ``INITPROC`` ,这个过程基于 ``lazy_static`` 在运行时完成。
|
||||
|
||||
.. code-block:: 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<TaskControlBlock> = 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`` 是如何实现的:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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_cx_ptr = kernel_stack.push_on_top(TaskContext::goto_trap_return());
|
||||
let task_control_block = Self {
|
||||
pid: pid_handle,
|
||||
kernel_stack,
|
||||
inner: Mutex::new(TaskControlBlockInner {
|
||||
trap_cx_ppn,
|
||||
base_size: user_sp,
|
||||
task_cx_ptr: task_cx_ptr as usize,
|
||||
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.acquire_inner_lock().get_trap_cx();
|
||||
*trap_cx = TrapContext::app_init_context(
|
||||
entry_point,
|
||||
user_sp,
|
||||
KERNEL_SPACE.lock().token(),
|
||||
kernel_stack_top,
|
||||
trap_handler as usize,
|
||||
);
|
||||
task_control_block
|
||||
}
|
||||
|
||||
- 第 10 行我们解析 ELF 得到应用地址空间 ``memory_set`` ,用户栈在应用地址空间中的位置 ``user_sp`` 以及应用的入口点 ``entry_point`` 。
|
||||
- 第 11 行我们手动查页表找到应用地址空间中的 Trap 上下文被实际放在哪个物理页帧上,用来做后续的初始化。
|
||||
- 第 16~18 行我们为该进程分配 PID 以及内核栈,并记录下内核栈在内核地址空间的位置 ``kernel_stack_top`` 。
|
||||
- 第 20 行我们在该进程的内核栈上压入初始化的任务上下文,使得第一次任务切换到它的时候可以跳转到 ``trap_return`` 并进入用户态开始执行。
|
||||
- 第 21 行我们整合之前的部分信息创建进程控制块 ``task_control_block`` 。
|
||||
- 第 39 行我们初始化位于该进程应用地址空间中的 Trap 上下文,使得第一次进入用户态的时候时候能正确跳转到应用入口点并设置好用户栈,同时也保证在 Trap 的时候用户态能正确进入内核态。
|
||||
- 第 46 行将 ``task_control_block`` 返回。
|
||||
|
||||
进程调度机制
|
||||
--------------------------------------------
|
||||
|
||||
通过调用 ``task`` 子模块提供的 ``suspend_current_and_run_next`` 函数可以暂停当前任务并切换到下一个任务,当应用调用 ``sys_yield`` 主动交出使用权、本轮时间片用尽或者由于某些原因内核中的处理无法继续的时候,就会在内核中调用此函数触发调度机制并进行任务切换。下面给出了两种典型的使用情况:
|
||||
|
||||
.. code-block:: rust
|
||||
:emphasize-lines: 4,18
|
||||
|
||||
// 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`` 的实现也需要发生变化:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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();
|
||||
|
||||
// ---- hold current PCB lock
|
||||
let mut task_inner = task.acquire_inner_lock();
|
||||
let task_cx_ptr2 = task_inner.get_task_cx_ptr2();
|
||||
// Change status to Ready
|
||||
task_inner.task_status = TaskStatus::Ready;
|
||||
drop(task_inner);
|
||||
// ---- release current PCB lock
|
||||
|
||||
// push back to ready queue.
|
||||
add_task(task);
|
||||
// jump to scheduling cycle
|
||||
schedule(task_cx_ptr2);
|
||||
}
|
||||
|
||||
首先通过 ``take_current_task`` 来取出当前正在执行的任务,修改其进程控制块内的状态,随后将这个任务放入任务管理器的队尾。接着调用 ``schedule`` 函数来触发调度并切换任务。注意,当仅有一个任务的时候, ``suspend_current_and_run_next`` 的效果是会继续执行这个任务。
|
||||
|
||||
进程的生成机制
|
||||
--------------------------------------------
|
||||
|
||||
在内核中手动生成的进程只有初始进程 ``initproc`` ,余下所有的进程都是它直接或间接 fork 出来的。当一个子进程被 fork 出来之后,它可以调用 ``exec`` 系统调用来加载并执行另一个可执行文件。因此, ``fork/exec`` 两个系统调用提供了进程的生成机制。下面我们分别来介绍二者的实现。
|
||||
|
||||
fork 系统调用的实现
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
在实现 fork 的时候,最为关键且困难的是为子进程创建一个和父进程几乎完全相同的应用地址空间。我们的实现如下:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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`` 来从父进程的进程控制块创建一份子进程的控制块:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// os/src/task/task.rs
|
||||
|
||||
impl TaskControlBlock {
|
||||
pub fn fork(self: &Arc<TaskControlBlock>) -> Arc<TaskControlBlock> {
|
||||
// ---- hold parent PCB lock
|
||||
let mut parent_inner = self.acquire_inner_lock();
|
||||
// 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();
|
||||
// push a goto_trap_return task_cx on the top of kernel stack
|
||||
let task_cx_ptr = kernel_stack.push_on_top(TaskContext::goto_trap_return());
|
||||
let task_control_block = Arc::new(TaskControlBlock {
|
||||
pid: pid_handle,
|
||||
kernel_stack,
|
||||
inner: Mutex::new(TaskControlBlockInner {
|
||||
trap_cx_ppn,
|
||||
base_size: parent_inner.base_size,
|
||||
task_cx_ptr: task_cx_ptr as usize,
|
||||
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
|
||||
// **** acquire child PCB lock
|
||||
let trap_cx = task_control_block.acquire_inner_lock().get_trap_cx();
|
||||
// **** release child PCB lock
|
||||
trap_cx.kernel_sp = kernel_stack_top;
|
||||
// return
|
||||
task_control_block
|
||||
// ---- release parent PCB lock
|
||||
}
|
||||
}
|
||||
|
||||
它基本上和新建进程控制块的 ``TaskControlBlock::new`` 是相同的,但要注意以下几点:
|
||||
|
||||
- 子进程的地址空间不是通过解析 ELF 而是通过在第 8 行调用 ``MemorySet::from_existed_user`` 复制父进程地址空间得到的;
|
||||
- 第 26 行,我们让子进程和父进程的 ``base_size`` ,也即应用数据的大小保持一致;
|
||||
- 在 fork 的时候需要注意父子进程关系的维护。第 30 行我们将父进程的弱引用计数放到子进程的进程控制块中,而在第 36 行我们将子进程插入到父进程的孩子向量 ``children`` 中。
|
||||
|
||||
我们在子进程内核栈上压入一个初始化的任务上下文,使得内核一旦通过任务切换到该进程,就会跳转到 ``trap_return`` 来进入用户态。而在复制地址空间的时候,子进程的 Trap 上下文也是完全从父进程复制过来的,这可以保证子进程进入用户态和其父进程回到用户态的那一瞬间 CPU 的状态是完全相同的(后面我们会让它们有一点不同从而区分两个进程)。而两个进程的应用数据由于地址空间复制的原因也是完全相同的,这是 fork 语义要求做到的。
|
||||
|
||||
在具体实现 ``sys_fork`` 的时候,我们需要特别注意如何体现父子进程的差异:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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.acquire_inner_lock().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;
|
||||
// add new task to scheduler
|
||||
add_task(new_task);
|
||||
new_pid as isize
|
||||
}
|
||||
|
||||
在调用 ``syscall`` 进行系统调用分发并具体调用 ``sys_fork`` 之前,我们已经将当前进程 Trap 上下文中的 sepc 向后移动了 4 字节使得它回到用户态之后会从 ecall 的下一条指令开始执行。之后当我们复制地址空间的时候,子进程地址空间 Trap 上下文的 sepc 也是移动之后的值,我们无需再进行修改。
|
||||
|
||||
父子进程回到用户态的瞬间都处于刚刚从一次系统调用返回的状态,但二者的返回值不同。第 8~11 行我们将子进程的 Trap 上下文用来存放系统调用返回值的 a0 寄存器修改为 0 ,而父进程系统调用的返回值会在 ``trap_handler`` 中 ``syscall`` 返回之后再设置为 ``sys_fork`` 的返回值,这里我们返回子进程的 PID 。这就做到了父进程 ``fork`` 的返回值为子进程的 PID ,而子进程的返回值则为 0 。通过返回值是否为 0 可以区分父子进程。
|
||||
|
||||
另外,不要忘记在第 13 行,我们将生成的子进程通过 ``add_task`` 加入到任务管理器中。
|
||||
|
||||
exec 系统调用的实现
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``exec`` 系统调用使得一个进程能够加载一个新的 ELF 可执行文件替换原有的应用地址空间并开始执行。我们先从进程控制块的层面进行修改:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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();
|
||||
|
||||
// **** hold current PCB lock
|
||||
let mut inner = self.acquire_inner_lock();
|
||||
// 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.lock().token(),
|
||||
self.kernel_stack.get_top(),
|
||||
trap_handler as usize,
|
||||
);
|
||||
// **** release current PCB lock
|
||||
}
|
||||
}
|
||||
|
||||
它在解析传入的 ELF 格式数据之后只做了两件事情:
|
||||
|
||||
- 首先是从 ELF 生成一个全新的地址空间并直接替换进来(第 15 行),这将导致原有的地址空间生命周期结束,里面包含的全部物理页帧都会被回收;
|
||||
- 然后是修改新的地址空间中的 Trap 上下文,将解析得到的应用入口点、用户栈位置以及一些内核的信息进行初始化,这样才能正常实现 Trap 机制。
|
||||
|
||||
这里无需对任务上下文进行处理,因为这个进程本身已经在执行了,而只有被暂停的应用才需要在内核栈上保留一个任务上下文。
|
||||
|
||||
借助它 ``sys_exec`` 就很容易实现了:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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`` 为止。
|
||||
|
||||
回到 ``sys_exec`` 的实现,它调用 ``translated_str`` 找到要执行的应用名并试图在应用加载器提供的 ``get_app_data_by_name`` 接口中找到对应的 ELF 数据。如果找到的话就调用 ``TaskControlBlock::exec`` 替换掉地址空间并返回 0。这个返回值其实并没有意义,因为我们在替换地址空间的时候本来就对 Trap 上下文重新进行了初始化。如果没有找到的话就不做任何事情并返回 -1,在shell程序-user_shell中我们也正是通过这个返回值来判断要执行的应用是否存在。
|
||||
|
||||
系统调用后重新获取 Trap 上下文
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
原来在 ``trap_handler`` 中我们是这样处理系统调用的:
|
||||
|
||||
.. code-block:: 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`` ,目前的实现如下:
|
||||
|
||||
.. code-block:: 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`` 系统调用使得应用能够取得用户的键盘输入。
|
||||
|
||||
.. code-block:: 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`` 函数退出当前任务并切换到下一个。使用方法如下:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
:emphasize-lines: 4,29,34
|
||||
|
||||
// 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`` 写入当前进程的进程控制块中:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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();
|
||||
// **** hold current PCB lock
|
||||
let mut inner = task.acquire_inner_lock();
|
||||
// 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
|
||||
|
||||
// ++++++ hold initproc PCB lock here
|
||||
{
|
||||
let mut initproc_inner = INITPROC.acquire_inner_lock();
|
||||
for child in inner.children.iter() {
|
||||
child.acquire_inner_lock().parent = Some(Arc::downgrade(&INITPROC));
|
||||
initproc_inner.children.push(child.clone());
|
||||
}
|
||||
}
|
||||
// ++++++ release parent PCB lock here
|
||||
|
||||
inner.children.clear();
|
||||
// deallocate user space
|
||||
inner.memory_set.recycle_data_pages();
|
||||
drop(inner);
|
||||
// **** release current PCB lock
|
||||
// drop task manually to maintain rc correctly
|
||||
drop(task);
|
||||
// we do not have to save task context
|
||||
let _unused: usize = 0;
|
||||
schedule(&_unused as *const _);
|
||||
}
|
||||
|
||||
- 第 13 行我们调用 ``take_current_task`` 来将当前进程控制块从处理器监控 ``PROCESSOR`` 中取出而不是得到一份拷贝,这是为了正确维护进程控制块的引用计数;
|
||||
- 第 17 行我们将进程控制块中的状态修改为 ``TaskStatus::Zombie`` 即僵尸进程,这样它后续才能被父进程在 ``waitpid`` 系统调用的时候回收;
|
||||
- 第 19 行我们将传入的退出码 ``exit_code`` 写入进程控制块中,后续父进程在 ``waitpid`` 的时候可以收集;
|
||||
- 第 24~26 行所做的事情是将当前进程的所有子进程挂在初始进程 ``initproc`` 下面,其做法是遍历每个子进程,修改其父进程为初始进程,并加入初始进程的孩子向量中。第 32 行将当前进程的孩子向量清空。
|
||||
- 第 34 行对于当前进程占用的资源进行早期回收。在第 4 行可以看出, ``MemorySet::recycle_data_pages`` 只是将地址空间中的逻辑段列表 ``areas`` 清空,这将导致应用地址空间的所有数据被存放在的物理页帧被回收,而用来存放页表的那些物理页帧此时则不会被回收。
|
||||
- 最后在第 41 行我们调用 ``schedule`` 触发调度及任务切换,由于我们再也不会回到该进程的执行过程中,因此无需关心任务上下文的保存。
|
||||
|
||||
父进程回收子进程资源
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
父进程通过 ``sys_waitpid`` 系统调用来回收子进程的资源并收集它的一些信息:
|
||||
|
||||
.. code-block:: rust
|
||||
:linenos:
|
||||
|
||||
// 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
|
||||
|
||||
// ---- hold current PCB lock
|
||||
let mut inner = task.acquire_inner_lock();
|
||||
if inner.children
|
||||
.iter()
|
||||
.find(|p| {pid == -1 || pid as usize == p.getpid()})
|
||||
.is_none() {
|
||||
return -1;
|
||||
// ---- release current PCB lock
|
||||
}
|
||||
let pair = inner.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, p)| {
|
||||
// ++++ temporarily hold child PCB lock
|
||||
p.acquire_inner_lock().is_zombie() &&
|
||||
(pid == -1 || pid as usize == p.getpid())
|
||||
// ++++ release child PCB lock
|
||||
});
|
||||
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 hold child lock
|
||||
let exit_code = child.acquire_inner_lock().exit_code;
|
||||
// ++++ release child PCB lock
|
||||
*translated_refmut(inner.memory_set.token(), exit_code_ptr) = exit_code;
|
||||
found_pid as isize
|
||||
} else {
|
||||
-2
|
||||
}
|
||||
// ---- release current PCB lock automatically
|
||||
}
|
||||
|
||||
``sys_waitpid`` 是一个立即返回的系统调用,它的返回值语义是:如果当前的进程不存在一个符合要求的子进程,则返回 -1;如果至少存在一个,但是其中没有僵尸进程(也即仍未退出)则返回 -2;如果都不是的话则可以正常回收并返回回收子进程的 pid 。但在编写应用的开发者看来, ``wait/waitpid`` 两个辅助函数都必定能够返回一个有意义的结果,要么是 -1,要么是一个正数 PID ,是不存在 -2 这种通过等待即可消除的中间结果的。这等待的过程正是在用户库 ``user_lib`` 中完成。
|
||||
|
||||
第 11~17 行判断 ``sys_waitpid`` 是否会返回 -1 ,这取决于当前进程是否有一个符合要求的子进程。当传入的 ``pid`` 为 -1 的时候,任何一个子进程都算是符合要求;但 ``pid`` 不为 -1 的时候,则只有 PID 恰好与 ``pid`` 相同的子进程才算符合条件。我们简单通过迭代器即可完成判断。
|
||||
|
||||
第 18~26 行判断符合要求的子进程中是否有僵尸进程,如果有的话还需要同时找出它在当前进程控制块子进程向量中的下标。如果找不到的话直接返回 ``-2`` ,否则进入第 28~36 行的处理:
|
||||
|
||||
- 第 28 行我们将子进程从向量中移除并置于当前上下文中,此时可以确认这是对于该子进程控制块的唯一一次强引用,即它不会出现在某个进程的子进程向量中,更不会出现在处理器监控器或者任务管理器中。当它所在的代码块结束,这次引用变量的生命周期结束,将导致该子进程进程控制块的引用计数变为 0 ,彻底回收掉它占用的所有资源,包括:内核栈和它的 PID 还有它的应用地址空间存放页表的那些物理页帧等等。
|
||||
- 剩下主要是将收集的子进程信息返回回去。第 31 行得到了子进程的 PID 并会在最终返回;第 33 行得到了子进程的退出码并于第 35 行写入到当前进程的应用地址空间中。由于应用传递给内核的仅仅是一个指向应用地址空间中保存子进程返回值的内存区域的指针,我们还需要在 ``translated_refmut`` 中手动查页表找到应该写入到物理内存中的哪个位置。其实现可以在 ``os/src/mm/page_table.rs`` 中找到,比较简单,在这里不再赘述。
|
|
@ -7,8 +7,5 @@
|
|||
0intro
|
||||
1process
|
||||
2core-data-structures
|
||||
3implement-process-mechanism
|
||||
4exercise
|
||||
3exercise
|
||||
|
||||
MULTICS操作系统是侏罗纪的“霸王龙”操作系统。
|
||||
UNIX操作系统是小巧聪明的“伤齿龙”操作系统。
|
Loading…
Reference in New Issue