Lab6实验报告
一、思考题
1、示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想 让父进程作为“读者”,代码应当如何修改?
修改后代码如下:
test_pipe.c
1 #include <stdlib.h>
2 #include <unistd.h>
3
4 int fildes[2];
5 char buf[100];
6 int status;
7
8 int main(){
9
10 status = pipe(fildes);
11
12 if (status == -1 ) {
13 printf("error\n");
14 }
15
16
17 switch (fork()) {
18 case -1:
19 break;
20
30 case 0: /* 子进程 - 作为管道的写者 */
31 close(fildes[0]); /* 关闭不用的读端 */
32 write(fildes[1], "Hello world\n", 12); /* 向管道中写数据 */
33 close(fildes[1]); /* 写入结束,关闭写端 */
34 exit(EXIT_SUCCESS);
22 default: /* 父进程 - 作为管道的读者 */
23 close(fildes[1]); /* 关闭不用的写端 */
24 read(fildes[0], buf, 100); /* 从管道中读数据 */
25 printf("child-process read:%s",buf); /* 打印读到的数据 */
26 close(fildes[0]); /* 读取结束,关闭读端 */
27 exit(EXIT_SUCCESS);
28
29
35 }
36 }
2、上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中 的 dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出 现预想之外的情况?
假如在执行完syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))语句之后立刻切换到该管道的另一个同为读或写的进程执行,此时该进程通过pageref(pipe)得到pipe页面的引用次数为2,又因为pageref(oldfd)也变成了2,两者相等,该进程调用_pipe_is_closed后得出管道另一侧关闭的结论,从而造成程序运行错误。
3、阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是 所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明。
因为进入系统调用时系统会陷入内核态,此时系统会关闭所有中断来防止其他进程的干扰,具体来说就是设置SR寄存器的中断屏蔽位。因此所有的系统调用一定是原子操作。
4、仔细阅读上面这段话,并思考下列问题
• 按照上述说法控制 pipe_close 中 fd 和 pipe unmap 的顺序,是否可以解决上述场 景的进程竞争问题?给出你的分析过程。
• 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件内容。 试想,如果要复制的是一个管道,那么是否会出现与 close 类似的问题?请模仿上 述材料写写你的理解。
1、可以解决。因为在正常情况下pageref(fd) < pageref(pipe),只要我们在解除映射时先解除fd的映射,就能保证pageref(fd)更小,不会存在两者相等的中间情况,因此可以解决上述场景竞争问题。
2、会出现类似问题。
原因:假如在执行完syscall_mem_map(0, oldfd, 0, newfd, vpt[VPN(oldfd)] & (PTE_D | PTE_LIBRARY))语句之后立刻切换到该管道的另一个同为读或写的进程执行,此时该进程通过pageref(pipe)得到pipe页面的引用次数为2,又因为pageref(oldfd)也变成了2,两者相等,该进程调用_pipe_is_closed后得出管道另一侧关闭的结论,从而造成程序运行错误。
解决方案:与close中的解决方案类似,我们只要保证不会出现pageref(fd) == pageref(pipe)的中间情况即可,因此只需要让引用次数更多的pipe首先映射,实现“大者更大”,这样就不不可能出现两者相等的中间情况。
5、思考以下三个问题。
• 认真回看 Lab5 文件系统相关代码,弄清打开文件的过程。
• 回顾 Lab1 与 Lab3,思考如何读取并加载 ELF 文件。
• 在 Lab1 中我们介绍了 data text bss 段及它们的含义,data 段存放初始化过的全 局变量,bss 段存放未初始化的全局变量。关于 memsize 和 filesize ,我们在 Note 1.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() 的实现,思考这一点 是如何实现的?
对于bss在ELF中不占空间,但是在内存中占据空间的问题,是在elf_load_seg函数中实现的,在该函数中,对于bin_size < sgsize的情况,直接用0填充使得程序占据空间达到sgsize,这就使得bss中所有的值被赋为0;
52 while (i < sgsize) {
53 if ((r = map_page(data, va + i, 0, perm, NULL, MIN(bin_size - i, BY2PG))) != 0) {
54 return r;
55 }
56 i += BY2PG;
57 }
6、通过阅读代码空白段的注释我们知道,将文件复制给标准输入或输出,需要 我们将其 dup 到 0 或 1 号文件描述符 (fd)。那么问题来了:在哪步,0 和 1 被“安排”为 标准输入和标准输出?请分析代码执行流程,给出答案。
是在user/init.c中实现的,具体代码如下:
45 // stdin should be 0, because no file descriptors are open yet
46 if ((r = opencons()) != 0) {
47 user_panic("opencons: %d", r);
48 }
49 // stdout
50 if ((r = dup(0, 1)) < 0) {
51 user_panic("dup: %d", r);
52 }
7、在 shell 中执行的命令分为内置命令和外部命令。在执行内置命令时 shell 不 需要 fork 一个子 shell,如 Linux 系统中的 cd 命令。在执行外部命令时 shell 需要 fork 一个子 shell,然后子 shell 去执行这条命令。 据此判断,在 MOS 中我们用到的 shell 命令是内置命令还是外部命令?请思考为什么 Linux 的 cd 命令是内部命令而不是外部命令?
在MOS中我们用到的shell命令是外部命令,因为我们在解析和执行命令时会fork一个子shell。
linux的cd命令之所以为内部命令,是因为cd命令不是一个独立的可执行文件,而是由shell的一个内置函数实现的,在解析到cd命令时,只需要调用该函数即可,这样可以提高系统的运行效率。
8、在你的 shell 中输入命令 ls.b | cat.b > motd。
• 请问你可以在你的 shell 中观察到几次 spawn ?分别对应哪个进程?
• 请问你可以在你的 shell 中观察到几次进程销毁?分别对应哪个进程?
输出如下:
$ ls.b | cat.b > motd
[00002803] pipecreate
[00003805] destroying 00003805
[00003805] free env 00003805
i am killed ...
[00004006] destroying 00004006
[00004006] free env 00004006
i am killed ...
[00003004] destroying 00003004
[00003004] free env 00003004
i am killed ...
[00002803] destroying 00002803
[00002803] free env 00002803
i am killed ...
1、可以观察到2次spawn,分别对应3805和4006进程,也就是ls.b命令和cat.b命令的两个进程。
2、可以观察到4次进程销毁,分别对应3805、4006、3004、2803四个进程,即ls.b命令进程、cat.b命令进程、管道fork出的子shell进程以及开始main函数中fork出的子shell进程。
二、重难点分析
本次实验主要分为管道的实现和shell的实现两个部分,并且前一部分的实现是后一部分的基础。在我看来,完成这两部分都有不小的难度,尤其是后一部分完成spawn函数的时候需要重新梳理前面所有lab的完成思路,并且能够正确使用之前已经实现的函数,尽管是在指导书的步步提醒之下,完成这一部分也绝非易事。
对于管道部分,我认为最关键的还是一个抽象的思维方式,只要我们能够将管道抽象为一块内存区域或者说是一个文件,假设它有两个端口,两端都能对这块区域进行改写,并且一端可以从这里拿走另一端写入的数据,这样就能够理解管道的本质(简单地说也就是下面这幅图)。
理解管道的工作原理之后,我们实现起来也就没有那么困难了,只要利用之前文件系统中已经实现的文件描述符以及对文件进行读写或者打开关闭等各种操作的函数就能够实现管道的要求。但是,实现这点之后还不够,因为对管道的操作非原子操作,其中就可能出现父子进程操作管道时发生冲突的情况,具体体现在对另一端管道是否已经关闭的判断上。为了解决这一问题,我们又分析了判断管道关闭时pageref(fd) == pageref(pipe)
这一条件可能出现的异常情况,然后通过调整关闭管道解除文件映射时unmap、map等的顺序避免了这个问题。
对于shell部分,由于这一部分是我们OS课设的最终成果,因此也是和之前的实验关联度最高,实现起来最为复杂的部分。尤其是spawn函数的实现,不仅涉及到文件的打开,还涉及到将ELF加载到内存并且创建新进程的过程,这需要我们能够对之前实现的各个主要函数的作用非常了解,同时还要考虑到很多的细节。
至于最终对shell的实现,我只能说_课程组它给的太多了,我哭死_,因为这一部分基本就是课程组已经帮我们写好了,尽管很难,但是我们并没有真正完成多少。但是理解shell的运行过程也是比较不容易,因为尤其是这还涉及到内置命令和外部命令的问题。要想理解shell的启动执行过程,下面这张图有一定帮助:
我们需要掌握的是,负责解析命令的其实是我们的shellfork出来的一个子shell,然后在遇到各种命令时,子shell又通过spawn创建新的进程去执行具体的命令。至于遇到管道|
命令,子shell还得再fork一次,让它的子shell去解析右侧命令,它自己负责解析左侧命令,最后一层层合并给出最终执行结果。
三、实验心得体会
本次实验作为OS课设的收官之作,比前面任何一次实验都更加关注操作系统的全局性,同时抽象的程度相比之下也最高。无论是管道的实现还是shell的实现,都需要我们对他们的实现过程有一个清晰的理解,在脑海中形成一种逻辑图去擘画它们的具体实现方式。与此同时,在实现的过程中我们要有一种面向对象
的思维方式,就是将某一部分功能丢给我们已经实现过的函数,因为本次实验更多的还是对之前实验结果的组合与封装,不能太拘泥于实现的细节,只有这样才能从宏观角度出发去理解这两部分最抽象的内容。
但是,得益于伟大的课程组,我在完成本次实验的过程中还是比较顺利的,因为自己填的空还是比较少的,还可能是因为之前的实验没有出现重大的漏洞,所以我也没有花费时间去找之前存在的bug,整体比较欣慰。
总而言之,OS课设的基本环节到这里就告一段落了,在最终看到我的MOS欢迎界面成功出现的时候,内心还是起了些许波澜。这一学期中我在实验中还是收获了不少的东西,当然,最令我欣慰的一点还是我对抽象
这一思想的理解更加透彻了,尽管在之后的学习生活中我可能不再会记得内存是如何管理的、进程是如何调度的、文件系统是怎么搭建的、管道和shell又是怎么跑起来的,但是,我一定不会忘了时时刻刻将具体的东西抽象为我能够理解的东西,利用抽象去简化我遇到的任何难题……
在此作别。Hope everything will be better!