BUAA-OS-2023-Lab6-Report
BUAA-OS-2023-Lab6-Report
Thinking 6.1
- 示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?
原有代码的分支语句如下:
switch (fork()) { |
其中,作为读者,子进程关掉了管道写端;相应的,父进程关掉了管道读端。那么我们只要将关闭的端交换,并修改写入/读取语句即可实现要求,即:
switch (fork()) { |
Thinking 6.2
- 上面这种不同步修改
pp_ref
而导致的进程竞争问题在user/lib/fd.c
中的dup
函数中也存在。请结合代码模仿上述情景,分析一下我们的dup
函数中为什么会出现预想之外的情况?
当我们调用 dup
函数时,会在进程中创建一个新的文件描述符 newfd
,这个文件描述符指向 oldfd
所拥有的文件表项,也就是在用户态中复制了一个文件的描述符。
实际上在执行复制的过程中,我们并不能一步把所有的数据都复制完,实际上是先对 fd
使用 syscall_mem_map
进行复制,再对它所属的 data
复制。
现在假设一个情景:子进程 dup(pipe[1])
后 read(pipe[0])
,父进程 dup(pipe[0])
后 write(pipe[1])
。
先令子进程执行:顺序执行至 dup 完成后发生时钟中断,此时 pageref(pipe[1]) = 1
,pageref(pipe) = 1
随后父进程开始执行:执行至 dup 函数中 fd 和 data 的 map 之间,此时 pageref(pipe[0]) = 1
,pageref(pipe) == 1
子进程再次开始执行:进入 read 函数,判断发现 pageref(pipe[0]) == pageref(pipe)
这个非同步更改的 pageref
和管道关闭时的等式一致,这里会让 read
函数认为管道中已经没有了写者,于是关闭了管道的读端。
Thinking 6.3
- 阅读上述材料并思考:为什么系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。希望能结合相关代码进行分析说明
我认为系统调用是原子操作。因为系统调用开始前,通过修改 SR 寄存器的值,关闭了外部中断,而在执行内核代码时,合理的内核设计应保证不出现其它类型的异常。所以这使得系统调用成为了原子操作。
Thinking 6.4
- 仔细阅读上面这段话,并思考下列问题
- 按照上述说法控制 pipe_close 中fd 和 pipe unmap 的顺序,是否可以解决上述场景的进程竞争问题?给出你的分析过程。
- 我们只分析了 close 时的情形,在 fd.c 中有一个 dup 函数,用于复制文件内容。试想,如果要复制的是一个管道,那么是否会出现与 close 类似的问题?请模仿上述材料写写你的理解。
- 可以解决上述问题。
- 最初
pageref(pipe[0]) = 2
,pageref(pipe[1]) = 2
,pageref(pipe) = 4
- 子进程先运行,执行
close
解除了pipe[1]
的文件描述符映射 - 发生时钟中断,此时
pageref(pipe[0]) = 2
,pageref(pipe[1]) = 1
,pageref(pipe) = 4
- 父进程执行完
close(pipe[0])
后,pageref(pipe[0]) = 1
,pageref(pipe[1]) = 1
,pageref(pipe) = 3
- 可以发现此过程中不满足写端关闭的条件
- 最初
- 在
Thinking 6.2
中用到的样例就体现了问题发生的原理。如果先映射作为fd
的pipe[0]
,就会暂时产生pageref(pipe) == pageref(pipe[0])
的情况,会出现类似问题。
Thinking 6.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() 的实现,思考这一点是如何实现的?
- 打开文件的过程:
- 根据文件名,调用用户态的
open
函数,其申请了一个文件描述符,并且调用了服务函数fsipc_open
,利用fsipc
包装后向文件服务进程发起请求 - 文件服务进程接收到请求后分发给
serve_open
函数,创建Open
并调用file_open
函数从磁盘中加载到内存中,返回共享的信息,文件打开
- 根据文件名,调用用户态的
- 加载 ELF 文件:
- 在进程中打开 ELF 文件后,先创建子进程,初始化其堆栈,做好前置工作
- 按段(Segment)解析 ELF 文件,利用
elf_load_seg
函数将每个段映射到子进程的对应地址空间中,在函数执行过程中,会对在文件中不占大小、在内存中需要补 0 的.bss
段数据进行额外的映射(总文件大小与已经映射的大小的差值即为.bss
段大小:追加在文件部分之后,并填充为 0) - 实际的映射函数是
spwan_mapper
,它利用syscall_mem_map
将数据从父进程映射到子进程中,完成 ELF 文件的加载
Thinking 6.6
- 通过阅读代码空白段的注释我们知道,将文件复制给标准输入或输出,需要我们将其 dup 到 0 或 1 号文件描述符 (fd)。那么问题来了:在哪步,0 和 1 被“安排”为标准输入和标准输出?请分析代码执行流程,给出答案。
注释中进行了如下标记:
// Open 't' for reading, dup it onto fd 0, and then close the original fd. |
这意味着用于 reading
的文件描述符会被 dup
到 fd[0]
,过程如下:
// Open 't' for reading, dup it onto fd 0, and then close the original fd. |
映射 writing
部分的描述符操作类似
Thinking 6.7
- 在 shell 中执行的命令分为内置命令和外部命令。在执行内置命令时 shell 不需要 fork 一个子 shell,如 Linux 系统中的 cd 命令。在执行外部命令时 shell 需要 fork 一个子 shell,然后子 shell 去执行这条命令。
- 据此判断,在 MOS 中我们用到的 shell 命令是内置命令还是外部命令?请思考为什么 Linux 的 cd 命令是内部命令而不是外部命令?
我们用到的 shell 命令均属于外部命令。在 shell 运行过程中,我们对指令调用 runcmd
进行处理,其内部调用了 parsecmd
进行解析,在指令解析后直接利用这个指令 spwan
了一个子进程。
int child = spawn(argv[0], argv); |
这也就是说,无论执行任何指令,MOS 中的 shell 都会将这个流程解析为:创建子进程、运行指令所指向的文件、完成所需功能
Thinking 6.8
- 在你的shell 中输入命令ls.b | cat.b > motd。
- 请问你可以在你的shell 中观察到几次spawn ?分别对应哪个进程?
- 请问你可以在你的shell 中观察到几次进程销毁?分别对应哪个进程?
终端输出如下:
$ ls.b | cat.b > motd |
- 可以观察到2次
spawn
:3805 和 4006 进程,这是 ls.b 命令和 cat.b 命令通过 shell 创建的进程 - 可以观察到4次进程销毁:3805、4006、3004、2803,按顺序是 ls.b 命令、cat.b 命令 spawn 出的进程、通过管道创建的 shell 进程和 main 函数的 shell 进程。
实验总结
Lab6 实验共分为两部分。
第一部分是完善文件系统,为其增加管道、控制台两种文件属性,便于下一部分进行 shell 中命令等的传递。这其中需要注意的是非原子操作的进程安全问题,由于管道检测和 dup
函数的非原子性,可能会导致出现管道状态的错误判断,我们需要对其进行进程安全的保护。
第二部分是补充 shell 的相关代码,在 MOS 系统中实现一个通过外部命令驱动的 shell 。我们在代码中首先完成了使用指定 ELF 文件创建进程的 spawn
函数,这是我们 shell 创建子进程并实现功能的重点。随后通过解析输入命令,实现了对用户从终端输入命令的执行。但是在这个过程中我们填写的代码很少,也许不能有效地理清整个 shell 工作函数执行的顺序,还需要更进一步的分析和整理。