Project rCore: 进程间通讯

管道、I/O 重定向和信号是怎么实现的

上一章《Project rCore: 文件系统》实现了一个简易的文件系统,提出一个对文件的抽象(aka File trait),同时实现了对文件操作所需要的基本的 syscalls。

本章主要介绍的是进程间通讯:(1)在上一章的基础上实现了管道,现在两个进程之间可以自由的传输数据了。(2)为了让管道更有用,还改善了 user_shell,现在用户程序有能力接受自定义参数,并实现了 I/O 重定向功能。(3)实现了信号机制,现在操作系统能够主动对指定的用户程序发送信息/通知了。

Let's dive in!

基于文件的标准输入/输出和 I/O 重定向

我们不得不再次搬出来上一章对 File trait 的定义,以及一个 UNIX 重要信条“一切皆文件”。

UNIX 对文件的抽象最核心的就是 readwrite 两个方法,反过来说,一切可读和可写的东西都可以被抽象为一个文件。在通过 readablewritable 两个方法来表明这个文件是“可读的”/还是”可写的“/还是”可读可写的“。

所谓的标准输入,我们站在更抽象一层的角度考虑:用户程序把字符“写”到屏幕上。标准输出则是用户从键盘中“读”字符。串口设备则是用户既可以从设备中读取数据,也可以写入数据到设备中。自然而然,标准输入和输出也就能在 File trait 的实现下重生。

但是具体如何实现的,这里我想再赘述了,因为包含很多与 SBI 的交互。

我想说的是 fd 是如何分配的问题,目前有三个标准:标准输入(stdin)、标准输出(stdout)和标准错误(stderr),这些“标准 XX”会在进程被创建的时候自动注册到 fd_table 中。

Name Fd
stdin 0
stdout 1
stderr 2

他们都是被硬编码到 fd_table 中的,因此其他的 fd 最低是从 3 开始的。如果我们把原来的 stdin 关闭,并且将 fd = 0 替换为一个文件,所有的输出将会写入到该文件中,那么我们就实现了“I/O 重定向”。

管道

管道可以简单的被理解为一个数据通道,数据从一个进程流向另一个进程。在实现上是在内存的某块区域内创建一个 buffer,并分别创建一个读端(read end)和写端(write end),写端就是向管道 buffer 写入数据,读端则是从缓存 buffer 中读取数据。整个流程如下图所示。

管道也必须以文件形式存在,也就是必须为管道实现 File trait。对于读端就是只实现 read 方法,让其从 ring buffer 中读数据,写端就只实现 write 方法,让其写数据到 ring buffer 就行了,似乎也没什么难的嘛。

这里有个反直觉的问题:为什么必须要搞一个读端和一个写端,而不能一个 fd 就搞定读和写的事情呢?我觉得一个 fd 搞定这个事情本身在实现层面上是能够实现的,但是这样做管道就变成了一个全双工模式,首先这不符合管道从一端流向另一端的模式。其次在内部只有一个 ring buffer,两端同时写数据就会发生错乱。综上,读端和写端分离能避免很多必要的问题。

当我们在 shell 中执行 ls | grep hello 的时候,管道是如何被创建出来的?

这里就不得不说当一个进程调用 fork 系统调用的时候,当前进程的 fd_table 将会被一一复制到子进程中。我们在执行 fork 之前创建了一个管道,假设读端和写端的 fd 分别是 3 和 4,那么新创建的子进程的 fd_table 的 3 和 4 也会保存一份管道的读端和写端。我们在父进程中关闭 fd 为 3 的读端,以及在子进程中关闭 fd 为 4 的写端。这样我们就能实现了“父进程 -> 子进程”的管道。

在实际使用中我们的程序都是在 shell 中启动的,这里如果想实现“ls -> grep”的管道,就不得不在 shell、ls 和 grep 三方之间复制 fd_table 和关闭不用的读端和写端,总体思路与上面基本一致,大家可以自行思考或者查考具体的实现方案,这里就不在赘述了。

最后我们在来说下管道和 I/O 重定向的结合。ls | grep hello 这个命令的意思是把 ls 的进程的输出作为 grep 的输入,只需要替换 ls 进程 fd 为 1 的文件(aka stdout)为管道的写端,替换 grep 进程 fd 为 2 的文件(aka stdin)为管道的读端。

信号

信号(signals)是操作系统或某进程希望能单方面通知另外一个正在忙其它事情的进程产生了某个事件,并让这个进程能迅速响应。比如我们在输入 ctrl+c 的时候就能杀死当前进程,这是信号的一个例子。

更近一步的,我们需要有一种类似于硬件中断的软件级异步通知机制,让进程在没有事件的时候,该忙啥就忙啥;如果一有事件产生,它能够暂停当前的工作并及时地响应事件,在响应完事件后,恢复当前工作继续执行。

为了实现上述行为就需要为每个信号设置一个处理函数(handler),可以理解为是一种回调函数(callback),当一个特定的信号被触发了就调用对应的处理函数。rCore 实现了 31 个信号,可以被大概分为系统级信号以及用户级信号两个类别,系统级信号的处理函数是由系统指定的,而用户级则是可以给用户自定义处理逻辑。下表列出了 rCore 中的全部系统级信号。

信号 ID 描述
SIGDEF 1 默认信号,与 SIGKILL 的处理函数一样
SIGKILL 1 << 9 杀死进程,比如按下 ctrl+c 就会给程序发送这个信号
SIGCONT 1 << 18 恢复进程
SIGSTOP 1 << 19 暂停进程

那么剩下的全部信号都可以由用户自定义处理函数,在用户不显式指定的情况下信号是直接被忽略。

信号的处理函数等信息保存在操作系统侧,更具体的说是在 Task Control Block(TCB)中保存了一个 SignalActions 的数据结构,它保存了每个信号的处理函数和信号掩码,参见 SignalAction。操作系统暴露一个系统调用,帮助用户显式的注册自己的处理函数,在 rCore 中这个系统调用是 sys_sigaction

数据源可能来自系统也有可能来自其他用户程序,通过 sys_kill 系统调用发送信号。在真正去执行处理函数之前内核会检查两级信号掩码。第一级信号掩码是用户程序的信号掩码,表示这个程序不想接收什么信号,第二级信号掩码是当前正在执行的信号的信号掩码,表明当前信号会屏蔽什么其他的信号。假设说新的信号没有被屏蔽,那么就会被插入到待触发信号队列,本质上这个队列也是通过比特位标记不同的信号,参见 TaskControlBlock::signals

之前我们提到信号是操作系统或者其他用户应用主动发送信息的一种机制,但是从实际实现来看信号消费是一种被动地轮询方案。换句话说操作系统没有为信号实现一种特殊的唤醒机制,而是在进程被调度转入运行态之前,检查 TaskControlBlock::signals 是否有需要被激活的信号。

信号对应的处理函数到底是如何被指定的呢?在说明这个问题之前需要先简要回顾一下与 trap 相关的知识。第一个问题如何从内核态 trap 回用户态。相关的代码保存在 os/src/trap 中,正常情况下在内核态切换到用户态的时候需要:(1)将内核栈替换为用户栈,即设置 sp 寄存器;(2)下一条命令设置为用户的入口地址,这个入口地址保存在 TrapContext::sepc 中,即设置 sepc 寄存器。

信号处理发生在即将返回用户态之前,更具体的是在设置上述寄存器之前,在 rCore 中处理信号的函数是 handle_signals。如果有等待处理的信号,则保存用户的原有 TrapContext,我们称之为 TaskControlBlock::trap_ctx_backup,这个 trap_ctx_backup 用于正常返回用户程序。把真正的 TrapContext(保存在用户内存空间)的 sepc 修改为用户指定的处理函数地址,同时将 TrapContext::x[10] 设置为信号的 ID,x[10] 寄存器就是处理函数的第一个参数。这样被设置好之后,返回的是用户指定的用户指定的函数处理函数了。在用户的处理函数的结尾处显式调用 sys_sigreturn 系统调用就会将 trap_ctx_backup 变为原有的、正常的用户逻辑。

上述说的是用户级信号的处理,系统级信号的处理就更简单了,直接在系统级执行相应的逻辑。

All rights reserved
Except where otherwise noted, content on this page is copyrighted.