Lab6
Keywords:管道,shell
管道
共享内存和管道都是进程间通信(IPC)的方式。管道分为有名管道(可以在任意两个进程之间通信)和匿名管道(只能在具有公共祖先的进程之间使用,通常是在父子进程之间)。
MOS实验中仅要求实现匿名管道
Unix 中的匿名管道
|
在 Unix 中,管道由 int pipe(int fd[2]) 函数创建,成功创建管道返回 0,参数中的 fd用来保存读写端的文件描述符,fd[0] 对应读端,fd[1] 对应写端。通常,调用 pipe 的进程接着调用 fork,这样就创建了从父进程到子进程(或反向)的IPC通道。
匿名管道特点:
- 数据单向流动,具有固定的读端和写端
- 只能用于具有公共祖先的进程之间通信,实现依赖于父子进程文件共享
- 是一种只存在于内存中的文件。父进程调用 pipe函数时,会打开两个新的文件描述符:一个表示只读端,另一个表示只写端,两个描述符都映射到了同一片内存区域。在 fork 的配合下,子进程复制父进程的两个文件描述符,从而在父子进程间形成了四个(父子各拥有一读一写)指向同一片内存区域的文件描述符,父子进程可根据需要关掉自己不用的一个,从而实现父子进程间的单向通信管道,这也是匿名管道只能用在具有亲缘关系的进程间通信的原因。
MOS 中 pipe 的实现
保证负责父子进程通过管道访问的内存相同:在 pipe 中,首先分配两个文件描述符 fd0 和 fd1 并为其分配空间,然后给 fd0 对应的虚拟地址分配一页物理内存,再将 fd1 对应的虚拟地址映射到这一页物理内存。
PTE_LIBRARY
:共享页面是具有权限位 PTE_LIBRARY 的页面,需要保持共享可写的状态,使得父子进程对其进行修改的结果相互可见。当父子进程试图写共享页面时,直接在该页面上进行写操作即可。
int pipe(int pfd[2]) { |
管道的读写
struct Pipe { |
读者在从管道读取大小不超过 n 字节的数据时,拷贝 p_buf[p_rpos%BY2PIPE]
的数据,然后 p_rpos++。注意当缓冲区此时还未写入数据(即管道数据为空,即 p_rpos >= p_wpos)时,若写者进程结束则return,否则进程切换到写者运行。
写者在向管道写入数据时,将数据存入 p_buf[p_wpos%BY2PIPE]
,然后 p_wpos++。注意当缓冲区满溢(即 p_wpos - p_rpos >= BY2PIPE)时,若写者进程结束则return,否则进程切换到读者运行。
static int pipe_read(struct Fd *fd, void *vbuf, u_int n, u_int offset) { |
判断管道一端是否关闭
static int _pipe_is_closed(struct Fd *fd, struct Pipe *p)
判断管道的读者或写者是否已关闭。
原理见教程 P144。
static int _pipe_is_closed(struct Fd *fd, struct Pipe *p) { |
在我们的 MOS 操作系统中,只有 syscall_ 开头的系统调用函数是原子操作,其他所有包括 fork 这些函数在运行时都是可能会被随时打断
对共享变量进行读写需要保证操作原子性:
env_runs
是一个表示环境运行次数的计数器,每当发生一次上下文切换时,它的值就会增加。因此,如果在读取引用计数之前和之后,env_runs
的值没有发生变化,那么可以认为在这期间环境没有发生切换或其他并发操作。
通过比较env_runs
的值,可以实现一种简单的并发控制机制。如果在读取引用计数之前和之后的env_runs
值不同,说明期间发生了环境切换,可能有其他线程或进程同时进行了对管道的读写操作,因此需要重新读取引用计数。
这种简单的机制并不是绝对可靠的,因为在某些情况下,环境可能会发生多次切换,而这段代码只能检测到最后一次切换。但在大多数情况下,这种机制已经足够满足需求,并能减少竞态条件和数据不一致性的发生概率。如果需要更精确的并发控制,可能需要使用更复杂的同步机制,如互斥锁或原子操作。
shell
——实现命令行式 shell
- 解析命令:分析命令的基本单元——特殊符号或单词,解析用户输入的命令
- 执行命令:调用spawn产生子进程并装载命令对应的ELF文件,子进程执行命令,父进程等待子进程执行结束后,结束进程。
思考
1.
示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?
|
2.
上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中的 dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出现预想之外的情况?
dup
函数功能是将一个文件描述符的内容映射到另一个文件描述符中。当我们将一个管道的读/写端对应的文件描述符映射到另一个文件描述符时,映射前 ,若首先 map fd0 ,则 ,则 ,错误判断了写端已经关闭。
3.
阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明。
非原子性操作是因为发生了进程切换。CPU通过定时器产生的时钟中断确定一个时间片结束,从而跳转到内核下的schedule函数判断是否需要切换进程。
阅读指导书P83对 SR 寄存器的描述,每当异常发生时,执行 syscall 指令,CPU 会把 KUc 和 IEc 的数值拷贝到 KUp 和 IEp。随后将 KUc 和 IEc 置为 0。KUc = 0 表示 CPU 当前运行在内核态下, IEc = 0 表示 CPU 当前关闭了中断,也就不会产生时钟中断,那么系统调用就都是原子操作的。
4.
仔细阅读上面这段话,并思考下列问题
- 按照上述说法控制 pipe_close 中 fd 和 pipe unmap 的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
- 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件内容。试想,如果要复制的是一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。
问题1:可以解决,在任何时刻 ,所以先 unmap fd 就不会错误判断两者相等。更具体的分析在指导书 P146。
问题2:会出现类似问题,当我们将一个管道的读/写端对应的文件描述符映射到另一个文件描述符时,映射前 ,若首先 map fd0 ,则 ,则 ,错误判断了写端已经关闭。
5.
思考以下三个问题。
- 认真回看 Lab5 文件系统相关代码,弄清打开文件的过程。
- 回顾 Lab1 与 Lab3,思考如何读取并加载 ELF 文件。
- 在 Lab1 中我们介绍了 data text bss 段及它们的含义,data 段存放初始化过的全局变量,bss 段存放未初始化的全局变量。关于 memsize 和 filesize ,我们在 Note1.3.4中也解释了它们的含义与特点。关于 Note 1.3.4,注意其中关于“bss 段并不在文件中占数据”表述的含义。回顾 Lab3 并思考:elf_load_seg() 和 load_icode_mapper()函数是如何确保加载 ELF 文件时,bss 段数据被正确加载进虚拟内存空间。bss 段在 ELF 中并不占空间,但 ELF 加载进内存后,bss 段的数据占据了空间,并且初始值都是 0。请回顾 elf_load_seg() 和 load_icode_mapper() 的实现,思考这一点是如何实现的?
下面给出一些对于上述问题的提示,以便大家更好地把握加载内核进程和加载用户进程的区别与联系,类比完成 spawn 函数。
关于第一个问题,在 Lab3 中我们创建进程,并且通过 ENV_CREATE(…) 在内核态加载了初始进程,而我们的 spawn 函数则是通过和文件系统交互,取得文件描述块,进而找到 ELF 在“硬盘”中的位置,进而读取。
关于第二个问题,各位已经在 Lab3 中填写了 load_icode 函数,实现了 ELF 可执行文件中读取数据并加载到内存空间,其中通过调用 elf_load_seg 函数来加载各个程序段。在 Lab3 中我们要填写 load_icode_mapper 回调函数,在内核态下加载 ELF 数据到内存空间;相应地,在 Lab6 中 spawn 函数也需要在用户态下使用系统调用为 ELF 数据分配空间。
- 打开文件的过程:
user/lib/file.c
下的open
函数接受文件路径path
和 模式mode
作为输入参数,先调用fd_alloc
申请文件描述符,再调用fsipc_open
向文件系统服务进程发送打开文件的请求,fsipc
向文件系统服务进程发送 IPC 请求,并等待响应。 文件系统服务进程收到请求后调用serve_open
处理请求,serve_open
调用file_open
打开路径并寻找到文件描述符。 - 读取并加载 ELF 文件:load_icode 函数负责加载可执行文件 binary 到进程 e 的内存中。每当
elf_load_seg
函数解析到一个需要加载到内存中的页面,会将有关的信息作为参数传递给回调函数,并由它完成单个页面的加载过程,而这里load_icode_mapper
就是map_page
的具体实现。 - 在
elf_load_seg
函数处理中,当该段在文件中的大小达不到为填入这段内容新分配的页面大小(即bin_size < sgsize
)时,余下的部分用 0 来填充,即为 bss 段的数据赋了初值 0 。
6.
通过阅读代码空白段的注释我们知道,将文件复制给标准输入或输出,需要我们将其 dup 到 0 或 1 号文件描述符 (fd)。那么问题来了:在哪步,0 和 1 被“安排”为标准输入和标准输出?请分析代码执行流程,给出答案。
在 user/init.c
中如下,将0号文件描述符映射到1号,相当于把控制台的输入输出缓冲区当做管道。
if ((r = dup(0, 1)) < 0) { |
7.
在 shell 中执行的命令分为内置命令和外部命令。在执行内置命令时 shell 不需要 fork 一个子 shell,如 Linux 系统中的 cd 命令。在执行外部命令时 shell 需要 fork一个子 shell,然后子 shell 去执行这条命令。
据此判断,在 MOS 中我们用到的 shell 命令是内置命令还是外部命令?请思考为什么Linux 的 cd 命令是内部命令而不是外部命令?
MOS中外部命令。
确保 cd 切换目录仅在当前进程发生,且子进程无法直接修改父进程的环境变量。
8.
在你的 shell 中输入命令 ls.b | cat.b > motd。
- 请问你可以在你的 shell 中观察到几次 spawn ?分别对应哪个进程?
- 请问你可以在你的 shell 中观察到几次进程销毁?分别对应哪个进程?
观察到两次spawn。在 main 函数中,对于每次输入的命令,先 fork 出一个子进程处理命令,该子进程在运行runcmd时会调用一次 spawn;在执行管道时,fork出的子shell进程执行 “|” 后的命令时又进行了一次 spawn。==(notsure)==
观察到四次进程销毁,首先销毁处理管道时fork出的子shell进程在runcmd时的spawn产生的进程,其次销毁管道fork的子进程,然后销毁整条命令作为参数传入runcmd时spawn产生的进程,最后销毁在main函数进程fork出的子进程。