This commit is contained in:
Exusial 2021-08-28 16:02:01 +08:00
parent 43c2f8d9e9
commit d28481ce7a
6 changed files with 312 additions and 1695 deletions

View File

@ -8,13 +8,13 @@
其实在 **UNIX** 的早期发展历史中也碰到了同样的问题每个程序专注在完成一件事情上但缺少把多个程序联合在一起完成复杂功能的机制。直到1975年UNIX v6中引入了让人眼前一亮的创新机制-- **I/O重定向****管道pipe** 。基于这两种机制,操作系统在不用改变应用程序的情况下,可以将一个程序的输出重新定向到另外一个程序的输入中,这样程序之间就可以进行任意的连接,并组合出各种灵活的复杂功能。
本章我们也会引入新操作系统概念 -- 管道,并进行实现下一章将实现I/O重定向。除了键盘和屏幕这样的 **标准** 输入和 **标准** 输出之外,管道其实也可以看成是一种特殊的输入和输出,而后面一章讲解的 **文件系统** 中的对持久化存储数据的抽象 **文件(file)** 也是一种存储设备的输入和输出。所以,我们可以把这三种输入输出都统一在 **文件(file)** 这个抽象之中。这也体现了在 Unix 操作系统中, ” **一切皆文件** “ (Everything is a file) 重要设计哲学。
本章我们也会引入新操作系统概念 -- 管道,并进行实现。除了键盘和屏幕这样的 **标准** 输入和 **标准** 输出之外,管道其实也可以看成是一种特殊的输入和输出,而后面一章讲解的 **文件系统** 中的对持久化存储数据的抽象 **文件(file)** 也是一种存储设备的输入和输出。所以,我们可以把这三种输入输出都统一在 **文件(file)** 这个抽象之中。这也体现了在 Unix 操作系统中, ” **一切皆文件** “ (Everything is a file) 重要设计哲学。
在本章中提前引入 **文件** 这个概念,但本章不会详细讲解,只是先以最简单直白的方式对 **文件** 这个抽象进行简化的设计与实现。站在本章的操作系统的角度来看, **文件** 成为了一种需要操作系统管理的I/O资源。
为了让应用能够基于 **文件** 这个抽象接口进行I/O操作我们就需要对 **进程** 这个概念进行扩展,让它能够管理 **文件** 这种资源。具体而言,就是要对进程控制块进行一定的扩展。为了统一表示 **标准** 输入和 **标准** 输出和管道,我们将在每个进程控制块中增加一个 **文件描述符表** ,在表中保存着多个 **文件** 记录信息。每个文件描述符是一个非负的索引值,即对应文件记录信息的条目在文件描述符表中的索引,可方便进程表示当前使用的 **标准** 输入、 **标准** 输出和管道(当然在下一章还可以表示磁盘上的一块数据)。用户进程访问文件将很简单,它只需通过文件描述符,就可以对 **文件** 进行读写,从而完成接收键盘输入,向屏幕输出,以及两个进程之间进行数据传输的操作。
简而言之,本章我们首先将标准输入/标准输出的访问改造为基于文件描述符,然后同样基于文件描述符实现一种父子进程之间的通信机制——管道,从而实现灵活的进程间通信,并基于管道支持进程组合来实现复杂功能
本章我们的主要目的是实现进程间的通信方式。这就意味着一个进程得到的输入和输出不一定是针对标准输入输出流了也就是fd == 0 的 stdin 和 fd == 1 的 stdout而可能是对应的pipe的新的fd。考虑到lab7我们就需要实现一个比较完成的文件系统在lab6中乘着引入pipe的机会我们会先实现一个文件系统fs的雏形
实践体验
-----------------------------------------
@ -23,8 +23,6 @@
.. code-block:: console
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch6
在 qemu 模拟器上运行本章代码:
@ -34,33 +32,6 @@
$ cd os
$ make run
将 Maix 系列开发板连接到 PC并在上面运行本章代码
.. code-block:: console
$ cd os
$ make run BOARD=k210
进入shell程序后可以运行管道机制的简单测例 ``pipetest`` 和比较复杂的测例 ``pipe_large_test````pipetest`` 需要保证父进程通过管道传输给子进程的字符串不会发生变化;而 ``pipe_large_test`` 中,父进程将一个长随机字符串传给子进程,随后父子进程同时计算该字符串的某种 Hash 值(逐字节求和),子进程会将计算后的 Hash 值传回父进程,而父进程接受到之后,需要验证两个 Hash 值相同,才算通过测试。
运行两个测例的输出可能如下:
.. code-block::
>> pipetest
Read OK, child process exited!
pipetest passed!
Shell: Process 2 exited with code 0
>> pipe_large_test
sum = 369114(parent)
sum = 369114(child)
Child process exited!
pipe_large_test passed!
Shell: Process 2 exited with code 0
>>
本章代码树
-----------------------------------------
@ -162,12 +133,21 @@
本章代码导读
-----------------------------------------------------
在本章第一节 :doc:`/chapter6/1file-descriptor` 中,我们引入了文件的概念,用它来代表进程可以读写的多种被内核管理的硬件/软件资源。进程必须通过系统调用打开一个文件,将文件加入到自身的文件描述符表中,才能通过文件描述符(也就是某个特定文件在自身文件描述符表中的下标)来读写该文件。
本章中引入了新的几个系统调用:
文件的抽象 Trait ``File`` 声明在 ``os/src/fs/mod.rs`` 中,它提供了 ``read/write`` 两个接口,可以将数据写入应用缓冲区抽象 ``UserBuffer`` ,或者从应用缓冲区读取数据。应用缓冲区抽象类型 ``UserBuffer`` 来自 ``os/src/mm/page_table.rs`` 中,它将 ``translated_byte_buffer`` 得到的 ``Vec<&'static mut [u8]>`` 进一步包装,不仅保留了原有的分段读写能力,还可以将其转化为一个迭代器逐字节进行读写,这在读写一些流式设备的时候特别有用。
.. code-block:: c
在进程控制块 ``TaskControlBlock`` 中需要加入文件描述符表字段 ``fd_table`` ,可以看到它是一个向量,里面保存了若干实现了 ``File`` Trait 的文件,由于采用动态分发,文件的类型可能各不相同。 ``os/src/syscall/fs.rs````sys_read/write`` 两个读写文件的系统调用需要访问当前进程的文件描述符表,用应用传入内核的文件描述符来索引对应的已打开文件,并调用 ``File`` Trait 的 ``read/write`` 接口; ``sys_close`` 这可以关闭一个文件。调用 ``TaskControlBlock````alloc_fd`` 方法可以在文件描述符表中分配一个文件描述符。进程控制块的其他操作也需要考虑到新增的文件描述符表字段的影响,如 ``TaskControlBlock::new`` 的时候需要对 ``fd_table`` 进行初始化, ``TaskControlBlock::fork`` 中则需要将父进程的 ``fd_table`` 复制一份给子进程。
/// 功能:为当前进程打开一个管道。
/// 参数pipe 表示应用地址空间中的一个长度为 2 的 long 数组的起始地址,内核需要按顺序将管道读端和写端的文件描述符写入到数组中。
/// 返回值:如果出现了错误则返回 -1否则返回 0 。可能的错误原因是:传入的地址不合法。
/// syscall ID59
long sys_pipe(long fd[2]);
到本章为止我们支持两种文件:标准输入输出和管道。不同于前面章节,我们将标准输入输出分别抽象成 ``Stdin````Stdout`` 两个类型,并为他们实现 ``File`` Trait 。在 ``TaskControlBlock::new`` 创建初始进程的时候,就默认打开了标准输入输出,并分别绑定到文件描述符 0 和 1 上面。
管道 ``Pipe`` 是另一种文件,它可以用于父子进程间的单向进程间通信。我们也需要为它实现 ``File`` Trait 。 ``os/src/syscall/fs.rs`` 中的系统调用 ``sys_pipe`` 可以用来打开一个管道并返回读端/写端两个文件的文件描述符。管道的具体实现在 ``os/src/fs/pipe.rs`` 中,本章第二节 :doc:`/chapter6/2pipe` 中给出了详细的讲解。管道机制的测试用例可以参考 ``user/src/bin`` 目录下的 ``pipetest.rs````pipe_large_test.rs`` 两个文件。
/// 功能:当前进程关闭一个文件。
/// 参数fd 表示要关闭的文件的文件描述符。
/// 返回值:如果成功关闭则返回 0 ,否则返回 -1 。可能的出错原因:传入的文件描述符并不对应一个打开的文件。
/// syscall ID57
long sys_close(int fd);
同时为了支持对文件的支持对sys_write和sys_read都有修改。本章的pipe被我们抽象成了文件的概念因此其对应的fd就是用于sys_write和sys_read的fd。我们的sys_close关闭文件这本章也就是关闭管道。

View File

@ -1,11 +1,6 @@
基于文件的标准输入/输出
文件系统初步
===========================================
本节导读
-------------------------------------------
本节我们介绍为何要把标准输入/输出用文件来进行抽象,以及如何以文件和文件描述符概念来重新定义标准输入/输出,并在进程中加入文件描述符表,同时将进程对于标准输入输出的访问的修改为基于文件抽象的接口实现。这主要是为下一节的管道实现奠定基础。
文件简介
-------------------------------------------
@ -23,295 +18,304 @@
在QEMU模拟的RV计算机和K210物理硬件上存在串口设备操作系统通过串口设备的输入侧连接到了同学使用的计算机的键盘设备而串口设备的输出侧这连接到了同学使用的计算机的显示器窗口上。由于RustSBI直接管理了串口设备并给操作系统提供了两个SBI接口从而使得操作系统可以很简单地通过这两个SBI接口输出或输入字符。
文件是提供给应用程序用的但有操作系统来进行管理。虽然文件可代表很多种不同类型的I/O 资源,但是在进程看来,所有文件的访问都可以通过一个很简洁的统一抽象接口 ``File`` 来进行:
文件是提供给应用程序用的但有操作系统来进行管理。虽然文件可代表很多种不同类型的I/O 资源,但是在进程看来,所有文件的访问都可以通过一个很简洁的统一抽象接口 ``File`` 来进行。我们看一下我们OS框架是如何定义一个文件的
.. code-block:: rust
.. code-block:: c
// os/src/fs/mod.rs
pub trait File : Send + Sync {
fn read(&self, buf: UserBuffer) -> usize;
fn write(&self, buf: UserBuffer) -> usize;
}
这个接口在内存和I/O资源之间建立了数据交换的通道。其中 ``UserBuffer`` 是我们在 ``mm`` 子模块中定义的应用地址空间中的一段缓冲区(即内存)的抽象。它本质上其实只是一个 ``&[u8]`` ,但是它位于应用地址空间中,在内核中我们无法直接通过这种方式来访问,因此需要进行封装。然而,在理解抽象接口 ``File`` 的各方法时,我们仍可以将 ``UserBuffer`` 看成一个 ``&[u8]`` 切片,它是同时给出了缓冲区的起始地址及长度的一个胖指针。
``read`` 指的是从文件即I/O资源中读取数据放到缓冲区中最多将缓冲区填满即读取缓冲区的长度那么多字节并返回实际读取的字节数``write`` 指的是将缓冲区中的数据写入文件,最多将缓冲区中的数据全部写入,并返回直接写入的字节数。至于 ``read````write`` 的实现则与文件具体是哪种类型有关,它决定了数据如何被读取和写入。
回过头来再看一下用户缓冲区的抽象 ``UserBuffer`` ,它的声明如下:
.. code-block:: rust
// os/src/mm/page_table.rs
pub fn translated_byte_buffer(
token: usize,
ptr: *const u8,
len: usize
) -> Vec<&'static mut [u8]>;
pub struct UserBuffer {
pub buffers: Vec<&'static mut [u8]>,
}
impl UserBuffer {
pub fn new(buffers: Vec<&'static mut [u8]>) -> Self {
Self { buffers }
}
pub fn len(&self) -> usize {
let mut total: usize = 0;
for b in self.buffers.iter() {
total += b.len();
}
total
}
}
它只是将我们调用 ``translated_byte_buffer`` 获得的包含多个切片的 ``Vec`` 进一步包装起来,通过 ``len`` 方法可以得到缓冲区的长度。此外,我们还让它作为一个迭代器可以逐字节进行读写。有兴趣的读者可以参考类型 ``UserBufferIterator`` 还有 ``IntoIterator````Iterator`` 两个 Trait 的使用方法。
标准输入和标准输出
--------------------------------------------
其实我们在第二章就对应用程序引入了基于 **文件** 的标准输出接口 ``sys_write`` ,在第五章引入了基于 **文件** 的标准输入接口 ``sys_read`` 。虽然之前还没有文件描述符表,我们提前把标准输出设备在文件描述符表中的文件描述符的值规定为 ``1`` ,用 ``Stdout`` 表示;把标准输入设备在文件描述符表中的文件描述符的值规定为 ``0``,用 ``Stdin`` 表示 。现在,我们可以重构操作系统,为标准输入和标准输出实现 ``File`` Trait使得进程可以按文件接口与I/O外设进行交互
.. code-block:: rust
:linenos:
// os/src/fs/stdio.rs
pub struct Stdin;
pub struct Stdout;
impl File for Stdin {
fn read(&self, mut user_buf: UserBuffer) -> usize {
assert_eq!(user_buf.len(), 1);
// busy loop
let mut c: usize;
loop {
c = console_getchar();
if c == 0 {
suspend_current_and_run_next();
continue;
} else {
break;
}
}
let ch = c as u8;
unsafe { user_buf.buffers[0].as_mut_ptr().write_volatile(ch); }
1
}
fn write(&self, _user_buf: UserBuffer) -> usize {
panic!("Cannot write to stdin!");
}
}
impl File for Stdout {
fn read(&self, _user_buf: UserBuffer) -> usize{
panic!("Cannot read from stdout!");
}
fn write(&self, user_buf: UserBuffer) -> usize {
for buffer in user_buf.buffers.iter() {
print!("{}", core::str::from_utf8(*buffer).unwrap());
}
user_buf.len()
}
}
可以看到,标准输入文件 ``Stdin`` 是只读文件,只允许进程通过 ``read`` 从里面读入,目前每次仅支持读入一个字符,其实现与之前的 ``sys_read`` 基本相同,只是需要通过 ``UserBuffer`` 来获取具体将字节写入的位置。相反,标准输出文件 ``Stdout`` 是只写文件,只允许进程通过 ``write`` 写入到里面,实现方法是遍历每个切片,将其转化为字符串通过 ``print!`` 宏来输出。值得注意的是,如果有多核同时使用 ``print!`` 宏,将会导致两个不同的输出交错到一起造成输出混乱,后续我们还会对它做一些改进。
文件描述符与文件描述符表
--------------------------------------------
.. chyyuu 可以解释一下文件描述符的起因???
一个进程可以访问的I/O资源可以有很多种所以在操作系统类需要有一个管理进程访问的很多I/O资源的结构这就是**文件描述符表** (File Descriptor Table) ,其中的每个 **文件描述符** (File Descriptor) 代表了一个特定读写属性的I/O资源。
为简化操作系统设计实现,可以让每个进程都带有一个线性的 **文件描述符表** ,记录所有它请求内核打开并可以读写的那些文件集合。而 **文件描述符** (File Descriptor) 则是一个非负整数,表示文件描述符表中一个打开的 **文件描述符** 所处的位置(可理解为数组下标)。进程通过文件描述符,可以在自身的文件描述符表中找到对应的文件记录信息,从而也就找到了对应的文件,并对文件进行读写。当打开( ``open`` )或创建( ``create`` 一个文件的时候,如果顺利,内核会返回给应用刚刚打开或创建的文件对应的文件描述符;而当应用想关闭( ``close`` )一个文件的时候,也需要向内核提供对应的文件描述符,以完成对应文件描述符的回收操作。
文件I/O操作
-------------------------------------------
这样应用程序如果要基于文件进行I/O访问大致就会涉及如下几个操作
- 打开open应用只有打开文件操作系统才能返回一个可进行读写的文件描述符给应用应用才能基于这个值来进行对应文件的读写
- 关闭close应用基于文件描述符关闭文件后就不能再对文件进行读写操作了这样可以在一定程度上保证对文件的合法访问
- 读read应用可以基于文件描述符来读文件内容到相应内存中
- 写write应用可以基于文件描述符来把相应内存内容写到文件中
在本节中,还不会涉及创建文件。当一个进程被创建的时候,内核会默认为其打开三个缺省就存在的文件:
- 文件描述符为 0 的标准输入;
- 文件描述符为 1 的标准输出;
- 文件描述符为 2 的标准错误输出。
在我们的实现中并不区分标准输出和标准错误输出,而是会将文件描述符 1 和 2 均对应到标准输出。实际上,在本章中,标准输出文件就是串口输出,标准输入文件就是串口输入。
这里隐含着有关文件描述符的一条重要的规则:即进程打开一个文件的时候,内核总是会将文件分配到该进程文件描述符表中 **最小的** 空闲位置。比如,当一个进程被创建以后立即打开一个文件,则内核总是会返回文件描述符 3 。当我们关闭一个打开的文件之后,它对应的文件描述符将会变得空闲并在后面可以被分配出去。
我们需要在进程控制块中加入文件描述符表的相应字段:
.. code-block:: rust
:linenos:
:emphasize-lines: 12
// os/src/task/task.rs
pub struct TaskControlBlockInner {
pub trap_cx_ppn: PhysPageNum,
pub base_size: usize,
pub task_cx_ptr: usize,
pub task_status: TaskStatus,
pub memory_set: MemorySet,
pub parent: Option<Weak<TaskControlBlock>>,
pub children: Vec<Arc<TaskControlBlock>>,
pub exit_code: i32,
pub fd_table: Vec<Option<Arc<dyn File + Send + Sync>>>,
}
可以看到 ``fd_table`` 的类型包含多层嵌套,我们从外到里分别说明:
- ``Vec`` 的动态长度特性使得我们无需设置一个固定的文件描述符数量上限,我们可以更加灵活的使用内存,而不必操心内存管理问题;
- ``Option`` 使得我们可以区分一个文件描述符当前是否空闲,当它是 ``None`` 的时候是空闲的,而 ``Some`` 则代表它已被占用;
- ``Arc`` 首先提供了共享引用能力。后面我们会提到,可能会有多个进程共享同一个文件对它进行读写。此外被它包裹的内容会被放到内核堆而不是栈上,于是它便不需要在编译期有着确定的大小;
- ``dyn`` 关键字表明 ``Arc`` 里面的类型实现了 ``File/Send/Sync`` 三个 Trait ,但是编译期无法知道它具体是哪个类型(可能是任何实现了 ``File`` Trait 的类型如 ``Stdin/Stdout`` ,故而它所占的空间大小自然也无法确定),需要等到运行时才能知道它的具体类型,对于一些抽象方法的调用也是在那个时候才能找到该类型实现的版本的地址并跳转过去。
.. note::
**Rust 语法卡片Rust 中的多态**
在编程语言中, **多态** (Polymorphism) 指的是在同一段代码中可以隐含多种不同类型的特征。在 Rust 中主要通过泛型和 Trait 来实现多态。
泛型是一种 **编译期多态** (Static Polymorphism),在编译一个泛型函数的时候,编译器会对于所有可能用到的类型进行实例化并对应生成一个版本的汇编代码,在编译期就能知道选取哪个版本并确定函数地址,这可能会导致生成的二进制文件体积较大;而 Trait 对象(也即上面提到的 ``dyn`` 语法)是一种 **运行时多态** (Dynamic Polymorphism),需要在运行时查一种类似于 C++ 中的 **虚表** (Virtual Table) 才能找到实际类型对于抽象接口实现的函数地址并进行调用,这样会带来一定的运行时开销,但是更为灵活。
当新建一个进程的时候,我们需要按照先前的说明为进程打开标准输入文件和标准输出文件:
.. code-block:: rust
:linenos:
:emphasize-lines: 18-25
// os/src/task/task.rs
impl TaskControlBlock {
pub fn new(elf_data: &[u8]) -> Self {
...
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,
fd_table: vec![
// 0 -> stdin
Some(Arc::new(Stdin)),
// 1 -> stdout
Some(Arc::new(Stdout)),
// 2 -> stderr
Some(Arc::new(Stdout)),
],
}),
// file.h
struct file {
enum { FD_NONE = 0, FD_PIPE} type; // FD_NODE means this file is null.
int ref; // reference count
char readable;
char writable;
struct pipe *pipe; // FD_PIPE
};
...
// 全局文件池
extern struct file filepool[128 * 16];
可以看到一个文件在我们OS对应的结构体之中存储了其种类本章目前只有管道下一章会引入新的支持。注意我们不认为 stdin、stdout、stderr 是真正的文件而是直接和串口接在一起。所以虽然它们占有fd但是没有给它们分配文件种类。大家可以通过分配给它们的fd值来区分是不是标准输入输出错误流。
和内存池一样文件我们也采用了比较简答的文件池写法。OS全局的文件数量是有上限的。
文件的ref记录了其引用的次数。我们分配一个文件时会遍历文件池寻找ref为0的文件来分配注意这里只是OS分配我们没有支持sys_open去打开在某个地址的文件的调用它在lab7实现。同理关闭文件就是减少其对应的ref值。
.. code-block:: c
// os/file.c
struct file* filealloc() {
for(int i = 0; i < FILE_MAX; ++i) {
if(filepool[i].ref == 0) {
filepool[i].ref = 1;
return &filepool[i];
}
}
return 0;
}
此外,在 fork 的时候,子进程需要完全继承父进程的文件描述符表来和父进程共享所有文件:
void
fileclose(struct file *f)
{
if(--f->ref > 0) {
return;
}
if(f->type == FD_PIPE){
pipeclose(f->pipe, f->writable);
}
memset(f, 0, sizeof(struct file));
}
.. code-block:: rust
注意文件对于进程而言也是其需要记录的一种资源因此我们在进程对应的PCB结构体之中也需要记录进程打开的文件信息。我们给PCB增加文件指针数组。
.. code-block:: c
// proc.h
// Per-process state
struct proc {
// ...
+ struct file* files[16];
};
// os/proc.c
int fdalloc(struct file* f) {
struct proc* p = curr_proc();
// fd = 0,1,2 is reserved for stdio/stdout/stderr
for(int i = 3; i < FD_MAX; ++i) {
if(p->files[i] == 0) {
p->files[i] = f;
return i;
}
}
return -1;
}
一个进程能打开的文件是有限的我们设置为16。一个进程如果要打开某一个文件其文件指针数组必须有空位。如果有就把下标做为文件的fd并把指定文件指针存入数组之中。
pipe管道的实现
--------------------------------------------
管道是一种进程间通信的方式。它允许管道两端的进程互相传递信息。我们OS框架对于pipe的设计十分简单: 找一块空闲内存作为 pipe 的 data buffer两端的进程对 pipe 的读写就转化为了对这块内存的读写。虽然逻辑十分简单但是进程读写管道实际还是通过sys_write和sys_read来实现的。sys_write 还同时需要完成屏幕输出,一个程序还可以拥有多个 pipe而且 pipe 还要能够使得其他程序可见来完成进程通讯的功能,对每个 pipe 还要维护一些状态来记录上一次读写到的位置和 pipe 实际可读的 size等。因此我们也需要关注一下我们OS pipe实现的细节。
首先,看一下管道的结构体。
.. code-block:: c
// file.h抽象成一个文件了。
#define PIPESIZE 512
struct pipe {
char data[PIPESIZE];
uint nread; // number of bytes read
uint nwrite; // number of bytes written
int readopen; // read fd is still open
int writeopen; // write fd is still open
};
可以看到管道把数据存在了一个char数组的缓存之中来维护。这里我们以ring buffer的形式管理管道的data buffer。
我们来看一下如何创建一个管道。
.. code-block:: c
:linenos:
:emphasize-lines: 8-16,29
// os/src/task/task.rs
int pipealloc(struct file *f0, struct file *f1)
{
// 这里没有用预分配,由于 pipe 比较大,直接拿一个页过来,也不算太浪费
struct pipe *pi = (struct pipe*)kalloc();
// 一开始 pipe 可读可写,但是已读和已写内容为 0
pi->readopen = 1;
pi->writeopen = 1;
pi->nwrite = 0;
pi->nread = 0;
impl TaskControlBlock {
pub fn fork(self: &Arc<TaskControlBlock>) -> Arc<TaskControlBlock> {
...
// 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());
// copy fd table
let mut new_fd_table: Vec<Option<Arc<dyn File + Send + Sync>>> = Vec::new();
for fd in parent_inner.fd_table.iter() {
if let Some(file) = fd {
new_fd_table.push(Some(file.clone()));
// 两个参数分别通过 filealloc 得到,把该 pipe 和这两个文件关连,一端可读,一端可写。读写端控制是 sys_pipe 的要求。
f0->type = FD_PIPE;
f0->readable = 1;
f0->writable = 0;
f0->pipe = pi;
f1->type = FD_PIPE;
f1->readable = 0;
f1->writable = 1;
f1->pipe = pi;
return 0;
}
管道两端的输入和输出被我们抽象成了两个文件。这两个文件的创建由sys_pipe调用完成。我们在分配时就会设置管道两端哪一端可写哪一端可读并初始化管道本身的nread和nwrite记录buffer的指针。
关闭pipe比较简单。函数其实只关闭了读写端中的一个如果两个都被关闭释放 pipe。
.. code-block:: c
:linenos:
void pipeclose(struct pipe *pi, int writable)
{
if(writable){
pi->writeopen = 0;
} else {
new_fd_table.push(None);
pi->readopen = 0;
}
}
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,
fd_table: new_fd_table,
}),
});
// add child
...
if(pi->readopen == 0 && pi->writeopen == 0){
kfree((char*)pi);
}
}
这样,即使我们仅手动为初始进程 ``initproc`` 打开了标准输入输出,所有进程也都可以访问它们。
重点是管道的读写.
文件读写系统调用
---------------------------------------------------
基于文件抽象接口和文件描述符表,我们终于可以让文件读写系统调用 ``sys_read/write`` 变得更加具有普适性,不仅仅局限于之前特定的标准输入输出:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
let token = current_user_token();
let task = current_task().unwrap();
let inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
.. code-block:: c
:linenos:
int pipewrite(struct pipe *pi, uint64 addr, int n)
{
// w 记录已经写的字节数
int w = 0;
struct proc *p = curr_proc();
while(w < n){
// 若不可读,写也没有意义
if(pi->readopen == 0){
return -1;
}
if let Some(file) = &inner.fd_table[fd] {
let file = file.clone();
// release Task lock manually to avoid deadlock
drop(inner);
file.write(
UserBuffer::new(translated_byte_buffer(token, buf, len))
) as isize
if(pi->nwrite == pi->nread + PIPESIZE){
// pipe write 端已满,阻塞
yield();
} else {
-1
// 一次读的 size 为 min(用户buffer剩余pipe 剩余写容量pipe 剩余线性容量)
uint64 size = MIN(
n - w,
pi->nread + PIPESIZE - pi->nwrite,
PIPESIZE - (pi->nwrite % PIPESIZE)
);
// 使用 copyin 读入用户 buffer 内容
copyin(p->pagetable, &pi->data[pi->nwrite % PIPESIZE], addr + w, size);
pi->nwrite += size;
w += size;
}
}
return w;
}
pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
let token = current_user_token();
let task = current_task().unwrap();
let inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
int piperead(struct pipe *pi, uint64 addr, int n)
{
// r 记录已经写的字节数
int r = 0;
struct proc *p = curr_proc();
// 若 pipe 可读内容为空,阻塞或者报错
while(pi->nread == pi->nwrite) {
if(pi->writeopen)
yield();
else
return -1;
}
if let Some(file) = &inner.fd_table[fd] {
let file = file.clone();
// release Task lock manually to avoid deadlock
drop(inner);
file.read(
UserBuffer::new(translated_byte_buffer(token, buf, len))
) as isize
} else {
-1
while(r < n && size != 0) {
// pipe 可读内容为空,返回
if(pi->nread == pi->nwrite)
break;
// 一次写的 size 为min(用户buffer剩余可读内容pipe剩余线性容量)
uint64 size = MIN(
n - r,
pi->nwrite - pi->nread,
PIPESIZE - (pi->nread % PIPESIZE)
);
// 使用 copyout 写用户内存
copyout(p->pagetable, addr + r, &pi->data[pi->nread % PIPESIZE], size);
pi->nread += size;
r += size;
}
return r;
}
我们都是在当前进程的文件描述符表中通过文件描述符找到某个文件,无需关心文件具体的类型,只要知道它一定实现了 ``File`` Trait 的 ``read/write`` 方法即可。Trait 对象提供的运行时多态能力会在运行的时候帮助我们定位到 ``read/write`` 的符合实际类型的实现。
由于我们的管道是由ring buffer形式来管理的其本身的容量只有PAGESIZE大小因此需要使用nread和nwrite两个指针来记录当前两端分别写到哪里了它们的绝对值可以大于PAGESIZE关键是两者的差值。由于必须写了才能读因此有关系 nwrite >= nread。相等意味着当前已经读完了就退出piperead。如果nwrite - nread == PAGESIZE 则说明已经写满了整个PAGESIZE不能再写了会覆盖住没读的部分。如果能写入就会将数据写入data之中注意由于是环形如果nwrite % PAGESIZE != 0并且当前指针位置到环尾写不下要写入的数据,会从环头继续写.大家可以仔细阅读write的实现。
pipe 相关系统调用
--------------------------------------------
首先是sys_pipe.
.. code-block:: c
:linenos:
// os/syscall.c
uint64 sys_pipe(uint64 fdarray) {
struct proc *p = curr_proc();
// 申请两个空 file
struct file* f0 = filealloc();
struct file* f1 = filealloc();
// 实际分配一个 pipe与两个文件关联
pipealloc(f0, f1);
// 分配两个 fd并将之与 文件指针关联
fd0 = fdalloc(f0);
fd1 = fdalloc(f1);
size_t PSIZE = sizeof(fd0);
copyout(p->pagetable, fdarray, &fd0, sizeof(fd0));
copyout(p->pagetable, fdarray + sizeof(uint64), &fd1, sizeof(fd1));
return 0;
}
这个系统调用完成了创建一个pipe并记录下两端对应file的功能。并把对应的fd写入user传入的数组地址之中传回user态。
sys_close比较简单。就只是释放掉进程的fd并且清空对应file并且设置其种类为FD_NONE.
.. code-block:: c
:linenos:
uint64 sys_close(int fd) {
// stdio/stdout/stderr can't be closed for now
if(fd <= 2)
return 0;
struct proc *p = curr_proc();
fileclose(p->files[fd]);
p->files[fd] = 0;
return 0;
}
void fileclose(struct file *f)
{
if(f->ref < 1)
panic("fileclose");
if(--f->ref > 0) {
return;
}
if(f->type == FD_PIPE){
pipeclose(f->pipe, f->writable);
}
f->off = 0;
f->readable = 0;
f->writable = 0;
f->ref = 0;
f->type = FD_NONE;
}
原来的 sys_write 更名为 console_write新 sys_write 根据文件类型分别调用 console_write 和 pipe_write。sys_read 同理。具体的区分是通过判断fd来进行的。
.. code-block:: c
:linenos:
uint64 sys_write(int fd, uint64 va, uint64 len) {
if(fd <= 2) {
return console_write(va, len);
}
struct proc *p = curr_proc();
struct file *f = p->files[fd];
if(f->type == FD_PIPE) {
return pipewrite(f->pipe, va, len);
}
error("unknown file type %d\n", f->type);
return -1;
}
uint64 sys_read(int fd, uint64 va, uint64 len) {
if(fd <= 2) {
return console_read(va, len);
}
struct proc *p = curr_proc();
struct file *f = p->files[fd];
if(f->type == FD_PIPE) {
return piperead(f->pipe, va, len);
}
error("unknown file type %d\n", f->type);
return -1;
}
注意一个文件目前fd最大就是15。

View File

@ -1,401 +1,48 @@
管道
进程通讯与 fork
============================================
本节导读
fork的修改
--------------------------------------------
在上一节,我们实现了基于文件接口的标准输入和输出,这样一个进程可以根据不同的输入产生对应的输出。本节我们将基于上一节介绍的文件接口 ``File`` 来把不同进程的输入和输出连接起来,从而在不改变应用程序代码的情况下,让操作系统具有进程间信息交换和功能组合的能力。这需要我们实现实现一种父子进程间的单向进程间通信机制——管道,并为此实现两个新的系统调用 ``sys_pipe````sys_close``
fork 为什么是毒瘤呢?因为你总是要在新增加一个东西以后考虑要不要为新功能增加 fork 支持。这一章的文件就是第一个例子,那么在 fork 语境下子进程也需要继承父进程的文件资源也就是PCB之中的指针文件数组。我们应该如何处理呢我们来看看 fork 在这一个 chapter 的实现:
管道机制简介
--------------------------------------------
.. code-block:: c
.. chyyuu 进一步介绍一下pipe的历史???
首先来介绍什么是 **管道** (Pipe) 。管道是一种进程间通信机制由操作系统提供并可通过直接编程或在shell程序的帮助下轻松地把不同进程目前是父子进程之间或子子进程之间的输入和输出对接起来。我们也可以将管道看成一个有一定缓冲区大小的字节队列它分为读和写两端需要通过不同的文件描述符来访问。读端只能用来从管道中读取而写端只能用来将数据写入管道。由于管道是一个队列读取的时候会从队头读取并弹出而写入的时候则会写入到队列的队尾。同时管道的缓冲区大小是有限的一旦整个缓冲区都被填满就不能再继续写入需要等到读端读取并从队列中弹出一些字符之后才能继续写入。当缓冲区为空的时候自然也不能继续从里面读取需要等到写端写入了一些数据之后才能继续读取。
比如一般在shell程序中 ``“|”`` 是管道符号即两个命令之间的一道竖杠。我们通过管道符号组合的命令就可以了解登录Linux的用户的各种情况
.. code-block:: shell
who # 登录Linux的用户信息
who | grep chyyuu # 是否用户ID为chyyuu的用户登录了
who | grep chyyuu | wc # chyyuu用户目前在线登录的个数
管道的系统调用原型及使用方法
--------------------------------------------
接下来,我们将逐步尝试实现上面描述的管道的初步效果。我们新增一个系统调用来为当前进程打开一个代表管道的文件集(包含一个只读文件,一个只写文件):
.. code-block:: rust
/// 功能:为当前进程打开一个管道。
/// 参数pipe 表示应用地址空间中的一个长度为 2 的 usize 数组的起始地址,内核需要按顺序将管道读端
/// 和写端的文件描述符写入到数组中。
/// 返回值:如果出现了错误则返回 -1否则返回 0 。可能的错误原因是:传入的地址不合法。
/// syscall ID59
pub fn sys_pipe(pipe: *mut usize) -> isize;
在用户库中会将其包装为 ``pipe`` 函数:
.. code-block:: rust
// user/src/syscall.rs
const SYSCALL_PIPE: usize = 59;
pub fn sys_pipe(pipe: &mut [usize]) -> isize {
syscall(SYSCALL_PIPE, [pipe.as_mut_ptr() as usize, 0, 0])
int fork() {
// ...
+ for(int i = 3; i < FD_MAX; ++i)
+ if(p->files[i] != 0 && p->files[i]->type != FD_NONE) {
+ p->files[i]->ref++;
+ np->files[i] = p->files[i];
+ }
// ...
}
// user/src/lib.rs
可以看到创建子进程时会遍历父进程继承其所有打开的文件并且给指定文件的ref + 1。因为我们记录的本身就只是一个指针只需用ref来记录一个文件还有没有进程使用。
pub fn pipe(pipe_fd: &mut [usize]) -> isize { sys_pipe(pipe_fd) }
你会发现 exec 的实现竟然没有修改,注意 exec 仅仅重新加载进程执行的测例文件镜像不会改变其他属性比如文件。也就是说fork 出的子进程打开了与父进程相同的文件,但是 exec 并不会把打开的文件刷掉,基于这一点,我们可以利用 pipe 进行进程间通信。
只有当一个管道的所有读端文件/写端文件都被关闭之后,管道占用的资源才会被回收,因此我们需要通过关闭文件的系统调用 ``sys_close`` 来尽可能早的关闭之后不再用到的读端的文件和写端的文件。
.. code-block:: c
.. code-block:: rust
char STR[] = "hello pipe!";
/// 功能:当前进程关闭一个文件。
/// 参数fd 表示要关闭的文件的文件描述符。
/// 返回值:如果成功关闭则返回 0 ,否则返回 -1 。可能的出错原因:传入的文件描述符并不对应一个打开的文件。
/// syscall ID57
pub fn sys_close(fd: usize) -> isize;
它会在用户库中被包装为 ``close`` 函数。
我们来从简单的管道测例 ``pipetest`` 中介绍管道的使用方法:
.. code-block:: rust
:linenos:
// user/src/bin/pipetest.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{fork, close, pipe, read, write, wait};
static STR: &str = "Hello, world!";
#[no_mangle]
pub fn main() -> i32 {
// create pipe
let mut pipe_fd = [0usize; 2];
pipe(&mut pipe_fd);
// read end
assert_eq!(pipe_fd[0], 3);
// write end
assert_eq!(pipe_fd[1], 4);
if fork() == 0 {
// child process, read from parent
// close write_end
close(pipe_fd[1]);
let mut buffer = [0u8; 32];
let len_read = read(pipe_fd[0], &mut buffer) as usize;
// close read_end
close(pipe_fd[0]);
assert_eq!(core::str::from_utf8(&buffer[..len_read]).unwrap(), STR);
println!("Read OK, child process exited!");
0
int main() {
uint64 pipe_fd[2];
int ret = pipe(&pipe_fd);
if (fork() == 0) {
// 子进程,从 pipe 读,和 STR 比较。
char buffer[32 + 1];
read(pipe_fd[0], buffer, 32);
assert(strncmp(buffer, STR, strlen(STR) == 0);
exit(0);
} else {
// parent process, write to child
// close read end
close(pipe_fd[0]);
assert_eq!(write(pipe_fd[1], STR.as_bytes()), STR.len() as isize);
// close write end
close(pipe_fd[1]);
let mut child_exit_code: i32 = 0;
wait(&mut child_exit_code);
assert_eq!(child_exit_code, 0);
println!("pipetest passed!");
0
// 父进程,写 pipe
write(pipe_fd[1], STR, strlen(STR));
int exit_code = 0;
wait(&exit_code);
assert(exit_code == 0);
}
return 0;
}
在父进程中,我们通过 ``pipe`` 打开一个管道文件数组,其中 ``pipe_fd[0]`` 保存了管道读端的文件描述符,而 ``pipe_fd[1]`` 保存了管道写端的文件描述符。在 ``fork`` 之后,子进程会完全继承父进程的文件描述符表,于是子进程也可以通过同样的文件描述符来访问同一个管道的读端和写端。之前提到过管道是单向的,在这个测例中我们希望管道中的数据从父进程流向子进程,也即父进程仅通过管道的写端写入数据,而子进程仅通过管道的读端读取数据。
因此,在第 25 和第 34 行,分别第一时间在子进程中关闭管道的写端和在父进程中关闭管道的读端。父进程在第 35 行将字符串 ``STR`` 写入管道的写端,随后在第 37 行关闭管道的写端;子进程在第 27 行从管道的读端读取字符串,并在第 29 行关闭。
如果想在父子进程之间实现双向通信,我们就必须创建两个管道。有兴趣的读者可以参考测例 ``pipe_large_test``
通过 sys_close 关闭文件
--------------------------------------------
关闭文件的系统调用 ``sys_close`` 实现非常简单,我们只需将进程控制块中的文件描述符表对应的一项改为 ``None`` 代表它已经空闲即可,同时这也会导致内层的引用计数类型 ``Arc`` 被销毁,会减少一个文件的引用计数,当引用计数减少到 0 之后文件所占用的资源就会被自动回收。
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_close(fd: usize) -> isize {
let task = current_task().unwrap();
let mut inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if inner.fd_table[fd].is_none() {
return -1;
}
inner.fd_table[fd].take();
0
}
基于文件的管道
--------------------------------------------
我们将管道的一端(读端或写端)抽象为 ``Pipe`` 类型:
.. code-block:: rust
// os/src/fs/pipe.rs
pub struct Pipe {
readable: bool,
writable: bool,
buffer: Arc<Mutex<PipeRingBuffer>>,
}
``readable````writable`` 分别指出该管道端可否支持读取/写入,通过 ``buffer`` 字段还可以找到该管道端所在的管道自身。后续我们将为它实现 ``File`` Trait ,之后它便可以通过文件描述符来访问。
而管道自身,也就是那个带有一定大小缓冲区的字节队列,我们抽象为 ``PipeRingBuffer`` 类型:
.. code-block:: rust
// os/src/fs/pipe.rs
const RING_BUFFER_SIZE: usize = 32;
#[derive(Copy, Clone, PartialEq)]
enum RingBufferStatus {
FULL,
EMPTY,
NORMAL,
}
pub struct PipeRingBuffer {
arr: [u8; RING_BUFFER_SIZE],
head: usize,
tail: usize,
status: RingBufferStatus,
write_end: Option<Weak<Pipe>>,
}
- ``RingBufferStatus`` 记录了缓冲区目前的状态:``FULL`` 表示缓冲区已满不能再继续写入; ``EMPTY`` 表示缓冲区为空无法从里面读取;而 ``NORMAL`` 则表示除了 ``FULL````EMPTY`` 之外的其他状态。
- ``PipeRingBuffer````arr/head/tail`` 三个字段用来维护一个循环队列,其中 ``arr`` 为存放数据的数组, ``head`` 为循环队列队头的下标, ``tail`` 为循环队列队尾的下标。
- ``PipeRingBuffer````write_end`` 字段还保存了它的写端的一个弱引用计数,这是由于在某些情况下需要确认该管道所有的写端是否都已经被关闭了,通过这个字段很容易确认这一点。
从内存管理的角度,每个读端或写端中都保存着所属管道自身的强引用计数,且我们确保这些引用计数只会出现在管道端口 ``Pipe`` 结构体中。于是,一旦一个管道所有的读端和写端均被关闭,便会导致它们所属管道的引用计数变为 0 ,循环队列缓冲区所占用的资源被自动回收。虽然 ``PipeRingBuffer`` 中保存了一个指向写端的引用计数,但是它是一个弱引用,也就不会出现循环引用的情况导致内存泄露。
.. chyyuu 介绍弱引用???
管道创建
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
通过 ``PipeRingBuffer::new`` 可以创建一个新的管道:
.. code-block:: rust
// os/src/fs/pipe.rs
impl PipeRingBuffer {
pub fn new() -> Self {
Self {
arr: [0; RING_BUFFER_SIZE],
head: 0,
tail: 0,
status: RingBufferStatus::EMPTY,
write_end: None,
}
}
}
``Pipe````read/write_end_with_buffer`` 方法可以分别从一个已有的管道创建它的读端和写端:
.. code-block:: rust
// os/src/fs/pipe.rs
impl Pipe {
pub fn read_end_with_buffer(buffer: Arc<Mutex<PipeRingBuffer>>) -> Self {
Self {
readable: true,
writable: false,
buffer,
}
}
pub fn write_end_with_buffer(buffer: Arc<Mutex<PipeRingBuffer>>) -> Self {
Self {
readable: false,
writable: true,
buffer,
}
}
}
可以看到,读端和写端的访问权限进行了相应设置:不允许向读端写入,也不允许从写端读取。
通过 ``make_pipe`` 方法可以创建一个管道并返回它的读端和写端:
.. code-block:: rust
// os/src/fs/pipe.rs
impl PipeRingBuffer {
pub fn set_write_end(&mut self, write_end: &Arc<Pipe>) {
self.write_end = Some(Arc::downgrade(write_end));
}
}
/// Return (read_end, write_end)
pub fn make_pipe() -> (Arc<Pipe>, Arc<Pipe>) {
let buffer = Arc::new(Mutex::new(PipeRingBuffer::new()));
let read_end = Arc::new(
Pipe::read_end_with_buffer(buffer.clone())
);
let write_end = Arc::new(
Pipe::write_end_with_buffer(buffer.clone())
);
buffer.lock().set_write_end(&write_end);
(read_end, write_end)
}
注意,我们调用 ``PipeRingBuffer::set_write_end`` 在管道中保留它的写端的弱引用计数。
现在来实现创建管道的系统调用 ``sys_pipe``
.. code-block:: rust
:linenos:
// os/src/task/task.rs
impl TaskControlBlockInner {
pub fn alloc_fd(&mut self) -> usize {
if let Some(fd) = (0..self.fd_table.len())
.find(|fd| self.fd_table[*fd].is_none()) {
fd
} else {
self.fd_table.push(None);
self.fd_table.len() - 1
}
}
}
// os/src/syscall/fs.rs
pub fn sys_pipe(pipe: *mut usize) -> isize {
let task = current_task().unwrap();
let token = current_user_token();
let mut inner = task.acquire_inner_lock();
let (pipe_read, pipe_write) = make_pipe();
let read_fd = inner.alloc_fd();
inner.fd_table[read_fd] = Some(pipe_read);
let write_fd = inner.alloc_fd();
inner.fd_table[write_fd] = Some(pipe_write);
*translated_refmut(token, pipe) = read_fd;
*translated_refmut(token, unsafe { pipe.add(1) }) = write_fd;
0
}
``TaskControlBlockInner::alloc_fd`` 可以在进程控制块中分配一个最小的空闲文件描述符来访问一个新打开的文件。它先从小到大遍历所有曾经被分配过的文件描述符尝试找到一个空闲的,如果没有的话就需要拓展文件描述符表的长度并新分配一个。
``sys_pipe`` 中,第 21 行我们调用 ``make_pipe`` 创建一个管道并获取其读端和写端,第 22~25 行我们分别为读端和写端分配文件描述符并将它们放置在文件描述符表中的相应位置中。第 26~27 行我们则是将读端和写端的文件描述符写回到应用地址空间。
管道读写
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
首先来看如何为 ``Pipe`` 实现 ``File`` Trait 的 ``read`` 方法,即从管道的读端读取数据。在此之前,我们需要对于管道循环队列进行封装来让它更易于使用:
.. code-block:: rust
:linenos:
// os/src/fs/pipe.rs
impl PipeRingBuffer {
pub fn read_byte(&mut self) -> u8 {
self.status = RingBufferStatus::NORMAL;
let c = self.arr[self.head];
self.head = (self.head + 1) % RING_BUFFER_SIZE;
if self.head == self.tail {
self.status = RingBufferStatus::EMPTY;
}
c
}
pub fn available_read(&self) -> usize {
if self.status == RingBufferStatus::EMPTY {
0
} else {
if self.tail > self.head {
self.tail - self.head
} else {
self.tail + RING_BUFFER_SIZE - self.head
}
}
}
pub fn all_write_ends_closed(&self) -> bool {
self.write_end.as_ref().unwrap().upgrade().is_none()
}
}
``PipeRingBuffer::read_byte`` 方法可以从管道中读取一个字节,注意在调用它之前需要确保管道缓冲区中不是空的。它会更新循环队列队头的位置,并比较队头和队尾是否相同,如果相同的话则说明管道的状态变为空 ``EMPTY`` 。仅仅通过比较队头和队尾是否相同不能确定循环队列是否为空,因为它既有可能表示队列为空,也有可能表示队列已满。因此我们需要在 ``read_byte`` 的同时进行状态更新。
``PipeRingBuffer::available_read`` 可以计算管道中还有多少个字符可以读取。我们首先需要需要判断队列是否为空,因为队头和队尾相等可能表示队列为空或队列已满,两种情况 ``available_read`` 的返回值截然不同。如果队列为空的话直接返回 0否则根据队头和队尾的相对位置进行计算。
``PipeRingBuffer::all_write_ends_closed`` 可以判断管道的所有写端是否都被关闭了,这是通过尝试将管道中保存的写端的弱引用计数升级为强引用计数来实现的。如果升级失败的话,说明管道写端的强引用计数为 0 ,也就意味着管道所有写端都被关闭了,从而管道中的数据不会再得到补充,待管道中仅剩的数据被读取完毕之后,管道就可以被销毁了。
下面是 ``Pipe````read`` 方法的实现:
.. code-block:: rust
:linenos:
// os/src/fs/pipe.rs
impl File for Pipe {
fn read(&self, buf: UserBuffer) -> usize {
assert_eq!(self.readable, true);
let mut buf_iter = buf.into_iter();
let mut read_size = 0usize;
loop {
let mut ring_buffer = self.buffer.lock();
let loop_read = ring_buffer.available_read();
if loop_read == 0 {
if ring_buffer.all_write_ends_closed() {
return read_size;
}
drop(ring_buffer);
suspend_current_and_run_next();
continue;
}
// read at most loop_read bytes
for _ in 0..loop_read {
if let Some(byte_ref) = buf_iter.next() {
unsafe { *byte_ref = ring_buffer.read_byte(); }
read_size += 1;
} else {
return read_size;
}
}
}
}
}
- 第 6 行的 ``buf_iter`` 将传入的应用缓冲区 ``buf`` 转化为一个能够逐字节对于缓冲区进行访问的迭代器,每次调用 ``buf_iter.next()`` 即可按顺序取出用于访问缓冲区中一个字节的裸指针。
- 第 7 行的 ``read_size`` 用来维护实际有多少字节从管道读入应用的缓冲区。
- ``File::read`` 的语义是要从文件中最多读取应用缓冲区大小那么多字符。这可能超出了循环队列的大小,或者由于尚未有进程从管道的写端写入足够的字符,因此我们需要将整个读取的过程放在一个循环中,当循环队列中不存在足够字符的时候暂时进行任务切换,等待循环队列中的字符得到补充之后再继续读取。
这个循环从第 8 行开始,第 10 行我们用 ``loop_read`` 来保存循环这一轮次中可以从管道循环队列中读取多少字符。如果管道为空则会检查管道的所有写端是否都已经被关闭,如果是的话,说明我们已经没有任何字符可以读取了,这时可以直接返回;否则我们需要等管道的字符得到填充之后再继续读取,因此我们调用 ``suspend_current_and_run_next`` 切换到其他任务,等到切换回来之后回到循环开头再看一下管道中是否有字符了。在调用之前我们需要手动释放管道自身的锁,因为切换任务时候的 ``__switch`` 并不是一个正常的函数调用。
如果 ``loop_read`` 不为 0 ,在这一轮次中管道中就有 ``loop_read`` 个字节可以读取。我们可以迭代应用缓冲区中的每个字节指针并调用 ``PipeRingBuffer::read_byte`` 方法来从管道中进行读取。如果这 ``loop_read`` 个字节均被读取之后还没有填满应用缓冲区就需要进入循环的下一个轮次,否则就可以直接返回了。
``Pipe````write`` 方法——即通过管道的写端向管道中写入数据的实现和 ``read`` 的原理类似,篇幅所限在这里不再赘述,感兴趣的读者可自行参考其实现。
小结
--------------------------------------------
这一章讲述的重点是一种有趣的进程间通信的机制--管道。通过管道,能够把不同进程的输入和输出连接在一起,实现进程功能的组合。为了能够统一表示输入,输出,以及管道,我们给出了与 **地址空间****进程** 齐名的操作系统抽象 **文件** ,并基于文件重构了操作系统的输入/输出机制。目前仅仅实现了非常简单的基于父子进程的管道机制。在操作系统层面还缺乏对命令行参数的支持在应用层面还缺少I/O重定向和shell程序中基于 "|" 管道符号的支持。但我们已经建立了基本的进程通信机制。
在下面一章我们将在操作系统中实现支持数据持久化存储的文件系统形成更完成的文件机制并进步改进执行进程的系统调用支持进程执行的命令行参数。在应用程序的层面完善I/O重定向并在shell中支持基于 "|" 管道符号形成更加灵活的进程间通信能力和shell命令行支持。
由于父子进程的fd列表一致可以直接使用创建好的pipe进行通信。

View File

@ -1,531 +0,0 @@
在内核中使用 easy-fs
===============================================
本节导读
-----------------------------------------------
上节实现了 ``easy-fs`` 文件系统,并能在用户态来进行测试,但还没有放入到内核中来。本节我们介绍如何将 ``easy-fs`` 文件系统接入内核中从而在内核中支持常规文件和目录。为此,在操作系统内核中需要有对接 ``easy-fs`` 文件系统的各种结构,它们自下而上可以分成这样几个层次:
- 块设备驱动层。针对内核所要运行在的 qemu 或 k210 平台,我们需要将平台上的块设备驱动起来并实现 ``easy-fs`` 所需的 ``BlockDevice`` Trait ,这样 ``easy-fs`` 才能将该块设备用作 easy-fs 镜像的载体。
- ``easy-fs`` 层。我们在上一节已经介绍了 ``easy-fs`` 文件系统内部的层次划分。这里是站在内核的角度,只需知道它接受一个块设备 ``BlockDevice`` ,并可以在上面打开文件系统 ``EasyFileSystem`` ,进而获取 ``Inode`` 核心数据结构,进行各种文件系统操作即可。
- 内核索引节点层。在内核中需要将 ``easy-fs`` 提供的 ``Inode`` 进一步封装成 ``OSInode`` ,以表示进程中一个打开的常规文件。由于有很多种不同的打开方式,因此在 ``OSInode`` 中要维护一些额外的信息。
- 文件描述符层。常规文件对应的 ``OSInode`` 是文件的内核内部表示,因此需要为它实现 ``File`` Trait 从而能够可以将它放入到进程文件描述符表中并通过 ``sys_read/write`` 系统调用进行读写。
- 系统调用层。由于引入了常规文件这种文件类型,导致一些系统调用以及相关的内核机制需要进行一定的修改。
块设备驱动层
-----------------------------------------------
``drivers`` 子模块中的 ``block/mod.rs`` 中,我们可以找到内核访问的块设备实例 ``BLOCK_DEVICE``
.. code-block:: rust
// os/drivers/block/mod.rs
#[cfg(feature = "board_qemu")]
type BlockDeviceImpl = virtio_blk::VirtIOBlock;
#[cfg(feature = "board_k210")]
type BlockDeviceImpl = sdcard::SDCardWrapper;
lazy_static! {
pub static ref BLOCK_DEVICE: Arc<dyn BlockDevice> = Arc::new(BlockDeviceImpl::new());
}
qemu 和 k210 平台上的块设备是不同的。在 qemu 上,我们使用 ``VirtIOBlock`` 访问 VirtIO 块设备;而在 k210 上,我们使用 ``SDCardWrapper`` 来访问插入 k210 开发板上真实的 microSD 卡,它们都实现了 ``easy-fs`` 要求的 ``BlockDevice`` Trait 。通过 ``#[cfg(feature)]`` 可以在编译的时候根据编译参数调整 ``BlockDeviceImpl`` 具体为哪个块设备,之后将它全局实例化为 ``BLOCK_DEVICE`` 使得内核的其他模块可以访问。
Qemu 模拟器平台
+++++++++++++++++++++++++++++++++++++++++++++++
在启动 Qemu 模拟器的时候,我们可以配置参数来添加一块 VirtIO 块设备:
.. code-block:: makefile
:linenos:
:emphasize-lines: 12-13
# os/Makefile
FS_IMG := ../user/target/$(TARGET)/$(MODE)/fs.img
run-inner: build
ifeq ($(BOARD),qemu)
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \
-drive file=$(FS_IMG),if=none,format=raw,id=x0 \
-device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
- 第 12 行,我们为虚拟机添加一块虚拟硬盘,内容为我们之前通过 ``easy-fs-fuse`` 工具打包的包含应用 ELF 的 easy-fs 镜像,并命名为 ``x0``
- 第 13 行,我们将硬盘 ``x0`` 作为一个 VirtIO 总线中的一个块设备接入到虚拟机系统中。 ``virtio-mmio-bus.0`` 表示 VirtIO 总线通过 MMIO 进行控制,且该块设备在总线中的编号为 0 。
**内存映射 I/O** (MMIO, Memory-Mapped I/O) 指的是外设的设备寄存器可以通过特定的物理内存地址来访问每个外设的设备寄存器都分布在没有交集的一个或数个物理地址区间中不同外设的设备寄存器所占的物理地址空间也不会产生交集且这些外设物理地址区间也不会和RAM的物理内存所在的区间存在交集。从 RV64 平台 Qemu 的 `源码 <https://github.com/qemu/qemu/blob/master/hw/riscv/virt.c#L58>`_ 中可以找到 VirtIO 总线的 MMIO 物理地址区间为从 0x10001000 开头的 4KiB 。为了能够在内核中访问 VirtIO 总线,我们就必须在内核地址空间中对特定内存区域提前进行映射:
.. code-block:: rust
// os/src/config.rs
#[cfg(feature = "board_qemu")]
pub const MMIO: &[(usize, usize)] = &[
(0x10001000, 0x1000),
];
如上面一段代码所示,在 ``config`` 子模块中我们硬编码 Qemu 上的 VirtIO 总线的 MMIO 地址区间(起始地址,长度)。在创建内核地址空间的时候需要建立页表映射:
.. code-block:: rust
// os/src/mm/memory_set.rs
use crate::config::MMIO;
impl MemorySet {
/// Without kernel stacks.
pub fn new_kernel() -> Self {
...
println!("mapping memory-mapped registers");
for pair in MMIO {
memory_set.push(MapArea::new(
(*pair).0.into(),
((*pair).0 + (*pair).1).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
}
memory_set
}
}
这里我们进行的是透明的恒等映射,从而让内核可以兼容于直接访问物理地址的设备驱动库。
由于设备驱动的开发过程比较琐碎,我们这里直接使用已有的 `virtio-drivers <https://github.com/rcore-os/virtio-drivers>`_ crate ,它已经支持 VirtIO 总线架构下的块设备、网络设备、GPU 等设备。关于VirtIO 相关驱动的内容,将放在后续章节中介绍。
.. code-block:: rust
// os/src/drivers/block/virtio_blk.rs
use virtio_drivers::{VirtIOBlk, VirtIOHeader};
const VIRTIO0: usize = 0x10001000;
pub struct VirtIOBlock(Mutex<VirtIOBlk<'static>>);
impl VirtIOBlock {
pub fn new() -> Self {
Self(Mutex::new(VirtIOBlk::new(
unsafe { &mut *(VIRTIO0 as *mut VirtIOHeader) }
).unwrap()))
}
}
impl BlockDevice for VirtIOBlock {
fn read_block(&self, block_id: usize, buf: &mut [u8]) {
self.0.lock().read_block(block_id, buf).expect("Error when reading VirtIOBlk");
}
fn write_block(&self, block_id: usize, buf: &[u8]) {
self.0.lock().write_block(block_id, buf).expect("Error when writing VirtIOBlk");
}
}
上面的代码中,我们将 ``virtio-drivers`` crate 提供的 VirtIO 块设备抽象 ``VirtIOBlk`` 包装为我们自己的 ``VirtIOBlock`` ,实质上只是加上了一层互斥锁,生成一个新的类型来实现 ``easy-fs`` 需要的 ``BlockDevice`` Trait 。注意在 ``VirtIOBlk::new`` 的时候需要传入一个 ``&mut VirtIOHeader`` 的参数, ``VirtIOHeader`` 实际上就代表以 MMIO 方式访问 VirtIO 设备所需的一组设备寄存器。因此我们从 ``qemu-system-riscv64`` 平台上的 Virtio MMIO 区间左端 ``VIRTIO0`` 开始转化为一个 ``&mut VirtIOHeader`` 就可以在该平台上访问这些设备寄存器了。
很容易为 ``VirtIOBlock`` 实现 ``BlockDevice`` Trait ,因为它内部来自 ``virtio-drivers`` crate 的 ``VirtIOBlk`` 类型已经实现了 ``read/write_block`` 方法,我们进行转发即可。
VirtIO 设备需要占用部分内存作为一个公共区域从而更好的和 CPU 进行合作。这就像 MMU 需要在内存中保存多级页表才能和 CPU 共同实现分页机制一样。在 VirtIO 架构下,需要在公共区域中放置一种叫做 VirtQueue 的环形队列CPU 可以向此环形队列中向 VirtIO 设备提交请求,也可以从队列中取得请求的结果,详情可以参考 `virtio 文档 <https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.pdf>`_ 。对于 VirtQueue 的使用涉及到物理内存的分配和回收,但这并不在 VirtIO 驱动 ``virtio-drivers`` 的职责范围之内,因此它声明了数个相关的接口,需要库的使用者自己来实现:
.. code-block:: rust
// https://github.com/rcore-os/virtio-drivers/blob/master/src/hal.rs#L57
extern "C" {
fn virtio_dma_alloc(pages: usize) -> PhysAddr;
fn virtio_dma_dealloc(paddr: PhysAddr, pages: usize) -> i32;
fn virtio_phys_to_virt(paddr: PhysAddr) -> VirtAddr;
fn virtio_virt_to_phys(vaddr: VirtAddr) -> PhysAddr;
}
由于我们已经实现了基于分页内存管理的地址空间,实现这些功能自然不在话下:
.. code-block:: rust
// os/src/drivers/block/virtio_blk.rs
lazy_static! {
static ref QUEUE_FRAMES: Mutex<Vec<FrameTracker>> = Mutex::new(Vec::new());
}
#[no_mangle]
pub extern "C" fn virtio_dma_alloc(pages: usize) -> PhysAddr {
let mut ppn_base = PhysPageNum(0);
for i in 0..pages {
let frame = frame_alloc().unwrap();
if i == 0 { ppn_base = frame.ppn; }
assert_eq!(frame.ppn.0, ppn_base.0 + i);
QUEUE_FRAMES.lock().push(frame);
}
ppn_base.into()
}
#[no_mangle]
pub extern "C" fn virtio_dma_dealloc(pa: PhysAddr, pages: usize) -> i32 {
let mut ppn_base: PhysPageNum = pa.into();
for _ in 0..pages {
frame_dealloc(ppn_base);
ppn_base.step();
}
0
}
#[no_mangle]
pub extern "C" fn virtio_phys_to_virt(paddr: PhysAddr) -> VirtAddr {
VirtAddr(paddr.0)
}
#[no_mangle]
pub extern "C" fn virtio_virt_to_phys(vaddr: VirtAddr) -> PhysAddr {
PageTable::from_token(kernel_token()).translate_va(vaddr).unwrap()
}
这里有一些细节需要注意:
- ``virtio_dma_alloc/dealloc`` 需要分配/回收数个 *连续* 的物理页帧,而我们的 ``frame_alloc`` 是逐个分配,严格来说并不保证分配的连续性。幸运的是,这个过程只会发生在内核初始化阶段,因此能够保证连续性。
- 在 ``virtio_dma_alloc`` 中通过 ``frame_alloc`` 得到的那些物理页帧 ``FrameTracker`` 都会被保存在全局的向量 ``QUEUE_FRAMES`` 以延长它们的生命周期,避免提前被回收。
K210 真实硬件平台
+++++++++++++++++++++++++++++++++++++++++++++++
在 K210 开发板上,我们可以插入 microSD 卡并将其作为块设备。相比 VirtIO 块设备来说,想要将 microSD 驱动起来是一件比较困难的事情。microSD 自身的通信规范比较复杂,且还需考虑在 K210 中microSD挂在 **串行外设接口** (SPI, Serial Peripheral Interface) 总线上的情况。此外还需要正确设置 GPIO 的管脚映射并调整各锁相环的频率。实际上,在一块小小的芯片中除了 K210 CPU 之外,还集成了很多不同种类的外设和控制模块,它们内在的关联比较紧密,不能像 VirtIO 设备那样容易地从系统中独立出来。
好在目前 Rust 嵌入式的生态正高速发展,针对 K210 平台也有比较成熟的封装了各类外设接口的库可以用来开发上层应用。但是其功能往往分散为多个 crate ,在使用的时候需要开发者根据需求自行进行组装。这属于 Rust 的特点之一,和 C 语言提供一个一站式的板级开发包风格有很大的不同。在开发的时候,笔者就从社区中选择了一些 crate 并进行了微量修改最终变成 ``k210-hal/k210-pac/k210-soc`` 三个能够运行在 S 特权级(它们的原身仅支持运行在 M 特权级)的 crate ,它们可以更加便捷的实现 microSD 的驱动。关于 microSD 的驱动 ``SDCardWrapper`` 的实现,有兴趣的读者可以参考 ``os/src/drivers/block/sdcard.rs``
.. note::
**感谢相关 crate 的原身**
- `k210-hal <https://github.com/riscv-rust/k210-hal>`_
- `k210-pac <https://github.com/riscv-rust/k210-pac>`_
- `k210-sdk-stuff <https://github.com/laanwj/k210-sdk-stuff>`_
要在 K210 上启用 microSD ,执行的时候无需任何改动,只需在 ``make run`` 之前将 microSD 插入 PC 再通过 ``make sdcard`` 将 easy-fs 镜像烧写进去即可。而后,将 microSD 插入 K210 开发板,连接到 PC 再 ``make run``
在对 microSD 进行操作的时候,会涉及到 K210 内置的各种外设,正所谓”牵一发而动全身“。因此 K210 平台上的 MMIO 包含很多区间:
.. code-block:: rust
// os/src/config.rs
#[cfg(feature = "board_k210")]
pub const MMIO: &[(usize, usize)] = &[
// we don't need clint in S priv when running
// we only need claim/complete for target0 after initializing
(0x0C00_0000, 0x3000), /* PLIC */
(0x0C20_0000, 0x1000), /* PLIC */
(0x3800_0000, 0x1000), /* UARTHS */
(0x3800_1000, 0x1000), /* GPIOHS */
(0x5020_0000, 0x1000), /* GPIO */
(0x5024_0000, 0x1000), /* SPI_SLAVE */
(0x502B_0000, 0x1000), /* FPIOA */
(0x502D_0000, 0x1000), /* TIMER0 */
(0x502E_0000, 0x1000), /* TIMER1 */
(0x502F_0000, 0x1000), /* TIMER2 */
(0x5044_0000, 0x1000), /* SYSCTL */
(0x5200_0000, 0x1000), /* SPI0 */
(0x5300_0000, 0x1000), /* SPI1 */
(0x5400_0000, 0x1000), /* SPI2 */
];
内核索引节点层
-----------------------------------------------
在本章的第一小节我们介绍过,站在用户的角度看来,在一个进程中可以使用多种不同的标志来打开一个文件,这会影响到打开的这个文件可以用何种方式被访问。此外,在连续调用 ``sys_read/write`` 读写一个文件的时候,我们知道进程中也存在着一个文件读写的当前偏移量,它也随着文件读写的进行而被不断更新。这些用户视角中的文件系统抽象特征需要内核来实现,与进程有很大的关系,而 ``easy-fs`` 文件系统不必涉及这些与进程结合紧密的属性。因此,我们需要将 ``easy-fs`` 提供的 ``Inode`` 加上上述信息,进一步封装为 OS 中的索引节点 ``OSInode``
.. code-block:: rust
// os/src/fs/inode.rs
pub struct OSInode {
readable: bool,
writable: bool,
inner: Mutex<OSInodeInner>,
}
pub struct OSInodeInner {
offset: usize,
inode: Arc<Inode>,
}
impl OSInode {
pub fn new(
readable: bool,
writable: bool,
inode: Arc<Inode>,
) -> Self {
Self {
readable,
writable,
inner: Mutex::new(OSInodeInner {
offset: 0,
inode,
}),
}
}
}
``OSInode`` 就表示进程中一个被打开的常规文件或目录。 ``readable/writable`` 分别表明该文件是否允许通过 ``sys_read/write`` 进行读写。至于在 ``sys_read/write`` 期间被维护偏移量 ``offset`` 和它在 ``easy-fs`` 中的 ``Inode`` 则加上一把互斥锁丢到 ``OSInodeInner`` 中。这在提供内部可变性的同时,也可以简单应对多个进程同时读写一个文件的情况。
文件描述符层
-----------------------------------------------
因为 ``OSInode`` 也是要一种要放到进程文件描述符表中,并通过 ``sys_read/write`` 系统调用进行读写的文件,因此我们也需要为它实现 ``File`` Trait
.. code-block:: rust
// os/src/fs/inode.rs
impl File for OSInode {
fn readable(&self) -> bool { self.readable }
fn writable(&self) -> bool { self.writable }
fn read(&self, mut buf: UserBuffer) -> usize {
let mut inner = self.inner.lock();
let mut total_read_size = 0usize;
for slice in buf.buffers.iter_mut() {
let read_size = inner.inode.read_at(inner.offset, *slice);
if read_size == 0 {
break;
}
inner.offset += read_size;
total_read_size += read_size;
}
total_read_size
}
fn write(&self, buf: UserBuffer) -> usize {
let mut inner = self.inner.lock();
let mut total_write_size = 0usize;
for slice in buf.buffers.iter() {
let write_size = inner.inode.write_at(inner.offset, *slice);
assert_eq!(write_size, slice.len());
inner.offset += write_size;
total_write_size += write_size;
}
total_write_size
}
}
本章我们为 ``File`` Trait 新增了 ``readable/writable`` 两个抽象接口从而在 ``sys_read/sys_write`` 的时候进行简单的访问权限检查。 ``read/write`` 的实现也比较简单,只需遍历 ``UserBuffer`` 中的每个缓冲区片段,调用 ``Inode`` 写好的 ``read/write_at`` 接口就好了。注意 ``read/write_at`` 的起始位置是在 ``OSInode`` 中维护的 ``offset`` ,这个 ``offset`` 也随着遍历的进行被持续更新。在 ``read/write`` 的全程需要获取 ``OSInode`` 的互斥锁,保证两个进程无法同时访问同个文件。
文件系统相关内核机制实现
-----------------------------------------------
文件系统初始化
+++++++++++++++++++++++++++++++++++++++++++++++
在上一小节我们介绍过,为了使用 ``easy-fs`` 提供的抽象,我们需要进行一些初始化操作才能成功将 ``easy-fs`` 接入到我们的内核中。按照前面总结的步骤:
1. 打开块设备。从本节前面可以看出,我们已经打开并可以访问装载有 easy-fs 文件系统镜像的块设备 ``BLOCK_DEVICE``
2. 从块设备 ``BLOCK_DEVICE`` 上打开文件系统。
3. 从文件系统中获取根目录的 inode 。
2-3 步我们在这里完成:
.. code-block:: rust
// os/src/fs/inode.rs
lazy_static! {
pub static ref ROOT_INODE: Arc<Inode> = {
let efs = EasyFileSystem::open(BLOCK_DEVICE.clone());
Arc::new(EasyFileSystem::root_inode(&efs))
};
}
这之后就可以使用根目录的 inode ``ROOT_INODE`` ,在内核中进行各种 ``easy-fs`` 的相关操作了。例如,在文件系统初始化完毕之后,在内核主函数 ``rust_main`` 中调用 ``list_apps`` 函数来列举文件系统中可用的应用的文件名:
.. code-block:: rust
// os/src/fs/inode.rs
pub fn list_apps() {
println!("/**** APPS ****");
for app in ROOT_INODE.ls() {
println!("{}", app);
}
println!("**************/")
}
通过 sys_open 打开文件
+++++++++++++++++++++++++++++++++++++++++++++++
我们需要在内核中也定义一份打开文件的标志 ``OpenFlags``
.. code-block:: rust
// os/src/fs/inode.rs
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}
impl OpenFlags {
/// Do not check validity for simplicity
/// Return (readable, writable)
pub fn read_write(&self) -> (bool, bool) {
if self.is_empty() {
(true, false)
} else if self.contains(Self::WRONLY) {
(false, true)
} else {
(true, true)
}
}
}
它的 ``read_write`` 方法可以根据标志的情况返回要打开的文件是否允许读写。简单起见,这里假设标志自身一定合法。
接着,我们实现 ``open_file`` 内核函数,可根据文件名打开一个根目录下的文件:
.. code-block:: rust
// os/src/fs/inode.rs
pub fn open_file(name: &str, flags: OpenFlags) -> Option<Arc<OSInode>> {
let (readable, writable) = flags.read_write();
if flags.contains(OpenFlags::CREATE) {
if let Some(inode) = ROOT_INODE.find(name) {
// clear size
inode.clear();
Some(Arc::new(OSInode::new(
readable,
writable,
inode,
)))
} else {
// create file
ROOT_INODE.create(name)
.map(|inode| {
Arc::new(OSInode::new(
readable,
writable,
inode,
))
})
}
} else {
ROOT_INODE.find(name)
.map(|inode| {
if flags.contains(OpenFlags::TRUNC) {
inode.clear();
}
Arc::new(OSInode::new(
readable,
writable,
inode
))
})
}
}
这里主要是实现了 ``OpenFlags`` 各标志位的语义。例如只有 ``flags`` 参数包含 `CREATE` 标志位才允许创建文件;而如果文件已经存在,则清空文件的内容。另外我们将从 ``OpenFlags`` 解析得到的读写相关权限传入 ``OSInode`` 的创建过程中。
在其基础上, ``sys_open`` 也就很容易实现了:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_open(path: *const u8, flags: u32) -> isize {
let task = current_task().unwrap();
let token = current_user_token();
let path = translated_str(token, path);
if let Some(inode) = open_file(
path.as_str(),
OpenFlags::from_bits(flags).unwrap()
) {
let mut inner = task.acquire_inner_lock();
let fd = inner.alloc_fd();
inner.fd_table[fd] = Some(inode);
fd as isize
} else {
-1
}
}
通过 sys_exec 加载并执行应用
+++++++++++++++++++++++++++++++++++++++++++++++
在有了文件系统支持之后,我们在 ``sys_exec`` 所需的表示应用的 ELF 文件格式的数据就不再需要通过应用加载器从内核的数据段获取,而是从文件系统中获取即可:
.. code-block:: rust
:linenos:
:emphasize-lines: 15-24
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
let mut args_vec: Vec<String> = Vec::new();
loop {
let arg_str_ptr = *translated_ref(token, args);
if arg_str_ptr == 0 {
break;
}
args_vec.push(translated_str(token, arg_str_ptr as *const u8));
unsafe { args = args.add(1); }
}
if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
let all_data = app_inode.read_all();
let task = current_task().unwrap();
let argc = args_vec.len();
task.exec(all_data.as_slice(), args_vec);
// return argc because cx.x[10] will be covered with it later
argc as isize
} else {
-1
}
}
注意上面代码片段中的高亮部分。当执行获取应用的 ELF 数据的操作时,首先调用 ``open_file`` 函数,以只读的方式在内核中打开应用文件并获取它对应的 ``OSInode`` 。接下来可以通过 ``OSInode::read_all`` 将该文件的数据全部读到一个向量 ``all_data`` 中:
.. code-block:: rust
// os/src/fs/inode.rs
impl OSInode {
pub fn read_all(&self) -> Vec<u8> {
let mut inner = self.inner.lock();
let mut buffer = [0u8; 512];
let mut v: Vec<u8> = Vec::new();
loop {
let len = inner.inode.read_at(inner.offset, &mut buffer);
if len == 0 {
break;
}
inner.offset += len;
v.extend_from_slice(&buffer[..len]);
}
v
}
}
之后,就可以从向量 ``all_data`` 中拿到应用中的 ELF 数据,当解析完毕并创建完应用地址空间后该向量将会被回收。
同样的,我们在内核中创建初始进程 ``initproc`` 也需要替换为基于文件系统的实现:
.. code-block:: rust
// os/src/task/mod.rs
lazy_static! {
pub static ref INITPROC: Arc<TaskControlBlock> = Arc::new({
let inode = open_file("initproc", OpenFlags::RDONLY).unwrap();
let v = inode.read_all();
TaskControlBlock::new(v.as_slice())
});
}

View File

@ -1,483 +0,0 @@
命令行参数与标准 I/O 重定向
=================================================
本节导读
-------------------------------------------------
之前我们已经支持从文件系统中加载应用还实现了文件的创建和读写。但是目前我们在应用中只能硬编码要操作的文件这就使得应用的功能大大受限shell程序对于文件的交互访问能力也很弱。为了解决这些问题我们需要在shell程序和内核中支持命令行参数的解析和传递还有标准 I/O 重定向功能。之后我们便可以在shell程序执行应用的时候通过调整命令手动将应用的输入和输出从标准输入输出替换为某个特定文件还实现了命令行工具 ``cat`` 来查看一个文件的内容。
命令行参数
-------------------------------------------------
在使用 C/C++ 语言开发 Linux 应用的时候,我们可以使用标准库提供的 ``argc/argv`` 来获取命令行参数,它们是直接被作为参数传给 ``main`` 函数的。下面来看一个打印命令行参数的例子:
.. code-block:: c
// a.c
#include <stdio.h>
int main(int argc, char* argv[]) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
其中 ``argc`` 表示命令行参数的个数,而 ``argv`` 是一个长度为 ``argc`` 的字符串数组,数组中的每个字符串都是一个命令行参数。我们可以在 Linux 系统上运行这个程序:
.. code-block:: console
$ gcc a.c -oa -g -Wall
$ ./a aa bb 11 22 cc
argc = 6
argv[0] = ./a
argv[1] = aa
argv[2] = bb
argv[3] = 11
argv[4] = 22
argv[5] = cc
为了支持后续的一些功能我们希望在我们自己的内核和shell程序上支持这个功能。为了对实现正确性进行测试在本章中我们编写了一个名为 ``cmdline_args`` 的应用,它是用 Rust 编写的,并只能在我们的内核上执行,但是它的功能是和 ``a.c`` 保持一致的。我们可以在我们的内核上运行该应用来看看效果:
.. code-block::
Rust user shell
>> cmdline_args aa bb 11 22 cc
argc = 6
argv[0] = cmdline_args
argv[1] = aa
argv[2] = bb
argv[3] = 11
argv[4] = 22
argv[5] = cc
Shell: Process 2 exited with code 0
>>
可以看到二者的输出是基本相同的。
但是,要实现这个看似简单的功能,需要内核和用户态的共同努力。为了支持命令行参数, ``sys_exec`` 的系统调用接口需要发生变化:
.. code-block:: rust
// user/src/syscall.rs
pub fn sys_exec(path: &str, args: &[*const u8]) -> isize;
可以看到,它的参数多出了一个 ``args`` 数组,数组中的每个元素都是一个命令行参数字符串的起始地址。由于我们是以引用的形式传递这个数组,实际传递给内核的实际上是这个数组的起始地址:
.. code-block:: rust
// user/src/syscall.rs
pub fn sys_exec(path: &str, args: &[*const u8]) -> isize {
syscall(SYSCALL_EXEC, [path.as_ptr() as usize, args.as_ptr() as usize, 0])
}
// user/src/lib.rs
pub fn exec(path: &str, args: &[*const u8]) -> isize { sys_exec(path, args) }
接下来我们分析一下,一行带有命令行参数的命令从输入到它的命令行参数被打印出来中间经历了哪些过程。
shell程序的命令行参数分割
+++++++++++++++++++++++++++++++++++++++++++++++++
回忆一下之前在shell程序 ``user_shell`` 中,一旦接收到一个回车,我们就会将当前行的内容 ``line`` 作为一个名字并试图去执行同名的应用。但是现在 ``line`` 还可能包含一些命令行参数,只有最开头的一个才是要执行的应用名。因此我们要做的第一件事情就是将 ``line`` 用空格进行分割:
.. code-block:: rust
// user/src/bin/user_shell.rs
let args: Vec<_> = line.as_str().split(' ').collect();
let mut args_copy: Vec<String> = args
.iter()
.map(|&arg| {
let mut string = String::new();
string.push_str(arg);
string
})
.collect();
args_copy
.iter_mut()
.for_each(|string| {
string.push('\0');
});
经过分割, ``args`` 中的 ``&str`` 都是 ``line`` 中的一段子区间,它们的结尾并没有包含 ``\0`` ,因为 ``line`` 是我们输入得到的,中间本来就没有 ``\0`` 。由于在向内核传入字符串的时候,我们只能传入字符串的起始地址,因此我们必须保证其结尾为 ``\0`` 。从而我们用 ``args_copy````args`` 中的字符串拷贝一份到堆上并在末尾手动加入 ``\0`` 。这样就可以安心的将 ``args_copy`` 中的字符串传入内核了。我们用 ``args_addr`` 来收集这些字符串的起始地址:
.. code-block:: rust
// user/src/bin/user_shell.rs
let mut args_addr: Vec<*const u8> = args_copy
.iter()
.map(|arg| arg.as_ptr())
.collect();
args_addr.push(0 as *const u8);
向量 ``args_addr`` 中的每个元素都代表一个命令行参数字符串的起始地址。由于我们要传递给内核的是这个向量的起始地址,为了让内核能够获取到命令行参数的个数,我们需要在 ``args_addr`` 的末尾放入一个 0 ,这样内核看到它的时候就能知道命令行参数已经获取完毕了。
``fork`` 出来的子进程里面我们需要这样执行应用:
.. code-block:: rust
// user/src/bin/user_shell.rs
// child process
if exec(args_copy[0].as_str(), args_addr.as_slice()) == -1 {
println!("Error when executing!");
return -4;
}
sys_exec 将命令行参数压入用户栈
+++++++++++++++++++++++++++++++++++++++++++++++++
``sys_exec`` 中,首先需要将应用传进来的命令行参数取出来:
.. code-block:: rust
:linenos:
:emphasize-lines: 6-14,19
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
let mut args_vec: Vec<String> = Vec::new();
loop {
let arg_str_ptr = *translated_ref(token, args);
if arg_str_ptr == 0 {
break;
}
args_vec.push(translated_str(token, arg_str_ptr as *const u8));
unsafe { args = args.add(1); }
}
if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
let all_data = app_inode.read_all();
let task = current_task().unwrap();
let argc = args_vec.len();
task.exec(all_data.as_slice(), args_vec);
// return argc because cx.x[10] will be covered with it later
argc as isize
} else {
-1
}
}
这里的 ``args`` 指向命令行参数字符串起始地址数组中的一个位置,每次我们都可以从一个起始地址通过 ``translated_str`` 拿到一个字符串,直到 ``args`` 为 0 就说明没有更多命令行参数了。在第 19 行调用 ``TaskControlBlock::exec`` 的时候,我们需要将获取到的 ``args_vec`` 传入进去并将里面的字符串压入到用户栈上。
.. code-block:: rust
:linenos:
:emphasize-lines: 11-34,45,50,51
// os/src/task/task.rs
impl TaskControlBlock {
pub fn exec(&self, elf_data: &[u8], args: Vec<String>) {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, mut user_sp, entry_point) = MemorySet::from_elf(elf_data);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
// push arguments on user stack
user_sp -= (args.len() + 1) * core::mem::size_of::<usize>();
let argv_base = user_sp;
let mut argv: Vec<_> = (0..=args.len())
.map(|arg| {
translated_refmut(
memory_set.token(),
(argv_base + arg * core::mem::size_of::<usize>()) as *mut usize
)
})
.collect();
*argv[args.len()] = 0;
for i in 0..args.len() {
user_sp -= args[i].len() + 1;
*argv[i] = user_sp;
let mut p = user_sp;
for c in args[i].as_bytes() {
*translated_refmut(memory_set.token(), p as *mut u8) = *c;
p += 1;
}
*translated_refmut(memory_set.token(), p as *mut u8) = 0;
}
// make the user_sp aligned to 8B for k210 platform
user_sp -= user_sp % core::mem::size_of::<usize>();
// **** 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 mut trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.lock().token(),
self.kernel_stack.get_top(),
trap_handler as usize,
);
trap_cx.x[10] = args.len();
trap_cx.x[11] = argv_base;
*inner.get_trap_cx() = trap_cx;
// **** release current PCB lock
}
}
第 11-34 行所做的主要工作是将命令行参数以某种格式压入用户栈。具体的格式可以参考下图(比如应用传入了两个命令行参数 ``aa````bb``
.. image:: user-stack-cmdargs.png
:align: center
- 首先需要在用户栈上分配一个字符串指针数组,也就是蓝色区域。数组中的每个元素都指向一个用户栈更低处的命令行参数字符串的起始地址。在第 12~24 行可以看到,最开始我们只是分配空间,具体的值要等到字符串被放到用户栈上之后才能确定更新。
- 第 23~32 行,我们逐个将传入的 ``args`` 中的字符串压入到用户栈中,对应于图中的橙色区域。为了实现方便,我们在用户栈上预留空间之后逐字节进行复制。注意 ``args`` 中的字符串是通过 ``translated_str`` 从应用地址空间取出的,它的末尾不包含 ``\0`` 。为了应用能知道每个字符串的长度,我们需要手动在末尾加入 ``\0``
- 第 34 行将 ``user_sp`` 以 8 字节对齐,即图中的绿色区域。这是因为命令行参数的长度不一,很有可能压入之后 ``user_sp`` 没有对齐到 8 字节,那么在 K210 平台上在访问用户栈的时候就会触发访存不对齐的异常。在 Qemu 平台上则并不存在这个问题。
我们还需要对应修改 Trap 上下文。首先是第 45 行,我们的 ``user_sp`` 相比之前已经发生了变化,它上面已经压入了命令行参数。同时,我们还需要修改 Trap 上下文中的 ``a0/a1`` 寄存器,让 ``a0`` 表示命令行参数的个数,而 ``a1`` 则表示图中 ``argv_base`` 即蓝色区域的起始地址。这两个参数在第一次进入对应应用的用户态的时候会被接收并用于还原命令行参数。
用户库从用户栈上还原命令行参数
+++++++++++++++++++++++++++++++++++++++++++++++++
在应用第一次进入用户态的时候,我们放在 Trap 上下文 a0/a1 两个寄存器中的内容可以被用户库中的入口函数以参数的形式接收:
.. code-block:: rust
:linenos:
:emphasize-lines: 10-24
// user/src/lib.rs
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start(argc: usize, argv: usize) -> ! {
unsafe {
HEAP.lock()
.init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE);
}
let mut v: Vec<&'static str> = Vec::new();
for i in 0..argc {
let str_start = unsafe {
((argv + i * core::mem::size_of::<usize>()) as *const usize).read_volatile()
};
let len = (0usize..).find(|i| unsafe {
((str_start + *i) as *const u8).read_volatile() == 0
}).unwrap();
v.push(
core::str::from_utf8(unsafe {
core::slice::from_raw_parts(str_start as *const u8, len)
}).unwrap()
);
}
exit(main(argc, v.as_slice()));
}
可以看到,在入口 ``_start`` 中我们就接收到了命令行参数个数 ``argc`` 和字符串数组的起始地址 ``argv`` 。但是这个起始地址不太好用,我们希望能够将其转化为编写应用的时候看到的 ``&[&str]`` 的形式。转化的主体在第 10~23 行,就是分别取出 ``argc`` 个字符串的起始地址(基于字符串数组的 base 地址 ``argv`` ),从它向后找到第一个 ``\0`` 就可以得到一个完整的 ``&str`` 格式的命令行参数字符串并加入到向量 ``v`` 中。最后通过 ``v.as_slice`` 就得到了我们在 ``main`` 主函数中看到的 ``&[&str]``
通过命令行工具 cat 输出文件内容
+++++++++++++++++++++++++++++++++++++++++++++++++
有了之前的命令行参数支持,我们就可以编写命令行工具 ``cat`` 来输出指定文件的内容了。它的使用方法如下:
.. code-block::
>> filetest_simple
file_test passed!
Shell: Process 2 exited with code 0
>> cat filea
Hello, world!
Shell: Process 2 exited with code 0
>>
``filetest_simple`` 会将 ``Hello, world!`` 输出到文件 ``filea`` 中。之后我们就可以通过 ``cat filea`` 来打印文件 ``filea`` 中的内容。
``cat`` 本身也是一个应用,且很容易实现:
.. code-block:: rust
// user/src/bin/cat.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
extern crate alloc;
use user_lib::{
open,
OpenFlags,
close,
read,
};
use alloc::string::String;
#[no_mangle]
pub fn main(argc: usize, argv: &[&str]) -> i32 {
assert!(argc == 2);
let fd = open(argv[1], OpenFlags::RDONLY);
if fd == -1 {
panic!("Error occured when opening file");
}
let fd = fd as usize;
let mut buf = [0u8; 16];
let mut s = String::new();
loop {
let size = read(fd, &mut buf) as usize;
if size == 0 { break; }
s.push_str(core::str::from_utf8(&buf[..size]).unwrap());
}
println!("{}", s);
close(fd);
0
}
标准输入输出重定向
-------------------------------------------------
为了进一步增强shell程序使用文件系统时的灵活性我们需要新增标准输入输出重定向功能。这个功能在我们使用 Linux 内核的时候很常用,我们在自己的内核中举个例子:
.. code-block::
>> yield > fileb
Shell: Process 2 exited with code 0
>> cat fileb
Hello, I am process 2.
Back in process 2, iteration 0.
Back in process 2, iteration 1.
Back in process 2, iteration 2.
Back in process 2, iteration 3.
Back in process 2, iteration 4.
yield pass.
Shell: Process 2 exited with code 0
>>
通过 ``>`` 我们可以将应用 ``yield`` 的输出重定向到文件 ``fileb`` 中。我们也可以注意到在屏幕上暂时看不到 ``yield`` 的输出了。在应用 ``yield`` 退出之后,我们可以使用 ``cat`` 工具来查看文件 ``fileb`` 的内容,可以看到里面的确是 ``yield`` 的输出。同理,通过 ``<`` 则可以将一个应用的输入重定向到某个指定文件而不是从键盘输入。
注意重定向功能对于应用来说是透明的。在应用中除非明确指出了数据要从指定的文件输入或者输出到指定的文件,否则数据默认都是输入自进程文件描述表位置 0 (即 ``fd=0`` )处的标准输入,并输出到进程文件描述符表位置 1 (即 ``fd=1`` )处的标准输出。这是由于内核在执行 ``sys_exec`` 系统调用创建基于新应用的进程时,会直接把文件描述符表位置 0 放置标准输入文件,位置 1 放置标准输出文件,位置 2 放置标准错误输出文件。标准输入/输出文件其实是把设备当成文件,标准输入文件就是串口的输入或键盘,而标准输出文件就是串口的输出或显示器。
因此,在应用执行之前,我们就要对应用进程的文件描述符表进行某种替换。以输出为例,我们需要提前打开文件并用这个文件来替换掉应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的重定向。在重定向之后,应用认为自己输出到 ``fd=1`` 的标准输出文件,但实际上是输出到我们指定的文件中。我们能够做到这一点还是得益于文件的抽象,因为在进程看来无论是标准输出还是常规文件都是一种文件,可以通过同样的接口来读写。
为了实现重定向功能,我们需要引入一个新的系统调用 ``sys_dup``
.. code-block:: rust
// user/src/syscall.rs
/// 功能:将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。
/// 参数fd 表示进程中一个已经打开的文件的文件描述符。
/// 返回值:如果出现了错误则返回 -1否则能够访问已打开文件的新文件描述符。
/// 可能的错误原因是:传入的 fd 并不对应一个合法的已打开文件。
/// syscall ID24
pub fn sys_dup(fd: usize) -> isize;
这个系统调用的实现非常简单:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_dup(fd: usize) -> isize {
let task = current_task().unwrap();
let mut inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if inner.fd_table[fd].is_none() {
return -1;
}
let new_fd = inner.alloc_fd();
inner.fd_table[new_fd] = Some(Arc::clone(inner.fd_table[fd].as_ref().unwrap()));
new_fd as isize
}
``sys_dup`` 函数中,首先检查传入 ``fd`` 的合法性。然后在文件描述符表中分配一个新的文件描述符,并保存 ``fd`` 指向的已打开文件的一份拷贝即可。
那么我们应该在什么时候进行替换,又应该如何利用 ``sys_dup`` 进行替换呢?
答案是在shell程序 ``user_shell`` 中进行处理。在分割命令行参数的时候,我们要检查是否存在通过 ``<````>`` 进行输入输出重定向的情况,如果存在的话则需要将它们从命令行参数中移除,并记录匹配到的输入文件名或输出文件名到字符串 ``input````output`` 中。注意为了实现方便我们这里假设输入shell程序的命令一定合法``<````>`` 最多只会出现一次,且后面总是会有一个参数作为重定向到的文件。
.. code-block:: rust
// user/src/bin/user_shell.rs
// redirect input
let mut input = String::new();
if let Some((idx, _)) = args_copy
.iter()
.enumerate()
.find(|(_, arg)| arg.as_str() == "<\0") {
input = args_copy[idx + 1].clone();
args_copy.drain(idx..=idx + 1);
}
// redirect output
let mut output = String::new();
if let Some((idx, _)) = args_copy
.iter()
.enumerate()
.find(|(_, arg)| arg.as_str() == ">\0") {
output = args_copy[idx + 1].clone();
args_copy.drain(idx..=idx + 1);
}
打开文件和替换的过程则发生在 ``fork`` 之后的子进程分支中:
.. code-block:: rust
:linenos:
// user/src/bin/user_shell.rs
let pid = fork();
if pid == 0 {
// input redirection
if !input.is_empty() {
let input_fd = open(input.as_str(), OpenFlags::RDONLY);
if input_fd == -1 {
println!("Error when opening file {}", input);
return -4;
}
let input_fd = input_fd as usize;
close(0);
assert_eq!(dup(input_fd), 0);
close(input_fd);
}
// output redirection
if !output.is_empty() {
let output_fd = open(
output.as_str(),
OpenFlags::CREATE | OpenFlags::WRONLY
);
if output_fd == -1 {
println!("Error when opening file {}", output);
return -4;
}
let output_fd = output_fd as usize;
close(1);
assert_eq!(dup(output_fd), 1);
close(output_fd);
}
// child process
if exec(args_copy[0].as_str(), args_addr.as_slice()) == -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);
}
- 输入重定向发生在第 6~16 行。我们尝试打开输入文件 ``input````input_fd`` 中。之后,首先通过 ``close`` 关闭标准输入所在的文件描述符 0 。之后通过 ``dup`` 来分配一个新的文件描述符来访问 ``input_fd`` 对应的输入文件。这里用到了文件描述符分配的重要性质:即必定分配可用描述符中编号最小的一个。由于我们刚刚关闭了描述符 0 ,那么在 ``dup`` 的时候一定会将它分配出去,于是现在应用进程的文件描述符 0 就对应到输入文件了。最后,因为应用进程的后续执行不会用到输入文件原来的描述符 ``input_fd`` ,所以就将其关掉。
- 输出重定向则发生在 18~31 行。它的原理和输入重定向几乎完全一致,只是通过 ``open`` 打开文件的标志不太相同。
之后,就可以通过 ``exec`` 来执行应用了。
虽然 ``fork/exec/waitpid`` 三个经典的系统调用自它们于古老的 Unix 时代诞生以来已经过去了太长时间,从某种程度上来讲已经不太适合新的内核环境了。人们也已经提出了若干种替代品并已经在进行实践,比如 ``spawn`` 或者 Linux 上的 ``clone`` 系统调用。但是它们迄今为止仍然存在就证明在它们的设计中还能够找到可取之处。从本节介绍的重定向就可以看出它们的灵活性以及强大的功能性:我们能够进行重定向恰恰是因为执行应用分为 ``fork````exec`` 两个调用,那么在这两个调用之间我们就能够进行一些类似重定向的处理。在实现的过程中,我们还用到了 ``fork`` 出来的子进程会和父进程共享文件描述符表的性质。