lab6 实验报告
思考题
Thinking 6.1
示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?
代码如下
switch(fork()){case-1:break;case0:/* 子进程- 作为管道的写者 */close(fildes[0]);/* 关闭不用的读端 */write(fildes[1],"Hello world\n",12);/* 向管道中写数据 */close(fildes[1]);/* 写入结束,关闭写端 */exit(EXIT_SUCCESS);default:/* 父进程- 作为管道的读者 */close(fildes[1]);/* 关闭不用的写端 */read(fildes[0],buf,100);/* 从管道中读数据 */printf("father-process read:%s",buf);/* 打印读到的数据 */close(fildes[0]);/* 读入结束,关闭读端 */exit(EXIT_SUCCESS);}Thinking 6.2
上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中的dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的dup函数中为什么会出现预想之外的情况?
- 在
dup函数中,dup函数的作用是让新旧文件描述符共享底层的物理内存和状态信息,其中,我们先对复制的是文件描述符的映射关系,然后再对文件描述符中的文件页进行复制 - 如果文件描述符指向一个管道文件,再
dup函数进行过程中,可能出现在复制文件描述符之后、复制文件页之前,文件描述符的pageref等于管道的引用次数 - 如果此时被外部中断,就会产生对引用次数的错误判断,导致
read或write异常终止
例如以下伪代码
pipe(p);if(fork()==0){dup(p[1]);read(p[0]);}else{dup(p[0]);write(p[1]);}若子进程先执行完dup后中断,父进程在执行完dup的文件描述符复制后中断,此时,pageref(p[0]) == pageref(p) == 1,子进程进入read函数后,发现pageref(p[0]) == pageref(p) == 1,使得read被错误的结束
Thinking 6.3
阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明。
- 只有
syscall_*开头的系统调用是原子操作,因为在这些系统调用开始前,通过修改SR寄存器的值,关闭了外部中断,从而在执行这些系统调用时,不会被例如时间片到等外部中断影响 - 复杂系统调用如
read则不是原子操作 - 假设一个进程调用
read(fd, buffer, 1024),试图从一个硬盘文件中读取 1024 字节- 进程从用户态陷入内核态,开始执行
read系统调用 - 内核向硬盘控制器发出指令:读取数据
- 硬盘是一个非常慢的设备,CPU 的速度比它快几个数量级,内核不可能为了等硬盘而停止运转
- 此时,内核会将当前进程的状态从
RUNNING改为BLOCKED(或WAITING),并将它从调度队列中移除 - 然后,调度器 (Scheduler) 会选择另一个处于
READY状态的进程来运行 - 过了很久(几毫秒后),硬盘通过一次硬件中断通知 CPU:“数据准备好了”
- 内核响应中断,将数据从硬件缓冲区复制到用户指定的
buffer,然后将原来那个进程的状态改回READY,等待下一次被调度
- 进程从用户态陷入内核态,开始执行
Thinking 6.4
仔细阅读上面这段话,并思考下列问题
• 按照上述说法控制 pipe_close中 fd和 pipe unmap的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
• 我们只分析了close时的情形,在 fd.c中有一个dup函数,用于复制文件描述符。试想,如果要复制的文件描述符指向一个管道,那么是否会出现与 close类似的问题?请模仿上述材料写写你的理解。
可以解决
- 初始时,
pageref(p[0]) = 2,pageref(p[1]) = 2,pageref(p) = 4 - 子进程运行,先执行完
close(p[1]),解除了p[1]的映射 - 时钟中断,父进程运行,执行完
close(p[0])中解除对 p[0] 的映射后,pageref(p[0]) = 1, pageref(p[1]) = 1, pageref(p) = 3 - 此时不满足写端关闭的条件
- 初始时,
会出现类似的问题,在
Thinking 6.2中已经提到,在要复制的文件描述符指向一个管道时,如果先对文件描述符p[0]进行复制,就会出现pageref(p) == pageref(p[0])的情况
Thinking 6.5
思考以下三个问题。
• 认真回看Lab5文件系统相关代码,弄清打开文件的过程。
• 回顾Lab1与Lab3,思考如何读取并加载ELF文件。
• 在Lab1 中我们介绍了 data text bss 段及它们的含义,data 段存放初始化过的全 局变量,bss段存放未初始化的全局变量。关于memsize和filesize,我们在Note 1.3.4中也解释了它们的含义与特点。关于Note1.3.4,注意其中关于“bss段并不在文 件中占数据”表述的含义。回顾Lab3并思考:elf_load_seg()和load_icode_mapper() 函数是如何确保加载ELF文件时,bss段数据被正确加载进虚拟内存空间。bss段 在ELF中并不占空间,但ELF加载进内存后,bss段的数据占据了空间,并且初始 值都是0。请回顾elf_load_seg() 和 load_icode_mapper() 的实现,思考这一点 是如何实现的?
打开文件的过程:利用
fd = open(prog, O_RDONLY),根据文件名调用open函数,然后调用fsipc_open函数向文件系统进程发出请求;在文件系统进程中,通过serve_open函数,将文件从磁盘加载到内存中,返回共享信息,这时文件打开读取并加载ELF文件:
- 先用
elf_form和readn读出ELF头 - 遍历每个程序头,判断是否为
PT_LOAD类型以是否加载到内存中 - 按段解析完后,利用
elf_load_seg函数,将每个段映射到对应的地址空间中
- 先用
在
lib/elfloader.c中,elf_load_seg完成对bss段的初始化
/* Step 2: alloc pages to reach `sgsize` when `bin_size` < `sgsize`. */while(i<sgsize){if((r=map_page(data,va+i,0,perm,NULL,MIN(sgsize-i,PAGE_SIZE)))!=0){returnr;}i+=PAGE_SIZE;}Thinking 6.6
通过阅读代码空白段的注释我们知道,将标准输入或输出定向到文件,需要 我们将其dup到0或1号文件描述符(fd)。那么问题来了:在哪步,0和1被“安排”为 标准输入和标准输出?请分析代码执行流程,给出答案。
通过console.c中的函数进行处理
intopencons(void){intr;structFd*fd;if((r=fd_alloc(&fd))<0){returnr;// 分配一个新的文件描述符结构}if((r=syscall_mem_alloc(0,fd,PTE_D|PTE_LIBRARY))<0){returnr;// 为文件描述符结构分配一页内存}fd->fd_dev_id=devcons.dev_id;// 将文件描述符与特定的设备关联起来fd->fd_omode=O_RDWR;// 设定文件描述符的打开模式returnfd2num(fd);// 内存地址转换为整数文件描述符编号}首先调用opencons()获得一个指向控制台的文件描述符
接着,使用dup系统调用将这个fd_num复制到 0 号(标准输入 stdin) 和 1 号(标准输出 stdout) 文件描述符上
完成这一步后,shell 以及它后续创建的所有子进程,就都默认将键盘作为标准输入,将屏幕作为标准输出了
Thinking 6.7
在 shell 中执行的命令分为内置命令和外部命令。在执行内置命令时shell不需要fork 一个子shell,如 Linux 系统中的 cd 命令。在执行外部命令时 shell 需要 fork 一个子shell,然后子 shell 去执行这条命令。
据此判断,在MOS 中我们用到的 shell 命令是内置命令还是外部命令?
请思考为什么 Linux 的 cd 命令是内部命令而不是外部命令?
- 在MOS中我们用到的 shell 命令是外部命令,在我们的shell中,其执行过程为调用 readline 读入用户输入的命令;fork 出一个子进程;子进程执行用户的命令,执行结束后子进程结束。父进程在此等待子进程;父进程等待子进程结束后,返回循环开始,读入用户的下一个命令;可见,shell是一个外部命令
- 对于
cd命令,其要求改变父进程的状态,单靠子进程无法完成该命令,因此cd命令是一个内部命令
Thinking 6.8
在你的 shell 中输入命令 ls.b | cat.b > motd。
• 请问你可以在你的shell 中观察到几次spawn?分别对应哪个进程?
• 请问你可以在你的shell 中观察到几次进程销毁?分别对应哪个进程?
- 2次
spawn,分别对应ls.b命令和cat.b命令通过shell创建的进程 - 4次进程销毁,9004、9805、8806、8003分别对应
ls.b、cat,b通过shell创建的进程和通过管道创建的shell进程和main函数创建的shell进程
难点分析
管道
对于判断管道是否关闭的正确性,在非原子操作中,任何操作都有可能被外部中断所影响,都有可能在外部中断的干扰下,产生错误的结果。
pipe_close和dup并非单一、不可分割的硬件指令。它们是由多个步骤组成的函数,例如“解除A的映射”和“解除B的映射”。在这两个步骤之间,存在一个时间间隔,允许进程切换等并发事件插入,破坏数据的一致性对于这个问题的解决,可以通过对于执行逻辑的变换,调整
unmap的顺序,即首先解除fd的映射,再解除pipe的映射,在这种情况下,使得 fd引用次数的-1 先于 pipe,pageref(pipe) > pageref(fd)仍成立,即使此时发生中断,也不会影响判断管道是否关闭的正确性
shell
增加一个通过外部命令驱动的
shell的一个难点在于对于整个MOS操作系统整体的理解,对于spawn函数的实现,我们需要综合运用到之前lab的知识,按照从文件系统打开对应的文件、申请新的进程控制块、将目标程序加载到子进程的地址空间中,并为它们分配物理页面、为子进程初始化地址空间。对于栈空间,由于spawn需要将命令行参数传递给用户程序, 所以要将参数也写入用户栈中、设置子进程的寄存器(栈指针sp和用户程序入口EPC)、将父进程的共享页面映射到子进程的地址空间中、设置子进程可执行这些步骤来完成对于
shell相关的函数spawn:装载新的ELF文件,执行新的程 序sh.c中的主函数:调用 readline 读入用户输入的命令、fork 出一个子进程、子进程执行用户的命令,执行结束后子进程结束。父进程在此等待子进程、父进程等待子进程结束后,返回循环开始,读入用户的下一个命令readline:从标准输入,读入一行用户读入的命令gettoken:将命令字符串分割,提取命令中基本单元parsecmd、runcmd:命令解析与执行函数
dupdup(0, 1)实际上是对文件描述符的复制,其作用是将文件描述符 上是指将输入输出都连接到终端- 在后续需要重定向时,使用 dup 把想要重定向 fd 覆盖到0,1上,也是类似的。通过这样的方法可以实现更高 程度的解耦。
实验体会
Lab6课下实验主要分为两部分
- 第一部分是完善文件系统,为其增加管道这种进程间通信方式,同时在对于管道通信时的操作,也就是对于多进程之间的控制,需要进行一个全面的理解;
- 第二部分是补充shell代码,在MOS操作系统增加一个通过外部命令驱动的shell,首先要实现使用ELF文件创建进程和
spawn函数,随后通过对输入输出的定位,对于输入的命令进行解析,并进行处理
对于整个lab6的课下实验,逻辑较为清晰,也比较容易理解,需要进行补全的代码似乎也是历次课下实验中最少的,但对于整个
shell的狗工作流程以及执行的顺序有一个较清晰的了解,还需要在补全代码之外的进一步分析与整理