lab6 实验报告 [BUAA-OS]
Lab6 实验报告
Part1. 思考题
Thinking 6.1 父进程为读者
示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?
1 |
|
Thinking 6.2 dup 中的进程竞争
上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中的 dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出现预想之外的情况?
dup 实现里,复制一个文件描述符 oldfd 到 newfd ,总共分两步调用 syscall_mem_map
来完成:
- 映射文件描述符(struct Fd)
- 映射文件内容对应的内存页(page mapping)
这两步会分别对引用计数 pp_ref 做一次“+1”操作。如果在这两次映射之间发生了进程切换,就可能出现下面这种情况:
假设有一个 pipe,引用次数:pp_ref=2,fd[0]=1,fd[1]=1。进程 A 在执行 dup(oldfd, newfd)
:
- 第一条
syscall_mem_map(… oldfd → newfd …)
已经执行完毕,fd[0]引用计数被+1,此时 fd[0]=2, - 紧接着要执行第二条映射(内容页映射),此时pp_ref 还未+1,但刚好被打断,调度到进程 B。
此时进程 B 中如果执行 _pipe_is_closed(fd, p)
则会根据 pp_ref == fd[0] == 2 错误地判断写端关闭,导致错误行为。
Thinking 6.3 原子操作
阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明。
系统调用会陷入内核态,在内核态下,CPU 会屏蔽中断,这样就可以保证系统调用的原子性。也就是说,在执行系统调用期间,CPU 不会被时钟中断打断,因此不会发生上下文切换。
1 | // in entry.S |
Thinking 6.4 解决进程竞争
仔细阅读上面这段话,并思考下列问题
• 按照上述说法控制 pipe_close 中 fd 和 pipe unmap 的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
• 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件描述符。试想,如果要复制的文件描述符指向一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。
- 在
pipe_close
函数中,按照上述说法控制fd
和pipeunmap
的顺序,可以解决进程竞争问题。具体来说,我们可以先关闭文件描述符fd
,然后再解除管道的映射pipe
。这样做的好处是,始终保证了pageref(pipe) > pageref(fd)
,从而避免了发生中断时出现二者相等的情况。 - 在
dup
函数中会出现与close
类似的问题。因为在dup
函数中,复制文件描述符时也会涉及到对管道的引用计数和映射操作。如果在复制过程中发生了进程切换,就可能导致类似的竞争条件。因此,在实现dup
函数时,也需要首先让pipe->pp_ref++
,从而确保pageref(pipe) > pageref(fd)
,避免在进程切换时出现二者相等的情况。
Thinking 6.5 如何加载 bss 段
思考以下三个问题。
• 认真回看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() 的实现,思考这一点是如何实现的?
- 打开文件的过程主要涉及到文件系统的用户接口和内核实现。用户进程通过调用 user/lib/file.c 中的
open()
请求打开一个文件,函数接着调用fsipc
模块的fsipc_open()
函数,向文件系统发送 IPC 请求。文件服务进程接收到请求后,通过serve
函数调用file_open()
来处理打开文件的逻辑。 - 加载 ELF 文件的过程主要由 kern/env.c 中的
load_icode()
函数实现。该函数首先通过elf_from
读取 ELF 文件头,然后根据 ELF 文件头中的段信息,调用elf_load_seg()
函数来加载各个段到虚拟内存中:映射文件中真实数据到 [p_vaddr, p_vaddr + p_filesz) 的虚拟地址空间,并将未初始化的 bss 段映射到 [p_vaddr + p_filesz, p_vaddr + p_memsz) 的虚拟地址空间,将 bss 段的初始值设置为 0。 - elf_load_seg() 处理每个段时,会通过调用
load_icode_mapper()
函数来映射文件到对应的虚拟内存地址空间。对于 bss 段,虽然在 ELF 文件中并不占用实际数据,但在加载时会根据p_memsz
的值为 bss 段分配足够的虚拟内存空间,并将其初始化为 0。这是通过为 bss 段不断分配页面并 在load_icode_mapper()
中将 bss 段的内存区域置零来实现的。
Thinking 6.6 内置命令和外部命令
通过阅读代码空白段的注释我们知道,将标准输入或输出定向到文件,需要
我们将其dup到0或1号文件描述符(fd)。那么问题来了:在哪步,0和1被“安排”为标准输入和标准输出?请分析代码执行流程,给出答案。
1 | // in user/init.c |
Thinking 6.7 标准输入和标准输出
在 shell 中执行的命令分为内置命令和外部命令。在执行内置命令时shell不
需要fork 一个子 shell,如 Linux 系统中的 cd 命令。在执行外部命令时 shell 需要 fork 一个子 shell,然后子 shell 去执行这条命令。
据此判断,在MOS 中我们用到的 shell 命令是内置命令还是外部命令?请思考为什么 Linux 的 cd 命令是内部命令而不是外部命令?
查看 user/sh.c 可知,MOS 中所有的 shell 命令都是外部命令。因为每个命令都需要通过 fork()
创建一个子进程来执行,子进程会调用 runcmd()
函数来处理命令。
Linux 的 cd
命令是内部命令而不是外部命令,因为它直接修改了当前 shell 进程的工作目录。执行 cd
命令时,shell 需要更新其内部状态,而不是创建一个新的子进程来执行该操作。这样可以避免不必要的资源开销和上下文切换,提高效率。
Thinking 6.8 解释命令执行的现象
在你的 shell 中输入命令 ls.b | cat.b > motd。
• 请问你可以在你的shell 中观察到几次spawn?分别对应哪个进程?
• 请问你可以在你的shell 中观察到几次进程销毁?分别对应哪个进程?
1 | ls.b | cat.b > motd |
-
spawn 2次,分别是 00002803 进程spawn 00003805 进程和 00003004 进程 spawn 00004006 进程。
-
进程销毁 4 次,分别对应:
- 00003805 进程(00002803 fork 出的子进程)解析并执行
ls.b
完毕后销毁。 - 00004006 进程(00003004 fork 出的子进程)执行管道右侧命令
cat.b
完毕后销毁。 - 00003004 进程(00002803 fork 出的子进程)解析并执行
cat.b > motd
完毕后销毁。 - 00002803 进程(主shell 的子进程)解析并执行
ls.b | cat > motd
完毕后销毁。
- 00003805 进程(00002803 fork 出的子进程)解析并执行
Part 2. 难点分析
Lab6 的实验设计主要围绕Shell的实现以及管道通信展开,实验的难点主要体现在以下几个方面:
- Shell 的启动与执行:需要理解进程的创建、执行和销毁过程,以及如何解析用户输入的命令,清楚Shell的函数调用流程。
- 管道通信的实现:需要掌握如何在进程间建立管道,如何通过管道传递数据,以及如何处理管道的读写端。
- 文件描述符的管理:需要加深Lab5中文件描述符的概念,如何通过
dup
函数复制文件描述符,以及如何在进程间共享文件描述符。
Part 3. 实验体会
Lab6 是一个综合性较强的实验,通过实现Shell和管道通信深入理解操作系统中进程管理、文件描述符和进程间通信的机制,也完成了整个MOS系统的内容,我们也从头体验自己动手完成一个小操作系统的乐趣,收获颇丰。