0%

OS_Lab6-实验报告

OS_Lab6_实验报告

PS: 该实验报告作为个人感悟将同步至我的个人博客网站

一. 思考题

Thinking 6.1 如果不关闭自己不会用到的管道端会怎么样?
如果不关闭不使用的管道端,会导致对应文件描述符(fd)的引用计数无法正确清零。因为我们在判断管道一端是否关闭时,依赖的是 pageref(fd) == pageref(pipe)。如果例如读者没有关闭写端,那么即使真正的写进程退出了,管道数据结构中的写端引用计数仍不为0。这会导致读者在读取空管道时,误以为还有写者存活,从而陷入死循环的阻塞等待(syscall_yield)中,永远无法读到 EOF 并退出。

Thinking 6.2 示例代码中,父进程操作管道的写端,子进程操作管道的读端。如果现在想让父进程作为“读者”,代码应当如何修改?
需要将父子进程中关闭端口和读写操作的逻辑对调:

  • 父进程(读者)修改为
1
2
3
4
5
close(fildes[1]); 
read(fildes[0], buf, 100);
printf("parent-process read:%s", buf);
close(fildes[0]);

  • 子进程(写者)修改为
1
2
3
4
close(fildes[0]); 
write(fildes[1], "Hello world\n", 12);
close(fildes[1]);

Thinking 6.3 MOS 中的管道缓冲区大小 PIPE_SIZE 过小有什么影响?缓冲区大小是怎么影响吞吐和切换效率的?

  • 影响:如果 PIPE_SIZE 过小(如 MOS 中仅为 32 Byte),写者会非常容易将缓冲区写满进而被阻塞,读者也会频繁读空缓冲区而阻塞。
  • 对吞吐和切换效率的影响:缓冲区过小会导致父子(读写)进程之间发生极其频繁的进程调度与上下文切换(Context Switch)。由于进程切换本身需要保存和恢复现场,这会带来巨大的时间开销,使得真正用于数据拷贝的 CPU 时间占比下降,最终导致整个系统的吞吐量急剧降低,切换效率低下。

Thinking 6.4 上面这个恒等式 (pageref(rfd) + pageref(wfd) == pageref(pipe)) 在所有时刻都成立吗?请给出你的分析过程,如果不成立,是否会造成误判?

  • 不一定在所有时刻成立
  • 分析过程:修改页面引用计数(map/unmap 操作)并不是一步完成的,而是对 fd 页面和 pipe 页面分别进行系统调用。由于在两次独立的系统调用之间可能会发生时钟中断,导致进程被调度走,因此在这两个操作的间隙,恒等式是不成立的。
  • 造成误判:会。例如,在写端 close 过程中,如果先 unmappipe,此时 pageref(pipe) 减小了,但 pageref(wfd) 还没减。如果此时切换到读进程,读者检查到 pageref(rfd) == pageref(pipe) 恰好成立(实际上写端还没完全关掉),就会误判写端已关闭而提前退出。

Thinking 6.5 上面这种不同步修改 pp_ref 而导致的进程竞争问题在 user/lib/fd.c 中的 dup 函数中也存在。请结合代码模仿上述情景,分析一下我们的 dup 函数中为什么会出现预想之外的情况?

  • dup 函数的作用是复制文件描述符,它需要依次 map 文件描述符页面(fd)和对应的管道数据缓冲区(pipe),这两个操作都会使对应的 pp_ref 增加1。
  • 情景:假设读者进程想要 dup 读端。如果它先 mapfd 页,此时 pageref(fd) 增加了1,但还没来得及 map pipe 页(引用未增加),时钟中断发生,切到了原本阻塞的写者进程。写者在判断读端是否关闭时,发现 pageref(wfd) == pageref(pipe) 居然成立了(因为 pipe 还没加1,导致等式意外左右相等),写者就会误以为读端已经全部关闭,从而错误地退出了。

Thinking 6.6 为什么在我们的 MOS 中,系统调用一定是原子操作呢?如果你觉得不是所有的系统调用都是原子操作,请给出反例。

  • 在我们的 MOS 系统中,以 syscall_ 开头的内核态系统调用函数是原子操作
  • 原因分析:MIPS 架构在触发异常进入内核态(执行 syscall)时,硬件会自动关闭中断(通过修改 CP0 的 STATUS 寄存器禁止中断),或者在陷入内核时的汇编入口中显式屏蔽了时钟中断。这意味着在内核执行这些 syscall_ 函数体时,是不可能被时钟中断打断并发生进程切换的,因此它们相对用户进程而言具备原子性。
  • 反例澄清:对于用户态的库函数(如 pipe_closeforkdup),它们内部包含了多个 syscall_ 调用,这些用户态函数不是原子操作,可能在两次 syscall 之间被打断。

Thinking 6.7 仔细阅读上面这段话,并思考下列问题

  • 控制 pipe_close 中的 unmap 顺序:可以解决。我们保证在 close 时,**先 unmap fd,再 unmap pipe**。这样即使中间被打断,pageref(pipe) 总是大于等于 pageref(fd) 的,此时判断管道关闭的条件 pageref(pipe) == pageref(fd) 绝对不可能意外成立,从而避免了读者的误判。
  • dup 函数中的 map 顺序:同理,会出现类似问题。为了保证在打断时 pageref(pipe) >= pageref(fd),我们在 dup 增加引用计数时,应当**先 map pipe,然后再 map fd**。这样即使在中间态发生切换,pipe 的引用计数已经升上去了,绝不会触发误判关闭的相等条件。

Thinking 6.8 思考以下三个问题。 (关于文件系统,ELF加载与bss段)
elf_load_seg()load_icode_mapper() 确保了 bss 段正确加载。在 ELF 文件中,bss 段不占据实际的物理磁盘空间,其在程序头中的表现为:memsz(在内存中需要的空间)严格大于 filesz(在文件中实际存放的数据大小)。
实现机制上:我们在分配虚拟内存时,根据 memsz 申请足够的内存页。然后从文件中拷贝出大小为 filesz 的数据到内存中,对于剩余的 memsz - filesz 这部分空间(即 bss 段),使用类似 bzero 的操作将其全部显式清零,从而满足了未初始化全局变量初始值为0的要求。

Thinking 6.9 在哪步,0 和 1 被“安排”为标准输入和标准输出?
通过阅读 user/init.c(即 init.b 的源码)可知:进程刚启动时,文件描述符表是空的。进程首先调用 opencons() 函数打开控制台,由于这是进程打开的第一个文件,系统自动分配了当前最小的空闲描述符,即 fd 0(标准输入)。紧接着,代码调用了 dup(0, 1),将描述符 0 映射到了描述符 1 上。至此,0和1都被“安排”好了,分别对应控制台的输入和输出。

Thinking 6.10 在 MOS 中我们用到的 shell 命令是内置命令还是外部命令?为什么 Linux 的 cd 命令是内部命令?

  • 在 MOS 中,我们使用的 lscatecho 都是外部命令(因为系统会为它们在磁盘上找到对应的 .b 文件,并通过 spawn 为其创建一个全新的子进程去执行)。
  • Linux 的 cd 必须是内部命令(内置命令)。因为 cd 的作用是改变当前进程的工作目录。如果把 cd 作为外部命令来 fork 一个子进程执行,子进程确实改变了它自己的工作目录,但在执行完毕退出后,父进程(shell 本身)的工作目录仍然是原来的样子,这完全违背了 cd 命令的设计初衷。

Thinking 6.11 在你的 shell 中输入命令 ls.b | cat.b > motd。观察到几次 spawn,几次销毁?

  • 几次 spawn:观察到 2 次 spawn。分别对应创建 ls.b 进程和 cat.b 进程。
  • 几次进程销毁:观察到 3 次 进程销毁。
  1. ls.b 执行完后自我销毁。
  2. cat.b 执行完后自我销毁。
  3. 因为有管道符号 |,shell 本身会 fork 出一个子 shell 来解析和执行管道右侧的指令。当右侧指令完毕后,这个临时创建的子 shell 也会销毁。

二. 难点分析

在 Lab6 中,最大的难点在于管道中的进程竞争问题以及 Shell 命令解析递归树的构建

  1. 管道竞争机制的脑内推理(Mind Map 替代方案)
    对于 _pipe_is_closed 引起的数据竞争,初看非常反直觉。我们需要在脑海中建立一个“引用计数时间线”。
  • 难点突破点:理解 syscall_ 的原子性与用户态库函数(非原子性)的区别。
  • 解决策略逻辑树:
  • 目标:不触发错误的 pageref(pipe) == pageref(fd) 判定。
  • 情况A (Close减少引用):必须让 fd 跑在前面(更早变小),所以顺序是 unmap(fd) -> unmap(pipe)。间隙状态:pipe > fd(安全)。
  • 情况B (Dup增加引用):必须让 pipe 跑在前面(更早变大),所以顺序是 map(pipe) -> map(fd)。间隙状态:pipe > fd(安全)。
  1. Shell 命令解析 (parsecmd) 的执行流
    parsecmd 的递归与 fork 结合是另一个难点。
  • 当读取到命令 A | B 时:
  1. 父 Shell 构建 pipe
  2. 父 Shell 执行 fork()
  3. 父进程行为:重定向输出到写端 -> runcmd(A) -> spawn(A) -> 等待子 Shell 结束。
  4. 子进程(子 Shell)行为:重定向输入到读端 -> **递归调用 parsecmd(B)**(处理 B 之后可能还挂着重定向符号) -> 最终 runcmd(B) -> 销毁自身。
    理清了这条从左到右、递归解析右子树的逻辑流程图,补全 parsecmd 的空缺代码便水到渠成了。

三. 实验体会

相比于 Lab0 刚接触命令行时的手忙脚乱,到了 Lab6,我感觉自己在系统底层的俯视感越来越强了。

这次实验把前几个 Lab 学到的知识完美地串联了起来:进程的 fork 与调度(Lab3)、共享内存机制的使用(Lab4)、文件系统的底层交互(Lab5),最终在 Lab6 汇聚成了一个肉眼可见、可交互的 Shell 和可以真正通信的管道。特别是用 spawn 读取硬盘上的 ELF 文件并分配内存的过程,让我彻底搞懂了从一行文本命令到可执行进程诞生中间到底发生了什么魔法。

其中最让我痛苦但也最有收获的,莫过于分析管道的竞争条件。作为一个经常在用户态写代码的计科生,以前很少考虑到几行连续的 C 代码中竟然会穿插时钟中断,还会因为执行顺序的微调而导致程序死锁或崩溃。把 mapunmap 的顺序颠倒过来就能化解一次致命的进程竞争,这种精巧的设计令人拍案叫绝。这也为后续面对更复杂的操作系统并发和内存管理问题打下了一剂强心针。总体来说,虽然查 BUG 的过程“苦也苦也”,但看到 MOS 终端跑出绿色的 pipe tests passed 时,成就感无与伦比。