add os[1-8]-ref for os refereces, add guide, add README

This commit is contained in:
Yu Chen
2022-06-27 22:22:44 +08:00
parent 7c1679774c
commit d752a67137
360 changed files with 32863 additions and 1 deletions

View File

@@ -0,0 +1,175 @@
引言
===========================================
本章导读
-------------------------------------------
我们将开发一个用户 **终端** (Terminal) 或 **命令行** (Command Line Application, 俗称 **Shell** )
形成用户与操作系统进行交互的命令行界面 (Command Line Interface)。
为此,我们要对任务建立新的抽象: **进程** ,并实现若干基于 **进程** 的强大系统调用。
.. note::
**任务和进程的关系与区别**
第三章提到的 **任务** 是这里提到的 **进程** 的初级阶段,与任务相比,进程能在运行中创建 **子进程**
用新的 **程序** 内容覆盖已有的 **程序** 内容、可管理更多物理或虚拟 **资源**
实践体验
-------------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch5
$ git clone https://github.com/LearningOS/rCore-Tutorial-Test-2022S.git user
请仿照ch4的做法将代码在本地更新并push到自己的实验仓库中。
注意user仓库有对ch5的测例更新请重新clone或者使用git pull等获取。
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
待内核初始化完毕之后将在屏幕上打印可用的应用列表并进入shell程序
.. code-block::
[rustsbi] RustSBI version 0.2.0-alpha.4
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation: RustSBI-QEMU Version 0.0.1
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: ssoft, stimer, sext (0x222)
[rustsbi] medeleg: ima, ia, bkpt, la, sa, uecall, ipage, lpage, spage (0xb1ab)
[rustsbi] pmp0: 0x80000000 ..= 0x800fffff (rwx)
[rustsbi] pmp1: 0x80000000 ..= 0x807fffff (rwx)
[rustsbi] pmp2: 0x0 ..= 0xffffffffffffff (---)
[rustsbi] enter supervisor 0x80200000
[kernel] Hello, world!
/**** APPS ****
ch2b_bad_address
ch2b_bad_instructions
ch2b_bad_register
ch2b_hello_world
ch2b_power_3
ch2b_power_5
ch2b_power_7
ch3b_sleep
ch3b_sleep1
ch3b_yield0
ch3b_yield1
ch3b_yield2
ch5b_exit
ch5b_forktest
ch5b_forktest2
ch5b_forktest_simple
ch5b_forktree
ch5b_initproc
ch5b_user_shell
**************/
Rust user shell
>>
可以通过输入ch5b开头的应用来测试ch5实现的fork等功能:
.. code-block::
>> ch5b_forktest_simple
sys_wait without child process test passed!
parent start, pid = 2!
ready waiting on parent process!
hello child process!
child process pid = 3, exit code = 100
Shell: Process 2 exited with code 0
本章代码树
--------------------------------------
.. code-block::
:linenos:
├── os
   ├── build.rs(修改:基于应用名的应用构建器)
   ├── ...
   └── src
   ├── ...
   ├── loader.rs(修改:基于应用名的应用加载器)
   ├── main.rs(修改)
   ├── mm(修改:为了支持本章的系统调用对此模块做若干增强)
   │   ├── address.rs
   │   ├── frame_allocator.rs
   │   ├── heap_allocator.rs
   │   ├── memory_set.rs
   │   ├── mod.rs
   │   └── page_table.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(修改:支持进程机制的任务控制块)
   └── trap
   ├── context.rs
   ├── mod.rs(修改:对于系统调用的实现进行修改以支持进程系统调用)
   └── trap.S
cloc os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 29 180 138 2049
Assembly 4 20 26 229
make 1 11 4 36
TOML 1 2 1 13
-------------------------------------------------------------------------------
SUM: 35 213 169 2327
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 本章的第一小节 :doc:`/chapter5/1process` 介绍了操作系统中经典的进程概念,并描述我们将要实现的参考自 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`` 可以按照顺序执行绝大部分应用,会在测试的时候为我们提供很多方便。
.. 接下来就需要在内核中实现简化版的进程机制并支持新增的系统调用。在本章第二小节 :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`` 中找到。

View File

@@ -0,0 +1,230 @@
与进程有关的重要系统调用
================================================
重要系统调用
------------------------------------------------------------
fork 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
/// 功能:由当前进程 fork 出一个子进程。
/// 返回值:对于子进程返回 0对于当前进程则返回子进程的 PID 。
/// syscall ID220
pub fn sys_fork() -> isize;
exec 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
/// 功能:将当前进程的地址空间清空并加载一个特定的可执行文件,返回用户态后开始它的执行。
/// 参数:字符串 path 给出了要加载的可执行文件的名字;
/// 返回值:如果出错的话(如找不到名字相符的可执行文件)则返回 -1否则不应该返回。
/// 注意path 必须以 "\0" 结尾,否则内核将无法确定其长度
/// syscall ID221
pub fn sys_exec(path: &str) -> isize;
利用 ``fork````exec`` 的组合,我们能让创建一个子进程,并令其执行特定的可执行文件。
waitpid 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
/// 功能:当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。
/// 参数pid 表示要等待的子进程的进程 ID如果为 -1 的话表示等待任意一个子进程;
/// exit_code 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。
/// 返回值:如果要等待的子进程不存在则返回 -1否则如果要等待的子进程均未结束则返回 -2
/// 否则返回结束的子进程的进程 ID。
/// syscall ID260
pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize;
``sys_waitpid`` 在用户库中被封装成两个不同的 API ``wait(exit_code: &mut i32)````waitpid(pid: usize, exit_code: &mut i32)``
前者用于等待任意一个子进程,后者用于等待特定子进程。它们实现的策略是如果子进程还未结束,就以 yield 让出时间片:
.. 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 => { sys_yield(); }
n => { return n; }
}
}
}
应用程序示例
-----------------------------------------------
借助这三个重要系统调用,我们可以开发功能更强大的应用。下面是两个案例: **用户初始程序-init** 和 **shell程序-user_shell** 。
用户初始程序-initproc
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在内核初始化完毕后创建的第一个进程,是 **用户初始进程** (Initial Process) ,它将通过
``fork+exec`` 创建 ``user_shell`` 子进程,并将被用于回收僵尸进程。
.. code-block:: rust
:linenos:
// user/src/bin/ch5b_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("ch5b_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`` 出的子进程分支,通过 ``exec`` 启动shell程序 ``user_shell``
注意我们需要在字符串末尾手动加入 ``\0``
- 第 21 行开始则为父进程分支,表示用户初始程序-initproc自身。它不断循环调用 ``wait`` 来等待并回收系统中的僵尸进程占据的资源。
如果回收成功的话则会打印一条报告信息给出被回收子进程的 PID 和返回值;否则就 ``yield_`` 交出 CPU 资源并在下次轮到它执行的时候再回收看看。
shell程序-user_shell
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
user_shell 需要捕获用户输入并进行解析处理,为此添加一个能获取用户输入的系统调用:
.. code-block:: rust
/// 功能:从文件中读取一段内容到缓冲区。
/// 参数fd 是待读取文件的文件描述符,切片 buffer 则给出缓冲区。
/// 返回值:如果出现了错误则返回 -1否则返回实际读到的字节数。
/// syscall ID63
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`` 函数。
shell程序 ``user_shell`` 实现如下:
.. code-block:: rust
:linenos:
:emphasize-lines: 28,53,61
// user/src/bin/ch5b_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`` 则维护着用户当前输入的命令内容,它也在不断发生变化。
- 如果用户输入回车键(第 28 行那么user_shell 会 fork 出一个子进程(第 34 行开始)并试图通过
``exec`` 系统调用执行一个应用,应用的名字在字符串 ``line`` 中给出。如果 exec 的返回值为 -1
说明在应用管理器中找不到对应名字的应用,此时子进程就直接打印错误信息并退出;否则子进程将开始执行目标应用。
fork 之后的 user_shell 进程自己的逻辑可以在第 41 行找到。它在等待 fork 出来的子进程结束并回收掉它的资源,还会顺带收集子进程的退出状态并打印出来。
- 如果用户输入退格键(第 53 行),首先我们需要将屏幕上当前行的最后一个字符用空格替换掉,
这可以通过输入一个特殊的退格字节 ``BS`` 来实现。其次user_shell 进程内维护的 ``line`` 也需要弹出最后一个字符。
- 如果用户输入了一个其他字符(第 61 行),就接将它打印在屏幕上,并加入到 ``line`` 中。
- 按键 ``Ctrl+A`` 再输入 ``X`` 来退出qemu模拟器。

View File

@@ -0,0 +1,540 @@
进程管理的核心数据结构
===================================
本节导读
-----------------------------------
为了更好实现进程管理,我们需要设计和调整内核中的一些数据结构,包括:
- 基于应用名的应用链接/加载器
- 进程标识符 ``PidHandle`` 以及内核栈 ``KernelStack``
- 任务控制块 ``TaskControlBlock``
- 任务管理器 ``TaskManager``
- 处理器管理结构 ``Processor``
基于应用名的应用链接/加载器
------------------------------------------------------------------------
在实现 ``exec`` 系统调用的时候,我们需要根据应用的名字而不仅仅是一个编号来获取应用的 ELF 格式数据。
因此,在链接器 ``os/build.rs`` 中,我们按顺序保存链接进来的每个应用的名字:
.. code-block::
:linenos:
:emphasize-lines: 8-13
// 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`` 指出。
而在加载器 ``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
};
}
使用 ``get_app_data_by_name`` 可以按照应用的名字来查找获得应用的 ELF 数据,而 ``list_apps``
在内核初始化时被调用,它可以打印出所有可用应用的名字。
.. 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!("**************/")
}
进程标识符和内核栈
------------------------------------------------------------------------
进程标识符
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
同一时间存在的所有进程都有一个自己的进程标识符,它们是互不相同的整数。这里将其抽象为一个 ``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: UPSafeCell<PidAllocator> =
unsafe { UPSafeCell::new(PidAllocator::new()) };
}
``PidAllocator::alloc`` 将会分配出去一个将 ``usize`` 包装之后的 ``PidHandle``
我们将其包装为一个全局分配进程标识符的接口 ``pid_alloc``
.. code-block:: rust
// os/src/task/pid.rs
pub fn pid_alloc() -> PidHandle {
PID_ALLOCATOR.exclusive_access().alloc()
}
同时我们也需要为 ``PidHandle`` 实现 ``Drop`` Trait 来允许编译器进行自动的资源回收:
.. code-block:: rust
// os/src/task/pid.rs
impl Drop for PidHandle {
fn drop(&mut self) {
//println!("drop pid {}", self.0);
PID_ALLOCATOR.exclusive_access().dealloc(self.0);
}
}
内核栈
~~~~~~~~~~~~~~~~~~~~~~
从本章开始,我们将应用编号替换为进程标识符来决定每个进程内核栈在地址空间中的位置。
在内核栈 ``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.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<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
.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)
的结构中,它是内核对进程进行管理的单位。在内核看来,它就等价于一个进程。
承接前面的章节,我们仅需对任务控制块 ``TaskControlBlock`` 进行若干改动,让它直接承担进程控制块的功能:
.. code-block:: rust
:linenos:
// os/src/task/task.rs
pub struct TaskControlBlock {
// immutable
pub pid: PidHandle,
pub kernel_stack: KernelStack,
// mutable
inner: UPSafeCell<TaskControlBlockInner>,
}
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<Weak<TaskControlBlock>>,
pub children: Vec<Arc<TaskControlBlock>>,
pub exit_code: i32,
}
任务控制块中包含两部分:
- 在初始化之后就不再变化的作为一个字段直接放在任务控制块中。这里将进程标识符 ``PidHandle`` 和内核栈 ``KernelStack`` 放在其中;
- 在运行过程中可能发生变化的则放在 ``TaskControlBlockInner`` 中,将它再包裹上一层 ``UPSafeCell<T>`` 放在任务控制块中。
在此使用 ``UPSafeCell<T>`` 可以提供互斥从而避免数据竞争。
``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`` ,当且仅当它的引用计数变为 0 的时候,进程控制块以及被绑定到它上面的各类资源才会被回收。
``TaskControlBlockInner`` 提供的方法主要是对于它内部字段的快捷访问:
.. code-block:: 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`` 目前提供以下方法:
.. code-block:: 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<TaskControlBlock>) -> Arc<TaskControlBlock> {...}
}
- ``inner_exclusive_access`` 尝试获取互斥锁来得到 ``TaskControlBlockInner`` 的可变引用。
- ``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: UPSafeCell<TaskManager> =
unsafe { UPSafeCell::new(TaskManager::new()) };
}
pub fn add_task(task: Arc<TaskControlBlock>) {
TASK_MANAGER.exclusive_access().add(task);
}
pub fn fetch_task() -> Option<Arc<TaskControlBlock>> {
TASK_MANAGER.exclusive_access().fetch()
}
``TaskManager`` 将所有的任务控制块用引用计数 ``Arc`` 智能指针包裹后放在一个双端队列 ``VecDeque`` 中。
使用智能指针的原因在于,任务控制块经常需要被放入/取出,如果直接移动任务控制块自身将会带来大量的数据拷贝开销,
而对于智能指针进行移动则没有多少开销。其次,允许任务控制块的共享引用在某些情况下能够让我们的实现更加方便。
``TaskManager`` 提供 ``add/fetch`` 两个操作,前者表示将一个任务加入队尾,后者则表示从队头中取出一个任务来执行。
从调度算法来看,这里用到的就是最简单的 RR 算法。全局实例 ``TASK_MANAGER`` 则提供给内核的其他子模块 ``add_task/fetch_task`` 两个函数。
处理器管理结构
------------------------------------------------------------------------
处理器管理结构 ``Processor`` 负责维护从任务管理器 ``TaskManager`` 分离出去的那部分 CPU 状态:
.. code-block:: rust
// os/src/task/processor.rs
pub struct Processor {
current: Option<Arc<TaskControlBlock>>,
idle_task_cx: TaskContext,
}
包括:
- ``current`` 表示在当前处理器上正在执行的任务;
- ``idle_task_cx_ptr`` 表示当前处理器上的 idle 控制流的任务上下文的地址。
在单核环境下,我们仅创建单个 ``Processor`` 的全局实例 ``PROCESSOR``
.. code-block:: rust
// os/src/task/processor.rs
lazy_static! {
pub static ref PROCESSOR: UPSafeCell<Processor> = unsafe { UPSafeCell::new(Processor::new()) };
}
正在执行的任务
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rust
:linenos:
// os/src/task/processor.rs
impl Processor {
pub fn take_current(&mut self) -> Option<Arc<TaskControlBlock>> {
self.current.take()
}
pub fn current(&self) -> Option<Arc<TaskControlBlock>> {
self.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.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`` 可以取出当前正在执行的任务。 ``Option::take`` 意味着 ``current`` 字段也变为 ``None``
- 第 7 行的 ``Processor::current`` 返回当前执行的任务的一份拷贝。。
- ``current_user_token````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_ptr(&mut self) -> *mut TaskContext {
&mut self.idle_task_cx as *mut _
}
}
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;
drop(task_inner);
// release coming task TCB manually
processor.current = Some(task);
// release processor manually
drop(processor);
unsafe {
__switch(idle_task_cx_ptr, next_task_cx_ptr);
}
}
}
}
调度功能的主体在 ``run_tasks`` 中实现。它循环调用 ``fetch_task`` 直到顺利从任务管理器中取出一个任务,然后获得
``__switch`` 两个参数进行任务切换。注意在整个过程中要严格控制临界区。
当一个应用交出 CPU 使用权时,进入内核后它会调用 ``schedule`` 函数来切换到 idle 控制流并开启新一轮的任务调度。
.. code-block:: 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);
}
}
切换回去之后,我们将跳转到 ``Processor::run````__switch`` 返回之后的位置,也即开启了下一轮循环。

View File

@@ -0,0 +1,665 @@
进程管理机制的设计实现
============================================
本节导读
--------------------------------------------
本节将介绍如何基于上一节设计的内核数据结构来实现进程管理:
- 初始进程 ``initproc`` 的创建;
- 进程调度机制:当进程主动调用 ``sys_yield`` 交出 CPU 使用权,或者内核本轮分配的时间片用尽之后如何切换到下一个进程;
- 进程生成机制:介绍进程相关的两个重要系统调用 ``sys_fork/sys_exec`` 的实现;
- 字符输入机制:介绍 ``sys_read`` 系统调用的实现;
- 进程资源回收机制:当进程调用 ``sys_exit`` 正常退出或者出错被内核终止后,如何保存其退出码,其父进程又是如何通过
``sys_waitpid`` 收集该进程的信息并回收其资源。
初始进程的创建
--------------------------------------------
内核初始化完毕之后,即会调用 ``task`` 子模块提供的 ``add_initproc`` 函数来将初始进程 ``initproc``
加入任务管理器,但在这之前,我们需要初始进程的进程控制块 ``INITPROC`` ,这基于 ``lazy_static`` 在运行时完成。
.. code-block:: rust
// os/src/task/mod.rs
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: 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
}
- 第 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 的时候用户态能正确进入内核态。
进程调度机制
--------------------------------------------
调用 ``task`` 子模块提供的 ``suspend_current_and_run_next`` 函数可以暂停当前任务,并切换到下一个任务,下面给出了两种典型的使用场景:
.. 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();
// ---- 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);
// ---- release 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`` 的效果是会继续执行这个任务。
进程的生成机制
--------------------------------------------
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> {
// ---- 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
// ---- release parent PCB automatically
// **** release children PCB automatically
}
}
它基本上和新建进程控制块的 ``TaskControlBlock::new`` 是相同的,但要注意以下几点:
- 子进程的地址空间不是通过解析 ELF而是通过在第 8 行调用 ``MemorySet::from_existed_user`` 复制父进程地址空间得到的;
- 在 fork 的时候需要注意父子进程关系的维护。既要将父进程的弱引用计数放到子进程的进程控制块中,又要将子进程插入到父进程的孩子向量 ``children`` 中。
实现 ``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.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;
// add new task to scheduler
add_task(new_task);
new_pid as isize
}
在调用 ``sys_fork`` 之前,我们已经将当前进程 Trap 上下文中的 sepc 向后移动了 4 字节,使得它回到用户态之后会从 ecall
的下一条指令开始执行。之后,当我们复制地址空间时,子进程地址空间 Trap 上下文的 sepc 也是移动之后的值,我们无需再进行修改。
父子进程回到用户态的瞬间都处于刚刚从一次系统调用返回的状态,但二者返回值不同。第 8~11 行我们将子进程的 Trap
上下文中用来存放系统调用返回值的 a0 寄存器修改为 0 ,而父进程系统调用的返回值会在 ``syscall`` 返回之后再设置为 ``sys_fork``
的返回值。这就做到了父进程 ``fork`` 的返回值为子进程的 PID ,而子进程的返回值为 0。
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();
// **** 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,
);
// **** release inner automatically
}
}
它在解析传入的 ELF 格式数据之后只做了两件事情:
- 首先从 ELF 生成一个全新的地址空间并直接替换进来(第 15 行),这将导致原有地址空间生命周期结束,里面包含的全部物理页帧都会被回收;
- 然后修改新的地址空间中的 Trap 上下文,将解析得到的应用入口点、用户栈位置以及一些内核的信息进行初始化,这样才能正常实现 Trap 机制。
``sys_exec`` 的实现如下,它调用 ``translated_str`` 找到要执行的应用名,并试图从应用加载器提供的 ``get_app_data_by_name``
接口中获取对应的 ELF 数据,如果找到的话就调用 ``TaskControlBlock::exec`` 替换地址空间。
.. code-block:: 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(data) = get_app_data_by_name(path.as_str()) {
let task = current_task().unwrap();
task.exec(data);
0
} else {
-1
}
}
应用在 ``sys_exec`` 系统调用中传递给内核的只有一个应用名字符串在用户地址空间中的首地址,内核必限手动查页表来获得字符串的值。
``translated_str`` 用来从用户地址空间中查找字符串,其原理就是逐字节查页表直到发现一个 ``\0`` 为止。为什么要逐字节查页表?
因为内核不知道字符串的长度,且字符串可能是跨物理页的。
.. code-block:: 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
}
系统调用后重新获取 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`` 失效了,因为它是就原来的地址空间而言的。为了能够处理类似的这种情况,我们在 ``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();
}
sys_read 获取输入
--------------------------------------------
我们需要实现 ``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 读入,且每次只能读入一个字符,这是利用 ``sbi``
提供的接口 ``console_getchar`` 实现的。如果还没有输入,我们就切换到其他进程,等下次切换回来时再看看是否有输入了。
获取到输入后就退出循环,并手动查页表将输入字符正确写入到应用地址空间。
进程资源回收机制
--------------------------------------------
进程的退出
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
当应用调用 ``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`` 带有一个退出码作为参数,这个退出码会在
``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();
// **** 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());
}
}
// ++++++ release parent PCB
inner.children.clear();
// deallocate user space
inner.memory_set.recycle_data_pages();
drop(inner);
// **** release 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`` 即僵尸进程;
- 第 19 行将传入的退出码 ``exit_code`` 写入进程控制块中,后续父进程在 ``waitpid`` 的时候可以收集;
- 第 24~26 行所做的事情是,将当前进程的所有子进程挂在初始进程 ``initproc`` 下面。第 32 行将当前进程的孩子向量清空。
- 第 34 行,对于当前进程占用的资源进行早期回收。 ``MemorySet::recycle_data_pages`` 只是将地址空间中的逻辑段列表
``areas`` 清空,这将导致应用地址空间的所有数据被存放在的物理页帧被回收,而用来存放页表的那些物理页帧此时则不会被回收。
- 最后在第 41 行我们调用 ``schedule`` 触发调度及任务切换,我们再也不会回到该进程的执行过程,因此无需关心任务上下文的保存。
父进程回收子进程资源
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. 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
// ---- access current TCB exclusively
let mut inner = task.inner_exclusive_access();
if !inner
.children
.iter()
.any(|p| pid == -1 || pid as usize == p.getpid())
{
return -1;
// ---- release current PCB
}
let pair = inner.children.iter().enumerate().find(|(_, p)| {
// ++++ temporarily access child PCB lock exclusively
p.inner_exclusive_access().is_zombie() && (pid == -1 || pid as usize == p.getpid())
// ++++ release 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;
// ++++ release child PCB
*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`` 完成。
首先判断 ``sys_waitpid`` 是否会返回 -1 ,这取决于当前进程是否有一个符合要求的子进程。当传入的 ``pid`` 为 -1
的时候,任何一个子进程都算是符合要求;但 ``pid`` 不为 -1 的时候,则只有 PID 恰好与 ``pid``
相同的子进程才算符合条件。我们简单通过迭代器即可完成判断。
再判断符合要求的子进程中是否有僵尸进程。如果找不到的话直接返回 ``-2`` ,否则进行下一步处理:
我们将子进程从向量中移除并置于当前上下文中,当它所在的代码块结束,这次引用变量的生命周期结束,子进程进程控制块的引用计数将变为
0 ,内核将彻底回收掉它占用的所有资源,包括内核栈、它的 PID 、存放页表的那些物理页帧等等。
获得子进程退出码后,考虑到应用传入的指针指向应用地址空间,我们还需要手动查页表找到对应物理内存中的位置。
``translated_refmut`` 的实现可以在 ``os/src/mm/page_table.rs`` 中找到。

View File

@@ -0,0 +1,137 @@
chapter5练习
==============================================
编程作业
---------------------------------------------
进程创建
+++++++++++++++++++++++++++++++++++++++++++++
大家一定好奇过为啥进程创建要用 fork + exec 这么一个奇怪的系统调用,就不能直接搞一个新进程吗?
思而不学则殆,我们就来试一试!这章的编程练习请大家实现一个完全 DIY 的系统调用 spawn用以创建一个新进程。
spawn 系统调用定义( `标准spawn看这里 <https://man7.org/linux/man-pages/man3/posix_spawn.3.html>`_ )
.. code-block:: rust
fn sys_spawn(path: *const u8) -> isize
- syscall ID: 400
- 功能:新建子进程,使其执行目标程序。
- 说明成功返回子进程id否则返回 -1。
- 可能的错误:
- 无效的文件名。
- 进程池满/内存不足等资源错误。
TIPS虽然测例很简单但提醒读者 spawn **不必** 像 fork 一样复制父进程的地址空间。
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 调度要求进程优先级 :math:`\geq 2`,所以设定进程优先级 :math:`\leq 1` 会导致错误。
- 进程初始 stride 设置为 0 即可。
- 进程初始优先级设置为 16。
为了实现该调度算法,内核还要增加 set_prio 系统调用
.. code-block:: rust
// syscall ID140
// 设置当前进程优先级为 prio
// 参数prio 进程优先级,要求 prio >= 2
// 返回值:如果输入合法则返回 prio否则返回 -1
fn sys_set_priority(prio: isize) -> isize;
实现 tips:
- 你可以在TCB加入新的字段来支持优先级等。
- 为了减少整数除的误差BIG_STRIDE 一般需要很大,但为了不至于发生反转现象(详见问答作业),或许选择一个适中的数即可,当然能进行溢出处理就更好了。
- stride 算法要找到 stride 最小的进程,使用优先级队列是效率不错的办法,但是我们的实验测例很简单,所以效率完全不是问题。事实上,很推荐使用暴力扫一遍的办法找最小值。
- 注意设置进程的初始优先级。
.. attention::
为了让大家能在本编程作业中使用 ``Vec`` 等数据结构,我们利用第三方库 ``buddy_system_allocator``
为大家实现了堆内存分配器,相关代码位于 ``mm/heap_allocator`` 模块。
背景知识: `Rust 中的动态内存分配 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter4/1rust-dynamic-allocation.html>`_
实验要求
+++++++++++++++++++++++++++++++++++++++++++++
- 实现分支ch5。
- 实验目录请参考 ch3。注意在reports中放入lab1-3的所有报告。
- 通过所有测例。
在 os 目录下 ``make run BASE=2`` 加载所有测例, ``ch5_usertest`` 打包了所有你需要通过的测例,
你也可以通过修改这个文件调整本地测试的内容, 或者单独运行某测例来纠正特定的错误。 ``ch5_stride``
检查 stride 调度算法是否满足公平性要求,六个子程序运行的次数应该大致与其优先级呈正比,测试通过标准是
:math:`\max{\frac{runtimes}{prio}}/ \min{\frac{runtimes}{prio}} < 1.5`.
CI 的原理是用 ``ch5_usertest`` 替代 ``ch5b_initproc`` ,使内核在所有测例执行完后直接退出。
从本章开始,你的内核必须前向兼容,能通过前一章的所有测例。
.. note::
利用 ``git cherry-pick`` 系列指令,能方便地将前一章分支 commit 移植到本章分支。
问答作业
--------------------------------------------
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<Stride> 的 pop
方法能返回真正最小的 Stride。补全下列代码中的 ``partial_cmp`` 函数,假设两个 Stride 永远不会相等。
.. code-block:: rust
use core::cmp::Ordering;
struct Stride(u64);
impl PartialOrd for Stride {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
// ...
}
}
impl PartialEq for Stride {
fn eq(&self, other: &Self) -> bool {
false
}
}
TIPS: 使用 8 bits 存储 stride, BigStride = 255, 则: ``(125 < 255) == false``, ``(129 < 255) == true``.
报告要求
------------------------------------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

View File

@@ -0,0 +1,12 @@
第五章:进程及进程管理
==============================================
.. toctree::
:maxdepth: 4
0intro
1process
2core-data-structures
3implement-process-mechanism
4exercise